Timers and frozen canisters - cryostasis

I’ll make a new dedicated topic for this because, in my opinion, it’s one of the last huge pains of IC development. I’d even call it the last archenemy of IC developers.

If you have timers in your canister, whenever it gets frozen because it runs out of cycles, those timers stop and do not resume once the canister is topped up. This requires someone to manually deploy the canister and change something so the Wasm hash changes, allowing the upgrade to go through.

This has happened to me around 15 times, and every time it’s extremely annoying, especially if you have a lot of canisters. Even if a canister is ~5 months old, you may run into build issues, then have to update dependencies and spend hours understanding how the project works so you don’t break anything. It gets even worse if the canister is part of a DAO.

Preferred solution: Pause the timers instead of deleting them. When the canister is topped up, automatically resume the timers.

How Canic could solve this

I agree that the replica should ultimately preserve an overdue timer and reactivate it
when the canister becomes runnable again.

The IC exposes one global timer per canister. Timer libraries maintain their task
queues in Wasm memory and use that global timer as the wake-up mechanism. The
specification currently deactivates the global timer when a canister runs out of
cycles and does not reactivate it after a top-up. The IC documentation describes this
behavior explicitly.
( https://docs.internetcomputer.org/guides/security/canister-upgrades/#reinstantiate-timers-during-upgrades )

Canic could provide a practical recovery mechanism without rebuilding or upgrading the
canister.

1. Add a canic_timer_wake endpoint

Every Canic canister could expose a small, idempotent recovery endpoint which:

  • authenticates the Canic root, controller, DAO, or configured guardian;
  • calls ic0.global_timer_set(now) to reactivate the global timer;
  • lets the existing Rust CDK timer queue continue processing;
  • returns a report containing the previous deadline, wake time, and timer status.

In the normal frozen-but-not-uninstalled case, the Wasm heap and CDK timer queue still
exist; only the protocol-level global timer has been deactivated. Therefore, rearming
the global timer should recover the existing timers without changing the Wasm hash.

The recovery path would become:

top up canister
→ call canic_timer_wake
→ global timer fires
→ existing CDK timer queue resumes

No source checkout, dependency upgrade, rebuild, or governance proposal to install new
Wasm would be required.

2. Couple every Canic top-up with a wake call

Canic’s funding workflow could treat these as one recoverable operation:

  1. Deposit cycles.
  2. Confirm that the canister has enough liquid cycles.
  3. Call canic_timer_wake.
  4. Verify that the timer scheduler is running again.

The workflow should be retry-safe, so if the funding canister crashes between steps 1
and 3, it can resume from the persisted receipt without depositing cycles twice.

For manual recovery, Canic could also offer something like:

canic medic recover-timers

That command could wake every Canic canister in the deployment and report which timers
resumed. This alone would replace the current “modify something, rebuild everything,
and upgrade every canister” procedure.

3. Add a fleet-level cycles guardian

Prevention should not depend only on a timer inside the canister that is about to
freeze.

A Canic root or dedicated guardian canister could monitor managed children, top them
up before their freezing reserve is reached, and always send the wake call after a
recovery top-up. The root itself would still need an independent guardian—another
canister or an operator service—because a canister cannot rescue itself once it no
longer executes.

For a DAO deployment, the guardian could be explicitly authorized by configuration, or
governance could call the recovery endpoint as a generic function. That is much
lighter and safer than approving a Wasm upgrade merely to restart timers.

4. Make critical timers durable

For stronger guarantees, Canic should eventually treat CDK timers only as wake-up
signals, not as the source of truth.

Each critical timer would have a stable logical record containing:

  • a stable timer name;
  • its interval and next due time;
  • whether it is enabled;
  • its last successful execution;
  • an in-flight lease or generation;
  • its missed-tick policy.

On init, upgrade, or canic_timer_wake, Canic would reconcile those stable records with
the in-memory scheduler. After a long outage, each timer could choose whether to skip
missed ticks, run once immediately, or perform bounded catch-up. The default should
probably be “run once, then resume the normal cadence” to avoid five months of missed
executions firing at once.

Important limitation

Canic cannot make an arbitrary top-up execute canister code by itself. Adding cycles
is a management operation and does not invoke a canister lifecycle hook. Without a
protocol change, some live actor must send the first post-top-up wake call.

So I see two complementary fixes:

  • Platform fix: preserve the global timer while frozen and automatically requeue it
    when cycles make the canister runnable again.

  • Canic fix: provide durable timer intent, an idempotent wake endpoint, and funding/
    guardian workflows that automatically call it.

The platform fix is still the ideal answer, but the Canic approach could remove almost
all of the operational pain immediately—and, most importantly, eliminate the need to
rebuild and upgrade an old canister just to restart its timers.

I suppose we could try to solve this at the application layer, but then how would an app know that its timers were deleted after the canister froze? Is that even possible?

If another canister detects that a canister was frozen and then restarts its timers, every library and module that uses timers would have to expose some kind of revive() function. We’d then have to collect all of those and call them from a single public recovery function.

But what if the cycles run out and someone tops up the canister before the monitoring system notices that it was frozen? Can the canister itself detect that its timers are no longer running? That would require yet another application-layer workaround, such as globally tracking the timestamps of the last timer invocations across multiple libraries and modules.

And what happens during a DoS attack? An attacker might simply want to stop your timers, then immediately refill your canisters afterward.

I think it should attempt to perform cryostasis.
Cryostasis is the preservation of a living organism at extremely low temperatures so that all biological processes effectively stop, with the expectation that it can be revived later.
In other words, after freezing, the IC should bring the canister back to a fully functional state.

Great thread. I will try to add a few data points.

1. A CDK level failure mode that produces exactly the “only an upgrade fixes it” symptom
Independent of what the protocol does to the global timer during a freeze, ic-cdk-timers only calls ic0.global_timer_set when the new soonest deadline is strictly earlier than its cached MOST_RECENT value, and that cache is only reset inside canister_global_timer. So once the protocol timer is deactivated for any reason while the cache holds a now-past deadline, every later set_timer / set_timer_interval is silently a no-op. We verified this in isolation on PocketIC 15 with a minimal probe canister: after deactivating the global timer, re-arming via set_timer_interval never revived it (counter frozen forever), while a raw ic0.global_timer_set(now) revived it immediately and the normal cadence continued (the forced canister_global_timer round resets the crate cache and reschedules from the heap queue). This matters for the thread because most “restart” endpoints people write just call set_timer_interval again, which cannot work in this state, so the timer looks unrecoverable and a wasm upgrade (which resets everything) looks like the only cure. It may not be the whole story behind post-freeze incidents, but it’s a mechanism that produces the same symptom and it’s fixable today.

2. So: +1 to the canic_timer_wake idea, with one load bearing detail that i hope can be useful.
The wake must be the raw ic0.global_timer_set(now) (in ic-cdk 0.19+: ic_cdk::api::global_timer_set(ic_cdk::api::time())), not another set_timer*. We just shipped exactly this into every restart endpoint of our fleet. Make the wake permissionless but rate limited (any authenticated caller, one global cooldown in a single stable cell, plus a health short-circuit) so recovery never depends on root/DAO being responsive; note that the forced set makes a real tick fire immediately, so if the wake endpoint also spawns an “immediate run” the two race deterministically, both need the same tick singleton (RAII guard); and store the TimerId + clear-before-rearm anywhere you re-arm intervals, or repeated wakes stack duplicates.

3. (For what it’s worth) our freeze ==> top-up repro attempt auto-resumed :thinking:
On PocketIC 15 we froze the probe for real; reserve ≫ balance, both updates and queries rejected; advanced well past several intervals, topped up, and the armed interval fired on its own with no call and no upgrade, then kept cadence. I’m not claiming that invalidates anyone’s field experience, mainnet history, replica versions, or edge paths can differ from a local repro, and the spec has evolved. But combined with point 1, it suggests some share of “freeze killed my timers permanently” incidents may actually be “my recovery attempts were silent no-ops because of the CDK cache”. If someone can reproduce the non-resume on current mainnet, that would cleanly separate the two; happy to compare notes on the repro setup (ours: probe pinned to ic-cdk 0.19 / ic-cdk-timers 1.0.0, freeze via freezing_threshold inflation, PocketIC 15).

4. One caution on the fleet-guardian (pt. 3 of the Canic post).
If Canic builds this, I’d suggest shipping detect-and-alert as the safe default and making auto-spend opt-in, bounded by (a) a burn-anomaly gate (compare observed burn between probes against the idle_cycles_burned_per_day the status call already returns; refuse to refill a child burning far above its idle profile), (b) a per-child refill budget per epoch with auto-pause, and (c) a reserve floor + kill switch.

5. Durable timer (pt. 4)
Yes, fully agree. E.g. our ticks are cursor/marker-driven and idempotent with “run once, then resume cadence” as the missed tick default, that’s also what makes both the wake call and any catch-up safe. And for anything custody critical we’re moving toward treating timers as an optimization only (pull/claim fallbacks that share the push path’s idempotency ledger), so a dead timer degrades to “user self-serves” instead of “funds sit silently”.

(Edit)
everything in point 1 is specific to the Rust ic-cdk-timers crate; the MOST_RECENT cache and the set_timer* no-op are crate properties. Motoko’s timer runtime manages the global timer differently, so a Motoko canister that loses timers post-freeze may have a different root cause, and the raw global_timer_set(now) wake is the Rust-CDK fix specifically. Read the point-3 generalization (“recovery attempts were silent no-ops because of the CDK cache”) as scoped to Rust-CDK users; Motoko behavior would need separate verification.

Great thread infu. Let’s get it fixed. :ice: :fire:

Timer-tool (https://mops.one/timer-tool) simplifies recovery in the meantime. The timer tool timer will be deleted, but since all timers are virtualized and saved you just need to poke that component to get everything back up and running. Of course this requires discipline to route all your timers needs through the timer-tool, but it is designed for just that purpose and even gives you hooks to observe when your timer was ultimately called(ie was it 3 days late? Maybe you want to ignore it.)

Not the replica fix you want, but might simplify recovery.

Great point, @infu . This feels like one of those small developer experience issues that creates huge headaches in production.
Timers should pause when a canister freezes and automatically resume once cycles are restored. That would make managing larger applications and DAOs much easier.

Reviving this old thread (don’t remember how I happened upon it) to point out that the protocol (i.e. replica) will not (or at least should not) consume a global timer that triggers while the canister is frozen. The timer is retained and will get executed as soon as you top up your canister.

Similarly ic-cdk-timers 1.0.0 goes to great lengths to ensure that every timer is executed exactly once, including if the canister gets frozen while or after the self-call that invokes the callback is made. As far as I (and Claude Code) can tell, the only way in which timers become inactivated is if the ic-cdk-timers code itself traps: the list of CDK timers is retained unchanged; the canister’s GlobalTimer is deactivated (whether the handler execution succeeds or traps; it is only retained on transient errors); and any set_timer with a future deadline will not reactivate the timer.

So to the extent possible, both the protocol and the Rust CDK (at least as of ic-cdk-timers version 1.0.0) do everything possible to ensure “cryostasis” when a canister is frozen. Not sure about Motoko, other Rust CDK versions or third-party libraries.

FWIW, here’s what Claude suggests for debugging the issue:

ic0.global_timer_set returns the previously armed value. Add a query/update that calls it and immediately restores the returned value. That splits the diagnosis cleanly:

  • returns 0 while the timer list is non-empty → CDK/replica desync (panic in ic_cdk_timers), not the freezing threshold;
  • returns a past deadline → the replica is armed and the scheduler is doing its job; the loss is downstream in the CDK;
  • the timer list is empty → the tasks were dropped in canister logic, or the canister was uninstalled.

“cryostasis” huh, very interesting.

They say that most of your brain shuts down in cryo-sleep. All but the primitive side, the animal side.

No wonder I’m still awake.

https://github.com/dragginzgame/ic-timers

plz feedbacks plz

Actually not yet, I have a massive 0.2 update coming. I’m aligning the direction with both our major repos that will use it.