A player's session is a live thing: their inventory, where they are, the half-finished trade, the request they are waiting on. Moving that to another machine normally means draining them, writing state out, standing them back up and hoping nothing was in flight. In SCUA the session is one self-contained blob, so moving it is sending the bytes.
#Why this is normally hard
Live state is pointers into one process's heap. The moment you want it somewhere else, you are writing a serializer: a walker, an identity table so shared references do not become copies, a visited set so cycles terminate, and the same thing backwards on the other side. Then you discover the player was mid-request when you moved them, and you have to decide what that request returns.
#What makes it cheap here
State lives in partitions, and a partition is one contiguous arena where every internal reference is an offset rather than an address. A handle is a 32-bit offset counted in 8-byte units, so the bytes mean the same thing wherever they are loaded.
That claim was tested before the language existed.
spike/arena_roundtrip.c builds a graph with the two cases
that break naive serialization, a cycle and a shared reference held by
identity, then copies the raw bytes to a different base and walks them
again:
$ make spike
[A] base=0x95c800000 used=136 bytes checksum=6178534732187436567
[B] base=0x95c810000 (distinct from A by 65536 bytes)
[B] checksum=6178534732187436567
PASS: graph valid at a different base address with no per-pointer fixup
PASS: B's bytes are identical to A's (no swizzle/fixup occurred)
PASS: B fully usable after A is freed (evacuate->reactivate holds)
Look at the second line. The bytes are memcmp identical
after the move, which is the proof that nothing quietly fixed anything
up on the way.
#Moving a session
Two C calls. scua_persist_actor serializes one actor,
its state and its mailbox bookkeeping, into a self-contained blob.
scua_load_actor loads that blob into a partition running
the same script and returns the actor's new id. This is a snapshot
rather than a teardown, so the live actor keeps running if you want it
to.
Two freezes in the worked example, one idle and one mid-call:
$ zig build embed-hibernate
SCUA session-hibernation demo (scua 0.27.0-dev.b13ad50)
--- ACT 1: Tier A — idle hibernation (freeze at a turn boundary) ---
[host] frozen to zig-out/hibernated-session.blob (62907 bytes); partition freed
--- ACT 2: Tier B — hibernate mid-call, wake with the result ---
[host] session parked on model ask what is a partition? — hibernating instead of waiting
[host] frozen mid-call (63155 bytes); zero compute while the model thinks...
[host] reloaded park: model ask — delivering the answer we hold
PASS: a session survived an idle freeze AND a mid-call freeze, and resumed with its answer
Sixty-three kilobytes for a session with two turns of history. The
second act is the interesting one. That session was parked on a slow
request when it was frozen. It came back, was handed the answer the host
had been holding, and the handler resumed inside its Ok arm
as though the call had been quick.
#The sharp edge, and the rule that handles it
Moving a session that was mid-request raises a real question: what does that request return on the other side?
SCUA's answer is that the parked call resumes as
Error("interrupted"), and the runtime never re-issues it.
That refusal is the whole safety property. A blind retry means a
POST can fire twice, an order placed twice or a payment
charged twice, because from outside you cannot tell whether the original
request reached the server before the move. So the runtime does not
guess. It hands the interruption back as an ordinary error and lets your
code decide.
fn place_order(order)
match http.post("https://api.shop/orders", encode(order))
Ok(resp) -> record_placed(order.id, resp)
Error(e) when e == "interrupted" -> reconcile(order) -- moved mid-flight: find out what happened
Error(e) -> retry_later(order, e) -- an ordinary failure is safe to retry
end
end
Reconcile rather than retry is the habit that goes with it: ask the
durable side what is true and act on the answer. There is a sibling
case, where a parked call whose deadline already passed comes back as
Error("timeout") instead, and it means the same thing for
safety.
#What you have to do on the far side
Data rides the blob and code never does. That is deliberate, and it has consequences worth knowing before you build on this.
Load the same script first. A schema fingerprint mismatch is refused with a format error rather than loading something that half fits.
Re-grant capabilities and re-register host functions. Both are host-side state and neither is persisted, so a rehydrated session has no filesystem or network authority until you give it back.
Expect a new actor id. Ids are runtime-local and reassigned on every load.
And one ordering invariant if you are moving a session that was mid-call: do not drain between loading the actor and delivering the result. By the same reload rule, the first drain settles a reloaded park as interrupted permanently, and a later wake is then a silent no-op.
#What is and is not demonstrated here
Everything above runs inside one process. The step across a network is the same bytes through a different pipe, and the property that makes it work, that the graph survives relocation to a different base with no fixup, is what the spike measures directly.
A fuzz run exercises the move path continuously, and one long-running
failure at seed 242 looked like persist.move corrupting a
value. It was the test. That harness read a packed array as though it
were unpacked, striding 16-byte values over 8-byte lanes, and ran past
the object into the next header, so the checksum was hashing arena
layout, which compaction legitimately reorders. move was
corrupting nothing. The repaired checksum was then shown to still catch
real corruption of a typed lane, so it goes green for the right
reason.