7. Interpreting
The explicit-stack engine: tail calls, handlers, resumable continuations, and the host boundary.
Status first, honestly: the engine (packages/kernel-rs/src/eval.rs) is real and runs in three places: native
Rust tests, the same code compiled to wasm and driven from Node, and the same wasm inside the editor page. It
replays all 684 recorded UCM results (packages/compiler/test/eval-wasm.test.ts) and agrees with every one.
The editor’s Run button runs your file in this engine first, in the page, so it works offline; if the server is
reachable it then runs the same file through UCM and compares. It displays every value exactly as UCM does (records,
variants, lists, dicts, floats, text escapes; only function values print as (not shown)), and it can yield every N
reductions so the editor’s Stop button works. What it does not do yet: run code that refers to definitions stored
outside the open buffer (those go to the server). It does not typecheck, so the editor only calls it on code the checker has accepted.
Why not just recurse?
A recursive host interpreter overflows its stack on deep or looping Clay code, and cannot capture a
continuation (needed for resume). So the engine keeps its own stack on the heap, as a list of frames,
and runs one loop:
loop:
match state:
Eval(code, env) -> push a Frame describing what to do with the result; descend into a sub-expression
Return(value) -> pop a Frame; continue with the value
A 1,000,000-iteration tail loop and a 1,000,000-deep recursion both run (about 5 s in a debug build). Tail calls
reuse the frame instead of pushing one. A step limit (50M) and a stack limit (10M) turn runaway programs into
Failed{resource-exhaustion}.
Frames
Each frame is “what to do when the value in hand is ready”:
enum Frame {
ArgOf { arg: Code, env: Env }, // evaluated the function, now evaluate the argument
CallWith{ f: Value }, // evaluated the argument, now apply f
Let { name, body: Code, env },
If { t: Code, e: Code, env },
And { r, env }, Or { r, env },
Scrut { cases, env }, // evaluated a `switch` scrutinee, now pick a case
HandleInit { body, env }, // handler value being computed
Handle { handler, handled }, // a marker: "abilities in `handled` stop here"
}
Handlers and resume
handle body with clauses pushes a Handle marker and runs body. When body performs an ability operation:
perform(ability, op, args):
walk the frame list from the top, looking for a Handle whose `handled` contains `ability`
captured = frames above that marker # a delimited continuation; frames are immutable
pop down to (and including) the marker
run the matching clause with (args..., resume = Cont(captured))
Applying resume(v) re-pushes the captured frames and returns v into them. Because the frame list is
immutable data, a continuation can be resumed zero, one or many times; the tests cover all three (calling
resume twice really does run the rest of the body twice). Handlers are shallow: after resuming, a handler
must be reinstalled by the clause itself if wanted, matching Unison.
If the walk reaches the bottom with no handler, the outcome is the host’s problem (below) or Failed{unhandled-ability}.
Semantics to copy from UCM
Never trust intuition here; these were checked against UCM: Int is 64-bit two’s complement with wraparound,
division floors, Int.mod’s sign follows floor division, divide-by-zero fails, text compares by code point.
The host boundary
The engine never touches the world. It reports a step and the caller answers:
enum Outcome {
Done(Value),
Failed { class, message },
Host { op: String, args: Vec<Value> }, // "host, please do this" (e.g. read the clock)
Perform { ability: String, op: u64, args: Vec<Value> }, // a user ability nobody handled
}
// engine.start(program, main) -> Outcome
// engine.resume(value) -> Outcome (called with the host's answer)
// engine.take_output() -> what the program wrote to stdout/stderr since last asked
Printing is deliberately not a round trip. IO.print is Unison’s real wrapper over putBytes; the engine
answers the write itself by appending to an in-engine buffer, and the host drains that buffer after every
outcome, so order is trivially preserved. Only things the engine cannot know (the time) become Host steps.
Buffered output is kept even when the program then fails; UCM loses it on a runtime abort, so the editor’s
comparison treats aborted runs as inconclusive for output.
Across the wasm boundary the request and each outcome are JSON (integers travel as decimal strings). The engine
lives inside wasm between calls, so a paused program keeps its continuation and resume just names it by id.
The host loop, as the editor runs it (packages/compiler/src/local-run.ts):
reply = evalStart(request)
while reply.status == "host":
reply = evalResume(reply.id, answer_for(reply.op)) # e.g. { int: Date.now() * 1000 }
A user handle beats the host for user abilities: Perform only escapes when no Handle marker claims it.
What IO is
Unison has one {IO} ability; Clay keeps it (no per-capability split) and exposes two operations typed only in
int, string and unit: IO.print(text) and IO.now(). Neither is a Unison ability operation: they are
ordinary builtins whose types carry {IO}. That shapes everything:
- Hashing.
IO.printis not one builtin but a small wrapper Unison writes: append a newline, encode as UTF-8,putBytesto the standard-output handle, match on theEitherresult andbugon failure. Both kernels hash that wrapper inline, so Clay’s hash equals the one UCM computes for the same wrapper, with no library to seed. (EitherandStdHandleare ordinary library types referenced by hash; constructor indices are not in declaration order. Recorded from UCM, not guessed.)IO.nowis simpler: the versioned builtinIO.systemTimeMicroseconds.v1applied to unit. - Checking. A function that prints must say so:
let main<IO> = .... Forgetting<IO>is a type error. - Running. In UCM the program’s stdout goes to the process stdout, separate from the result value, so Run
can show printed lines first, then
= result. In the engine it is the buffer above. - Comparing. Printing is deterministic, so local and UCM output must match line for line; a mismatch shows
as an
EVAL DISAGREEMENTline in the Console. A run that read the clock is marked non-deterministic and is not compared.
The design notes and the decision log are both in docs/host-ops-log.md.
If you were building this by hand
- Lower Core to a small
Codeenum with de Bruijn indices (the sketch still looks variables up by name; that is the first performance fix). - Write the frame loop with only
App,Lam,If, literals. Add tail calls. - Add
Let, data constructors,Switch. - Add
Handleand continuations last; they are ~100 lines once frames are immutable. - Replay UCM-recorded results as your test corpus. Every semantic above came from one.