Sleep sequence invariants
Design constraints and invariants for the RivetKit actor sleep / destroy lifecycle. Pair with actor-task-dispatch.md and rivetkit-core-internals.md for surrounding context.
Authority
- The engine owns lifecycle authority.
ctx.sleep()andctx.destroy()send fire-and-forgetActorIntentevents; they do not transition lifecycle state locally. The localSleepGrace/DestroyGracetransition runs when the engine replies withStopActor. envoy-clientretries intent delivery across reconnects via checkpoint-based event replay (engine/sdks/rust/envoy-client/src/events.rs). Core does not need its own retry path.
Public surface: keep-awake primitives
Two user-facing primitives in TypeScript. Both accept a Promise, never a closure.
| Method | Blocks idle sleep | Blocks grace finalize | Notes |
|---|---|---|---|
c.keepAwake(promise) |
Yes | Yes | Returns the same promise. Use for work the actor must stay up for. |
c.waitUntil(promise) |
No | Yes | Returns void. Use for best-effort flush/cleanup work that is allowed to complete inside the grace window. |
c.setPreventSleep(b) and c.preventSleep are deprecated no-ops retained for binary / call-site compatibility. They will be removed in 2.2.0.
Why two primitives and not one
keepAwake is scoped, non-leaky, and symmetric with waitUntil. setPreventSleep was a flag that had to be paired by hand; forgetting to clear it wedged the actor awake. A promise-scoped counter cannot leak: when the promise settles (resolve or reject), the counter decrements.
Why separate keep_awake and internal_keep_awake in core
Kept separate for debug visibility. Grace deadline warn logs report each counter independently so diagnostics distinguish user keep-awake sites from framework-owned keep-awake sites (schedule alarms, queue receives).
Sleep readiness predicates
Two predicates govern the sleep state machine. Both live on ActorContext / SleepState.
can_arm_sleep_timer()— the idle predicate. ReturnsCanSleep::Yesonly when every sleep-affecting counter is zero and the run handler is inactive (or waiting on a queue). Used to start the sleep idle timer.can_finalize_sleep()— the grace predicate. Returnstrueonly when every shutdown-affecting counter is zero:core_dispatched_hooks,shutdown_task_count,sleep_keep_awake,sleep_internal_keep_awake,active_http_requests,websocket_callbacks,pending_disconnects. Used to advance fromSleepGracetoSleepFinalize(or finalize destroy).
Removing preventSleep deleted both predicate branches. Any future sleep-affecting counter must add an entry in each predicate and must call ActorContext::reset_sleep_timer() on transitions that change the result.
Grace period and abort signals
start_grace(reason)fires at the start ofSleepGrace/DestroyGrace. It cancels the sleep idle timer, cancels the actor abort signal (actor_abort_signal), installs aSleepGraceStatewith the effective grace deadline, and resets the sleep timer to arm the grace tick.- The actor abort signal is a soft signal: "shutdown has started, please wrap up." User code observes it via
c.abortSignal. It does not force-stop work. - Destroy requests also use the normal grace path. The actor abort signal fires when destroy grace starts.
Grace deadline enforcement
When the grace deadline elapses before can_finalize_sleep() returns true:
on_sleep_grace_deadlineaborts the userrunhandle (run_handle.abort()), cancels the shutdown deadline token (cancel_shutdown_deadline()), records the timeout, and emits a structured warn log enumerating every non-drained counter.- The NAPI
RunGracefulCleanuptask observesshutdown_deadline_token()viatokio::select!and aborts its in-flightonSleep/onDestroycall so SQLite and KV cleanup inteardown_sleep_statedo not race against mid-commit user work. - Foreign-runtime adapters that run user cleanup callbacks must observe the shutdown deadline token the same way.
Guarding lifecycle requests
ctx.sleep()andctx.destroy()returnResult<()>. They fail withactor/startingif called before startup completes andactor/stoppingif the request flag has already been swapped to true for this generation. An atomicswap(true, ...)onsleep_requested/destroy_requestedenforces single-shot request semantics per generation.- The idle sleep timer request path (
spawn_sleep_timer_task) and theActorTasksleep-tick path both suppress the already-requested error: idle-driven requests may race user-driven requests and the warning is informational.
Serialize-state shutdown cap
SERIALIZE_STATE_SHUTDOWN_SANITY_CAP = 15s is the upper bound on how long the shutdown SerializeState reply wait is allowed to pend before save_final_state falls back to empty deltas (preserving prior state). This is a sanity cap, not a deadline anyone should ever hit; the normal drain finishes in milliseconds.
Test harness parity
- Rust integration tests live in
rivetkit-core/tests/modules/sleep.rsand pin predicate behavior, grace period selection, andsave_final_statecap. - TypeScript driver tests in
rivetkit-typescript/packages/rivetkit/tests/driver/actor-sleep*.test.tscover abort-signal-at-grace-entry,keepAwakeholding shutdown,c.dbwrites survivingonSleep, and regression coverage forsetPreventSleepbeing a no-op.