A long-lived session — an agent conversation, a player, a workflow — spends almost all of its life waiting for the next message. Keeping a warm process (or container) alive for every idle session costs tens to hundreds of megabytes each, around the clock. SCUA's alternative: freeze the session to a blob, drop it from memory entirely, and rehydrate it when something happens. While it waits, a session costs a few hundred kilobytes of object storage and zero compute — and when it wakes, it resumes exactly where it left off.
This works because a partition already is one relocatable blob (that's Persist and migrate); hibernation is that machinery pointed at a session lifecycle. The session script needs nothing special — it never learns it was frozen.
Everything on this page is driven from a C host through
include/scua.h, and all of it runs in the worked example
examples/embed_hibernate.c
(zig build embed-hibernate from a source checkout). If you
haven't embedded SCUA before, read Embed
SCUA in a host program first.
#The session is an actor — and an actor freezes whole
A session lives naturally as an actor: a handler function plus its private state, driven one message at a time.
fn handler(state, msg)
let n = state.count + 1
print(`turn {n}: "{msg}"`)
return { count = n, last = msg } -- the returned state is what gets frozen
end
fn start()
let a = actor(handler, { count = 0, last = "" })
tell(a, "hello")
end
Two C calls freeze and thaw it:
scua_persist_actor(p, actor_id, &blob, &len)serializes one actor — its state, its mailbox bookkeeping, and (see below) even a call it was parked on — into a self-contained blob. It's a snapshot, not a teardown: the live actor keeps running if you want it to.scua_load_actor(p, blob, len)loads that blob into a partition with the same script loaded, and returns the actor's new id.
The blob carries data, never code. On rehydration
you load the same script first (scua_load_script),
then the actor blob. Capability grants and the I/O platform are
host-side state and don't persist either — re-grant and re-install them
as part of rehydration. If your script declares record
types, the blob is stamped with their schema fingerprint and a
mismatched program refuses the load (-SCUA_ERR_FORMAT)
instead of misreading frozen state.
#Idle hibernation: freeze between turns
The everyday case. After the actor has handled its mail (a turn boundary), freeze it, write the blob to your store, and free the whole partition:
/* ...the session handled its message (scua_pcall / scua_io_poll ran to quiescence)... */
uint8_t *blob; size_t len;
scua_persist_actor(p, session_actor_id, &blob, &len);
store_put(session_id, blob, len); /* S3, a KV store, a file — your call */
scua_blob_free(blob, len);
scua_partition_free(p); /* the session is now 0 bytes resident */
Hours later, a message arrives for that session:
scua_partition *p = scua_partition_new();
scua_load_script(p, SESSION_SCRIPT, strlen(SESSION_SCRIPT), 0, 0, err, sizeof err);
int id = scua_load_actor(p, blob, len); /* state, count, history — all back */
scua_actor_tell_text(p, id, "are you still you?"); /* queue the message (runs nothing yet) */
scua_io_poll(p, err, sizeof err); /* run the turn */
/* ...freeze again, drop again... */
The handler sees state.count == 1 and answers turn 2. It
cannot tell that a freeze, a file, and a fresh partition happened in
between — that's the entire property.
Mid-call hibernation: don't wait resident, wake with the answer
The sharper case: the handler asks a slow question — model inference, a long job — through a parked capability call (the embedder async platform):
fn handler(state, question)
match model.ask(question) -- parks; the host performs the real call
Ok(answer) -> use(answer)
Error(e) -> reconcile(e) -- "interrupted": see the snapshot-survival page
end
end
model here is the host's own module. Register
ask with scua_register_module, and inside the
native call
scua_return_pending(ctx, "model", "ask", idempotent, safe_echo):
when it returns 1 the turn has parked exactly as http.get
parks under the platform, with the same submit callback, the same token
completes, and the same wake calls below; when it returns 0 parking
isn't available (no platform, or not a handler turn), so take a blocking
path instead. Built-in capability calls (http.get,
fs.read, …) park the same way with no extra work. And one
more thing to re-establish on rehydration, alongside grants and the
platform: re-register your modules — natives are
process state and never ride the blob; one you don't re-register
resolves nil and fails cleanly if called.
If the answer takes 30 seconds, holding a resident partition for 30
seconds of waiting defeats the point. Instead: freeze the actor
mid-park (scua_persist_actor works exactly the
same while a call is parked — the park travels in the blob), drop
everything, and when your answer arrives, rehydrate and deliver it
into the parked call:
/* the answer you kicked off out-of-band has landed; you own it */
int id = scua_load_actor(p, blob, len); /* same script + grants + platform first */
uint64_t park;
scua_io_parks(p, &park, 1); /* the reloaded, waiting call — exactly 1 here */
scua_io_park_read(p, park, mod, sizeof mod, op, sizeof op, echo, sizeof echo); /* optional: "model ask" */
scua_io_wake_http(p, park, 200, answer, strlen(answer)); /* hand it the result you hold */
scua_io_poll(p, err, sizeof err); /* the handler resumes in Ok(answer) */
The handler wakes up inside its Ok(answer) arm as if the
call had taken five milliseconds, when in fact it hibernated through the
wait — possibly on a different machine.
The one rule: wake before you poll. A reloaded
parked call is governed by the reload rule: the
first drain — any scua_io_poll, or a
scua_pcall that runs turns — settles it as
Error("interrupted"), permanently, and a later wake is a
silent no-op (your script then pays for the operation twice via its
reconcile arm). scua_load_actor and
scua_actor_tell_text don't drain, so the safe rehydration
order is always: load → wake → poll. All the scua_io_wake_*
result shapes mirror scua_io_complete_*
(_http, _text, _bytes,
_nil, _error), and a duplicate wake after
settlement is a defined no-op — retrying hosts are safe.
Only wake with a result you own. Waking is you
asserting "this operation completed, and this is its outcome". That's
true when your process armed the call, or when you read the outcome from
a durable job store you share with whoever did. If you can't
establish that — a different host picked up the blob, the job's fate is
unknown — don't fabricate an Ok. Just poll: the handler
gets Error("interrupted") and its reconcile arm asks the
durable side what really happened, which is the always-safe default. (A
held failure is still a result — deliver it with
scua_io_wake_error rather than leaving the script to
guess.)
#What it costs — honestly
Measured on the real C-ABI path
(zig build bench-hibernate, Apple-silicon dev machine,
local-file store; your store's PUT latency will dominate for big
sessions):
| history (records) | blob at rest | freeze (compact+serialize) | rehydrate (load actor) |
|---|---|---|---|
| 16 | ~95 KB | ~0.2 ms | ~0.09 ms |
| 4,096 | ~1.1 MB | ~0.5 ms | ~0.15 ms |
| 32,768 | ~8 MB | ~2.6 ms | ~0.65 ms |
Three things to know before you build on it:
- Freezing costs O(accumulated state), not O(the last turn). Every freeze recompacts the session's whole live set. A long conversation pays more per message to freeze — budget for it, or freeze on idle rather than after every turn.
- The store round-trip is the real suspend cost. Compaction is sub-millisecond to a few milliseconds; a network PUT of a multi-megabyte blob is not. Count both.
- Resume also recompiles the script (code never rides the blob). It's fast (~0.2 ms above), and a host that rehydrates many sessions of the same script amortizes it naturally.
Against the alternative — tens to hundreds of MB of warm process per idle session — the blob-at-rest column is the whole argument.
#See also
- Write handlers
that survive snapshots — the reload rule and the reconcile-on-resume
habit; mid-call hibernation is that page's happy path, and its
Error("interrupted")arm is your safety net whenever a wake isn't possible. - Persist and migrate — the persistence model this rides on (one relocatable blob, no custom serialization).
- Embed SCUA in a host program — the C-ABI basics, grants, and the async I/O platform used here.
examples/embed_hibernate.c— both flows above, runnable end to end (zig build embed-hibernate).