Language
Snapshots
A paused program can be frozen whole and brought back later. That can be on another page load, another machine or another day. saveState captures the whole interpreter in a portable blob, and restoreState boots a fresh VM from it.
A blob holds everything the program needs to go on.
- The heap and every global.
- Each suspended coroutine with its call frames.
- The scheduler and the events queued for it.
- The source and a structural fingerprint of the compiled bytecode.
restoreState parses the source again and checks both. It rejects a blob that belongs to another program, another compiler build or another snapshot format. One blob restores any number of times, each restore an independent copy.
A blob is pinned to one program and one compiler build by its fingerprint. An engine upgrade invalidates every saved blob.
Where a run can freeze
A run freezes at a suspension point. There are two ways to reach one.
- The program reaches one on its own, at an empty
receive(), asleep(n>0)or a pending host call. See Async. - The host forces one. With a preempt interval of
nthe VM yields everynloop back-edges. A barewhile True:that only counts can then be paused, with no change to the program.
At a suspension point the VM unwinds to a clean state it can serialize. A snapshot is only coherent there. Saving while the run executes fails.
Each yield costs the host a round trip. A small interval lets a pause land fast and slows the run, and 0 turns preemption off. That is the default.
A program that pauses
A shopping cart keeps state across events and parks on receive() every loop.
items = []
total = 0
while True:
msg = receive() # parks here until the host pushes an event
if msg == "checkout":
break
price = int(msg)
items.append(price)
total += price
print(f"added {price}, total {total}")
print(f"done: {len(items)} items, {total} total")items and total live in the VM heap. The only freeze point is the receive() line.
Save and restore
A page drives the round trip through createWorker. The first session adds two items and saves.
import { createWorker } from "https://cdn.edgepython.com/js/src/index.js";
// Wait until the VM is parked on an event so saveState() can capture it.
async function untilParked(worker) {
for (let i = 0; i < 200; i++) {
const stack = await worker.stateStack();
if (stack.some((c) => c.state === "waiting_event")) return;
await new Promise((r) => setTimeout(r, 10));
}
throw new Error("run never parked");
}
const worker = await createWorker();
worker.onOutput((chunk) => console.log(chunk));
worker.run(cartSrc); // never awaited, it parks on receive() and stays pending
await untilParked(worker);
worker.pushEvent("10"); // added 10, total 10
await untilParked(worker);
worker.pushEvent("25"); // added 25, total 35
await untilParked(worker);
const blob = await worker.saveState(); // Uint8Array holding the whole VM
worker.dispose(); // user closes the tab, run() is abandonedA second session on a fresh page load resumes where the user left off.
const worker = await createWorker();
worker.onOutput((chunk) => console.log(chunk));
const done = worker.restoreState(blob); // comes back parked on receive() with total 35
await untilParked(worker);
worker.pushEvent("5"); // added 5, total 40, continued from 35 not 0
worker.pushEvent("checkout"); // done: 3 items, 40 total
const { out } = await done; // resolves like run() once the program finishesThe last total reads 40 rather than 0. The snapshot restored the heap, and the program continued instead of restarting.
From the CLI
The CLI saves and restores the same blob as a plain file, with no JS involved.
Both hosts run one engine built from one source. A blob saved in one host restores in the other. The flags are in Run flags.
What cannot be preempted
A preempt lands at a loop back-edge, and only where the VM can unwind. Three cases run past their interval and yield at the next reachable back-edge.
- A class body and the init of an imported module. The top level of the entry script preempts normally.
- User code a builtin calls back into. That covers a
key=callback ofsortorsorted, a generator drained bylist()orsum(), and the__init__or__call__run while an object is built or called. - A single long native operation, such as sorting a huge list or
"x" * 10**8.
Everything else preempts, at any call depth and inside try or with blocks.
None of this changes results. An unpreemptible stretch delays a pause and never corrupts one. Why is in Design.
Inspect without resuming
stateGlobals and stateStack read a parked run without waking it. That suits a resume screen or debugging a restored blob.
await worker.stateStack();
// [{ state: "waiting_event", function: "<module>", ip: 12, frames: [] }]
await worker.stateGlobals();
// { items: "[10, 25]", total: "35" } values are reprsstate reads "waiting_event" for a parked receive() and "sleeping" for a sleep().
A run held by pause() reads "ready". A preempted coroutine waits on nothing. It is only not being stepped.
Limits
- Script state restores identically, queued but unconsumed events included.
- Live host resources are not captured. DOM handles, sockets and pending host calls must be recreated after a restore.
- A blob carries the whole heap and has no size cap.
- A restored run keeps the limits saved in its blob.
The byte layout is in Blob layout.