SCUA

News

SCUA moves a live player between machines

September 20, 2026

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.