Continuations & Preemption
Two independent mechanisms. Cont captures and resumes delimited continuations; Preempt interrupts running code at instruction boundaries. Each is useful on its own, and they compose, which is how fibers, coroutines, generators, and preemptive schedulers get built.
Overview
This page covers two subsystems. Neither contains the other, and each works with the other absent.
A continuation represents “the rest of the computation” — everything that would happen after a certain point. Zym provides delimited continuations that capture only up to a prompt boundary, making them composable and easy to reason about.
Continuations in Zym are one-shot — each can be resumed exactly once. After resuming, it becomes invalid.
A preemption is a hard yield at an instruction boundary, not an error. The VM stops between two instructions, runs a callback registered ahead of time, and resumes where it left off with the stack, locals, and call frames untouched. Nothing unwinds.
They compose because a preemption callback is ordinary script: it can capture a continuation, which suspends a computation that knows nothing about scheduling. Neither side needs the other. Continuations work with no entry registered, and preemption works with no prompt installed.
Key Features
Cont:
- Tagged Prompts — Multiple independent control flow boundaries
- One-Shot Semantics — Each continuation can be resumed exactly once
- Delimited Capture — Only captures state up to a prompt, not the entire stack
- GC Integration — Continuations are properly garbage collected
Preempt:
- Instruction Slices — Deadlines count instructions, so they fall at the same point on every run and every machine
- Independent Entries — Each registration has its own slice, its own callback, and its own id
- Shields — Critical sections that defer the script’s own callbacks
- Ownership Split — Script entries are always script-owned and maskable; host entries are neither reachable nor visible
Both stay out of the way when unused: a program that installs no prompt pays nothing for continuations, and an empty preemption table costs one decrement and one predicted branch per dispatch.
| Module | Purpose |
|---|---|
Cont | Delimited continuation primitives (prompt tags, capture, resume, abort, shift) |
Preempt | Scheduled interruption (instruction-counted callbacks, shields, budget inspection) |
Understanding Continuations
What is a Continuation?
A continuation represents "the rest of the computation" — everything that would happen after a certain point in your program. In Zym, you can capture this as a first-class value and resume it later with a chosen result.
Think of it like a bookmark in your program's execution that you can jump back to.
Delimited vs Undelimited
Undelimited continuations (like Scheme's call/cc) capture the entire program state — everything from the current point to program termination. This is powerful but unwieldy.
Delimited continuations (what Zym provides) capture only up to a prompt boundary. This makes them composable and much easier to reason about.
┌─────────────────────────────────────────────┐ │ Main Program │ │ ┌───────────────────────────────────┐ │ │ │ Prompt Boundary (tag: myTag) │ │ │ │ ┌─────────────────────────┐ │ │ │ │ │ Code that calls │ │ │ │ │ │ Cont.capture(myTag) │ │ │ │ │ │ ← CAPTURED PORTION → │ │ │ │ │ └─────────────────────────┘ │ │ │ └───────────────────────────────────┘ │ │ ← NOT captured (outside prompt) │ └─────────────────────────────────────────────┘
Prompt Tags
A prompt tag is a unique identifier that marks a control flow boundary. You create tags with Cont.newPrompt() and use them to:
- Install prompts — Mark where continuations can be captured to
- Capture — Specify which prompt to capture up to
- Abort — Specify which prompt to unwind to
Multiple tags can coexist, allowing independent libraries to use continuations without interfering with each other.
The shift Operator
When you use Cont.capture(tag) inside Cont.withPrompt, the captured continuation returns to the withPrompt call site.
The caller then has to inspect the result, check if it's a continuation with Cont.isContinuation(), and decide what to do.
This boilerplate is needed at every withPrompt call site that might encounter a capture.
Cont.shift(tag, handler) eliminates this pattern. Instead of returning the continuation to the caller,
it passes it directly to a handler function you provide. The handler runs at the prompt boundary and whatever it returns becomes the withPrompt result — no ambiguity, no isContinuation checks.
Getting Started
Basic Capture and Resume
Important: Always call Cont.capture() inside a helper function. If you capture directly at the prompt level, the variable holding the continuation gets overwritten when you resume!
// 1. Create a prompt tag var tag = Cont.newPrompt() // 2. Define a function that captures // This is CRITICAL - capture must be inside a function! func pauseAndGetValue() { print("About to pause...") var received = Cont.capture(tag) // Pause here print("Resumed with: " + str(received)) return received * 2 // Continue computation } // 3. Install prompt and call the function Cont.pushPrompt(tag) var k = pauseAndGetValue() // k receives the continuation // 4. k is now a continuation object print("Got continuation: " + str(Cont.isContinuation(k))) // 5. Resume the continuation with a value var result = Cont.resume(k, 21) // 21 becomes 'received' in the function print("Final result: " + str(result)) // 42 (21 * 2)
Cont Module
Prompt Tags
Creates a new, unique prompt tag.
Creates a prompt tag with a debug name (shown in error messages).
var tag = Cont.newPrompt("fiber")
Returns true if value is a prompt tag.
Installing Prompts
Executes fn (zero-argument function) within a prompt boundary. The prompt is automatically installed before fn runs and removed when it returns. This is the recommended way to install prompts.
Returns: fn’s return value, a captured continuation, or an abort value.
var tag = Cont.newPrompt("work") var result = Cont.withPrompt(tag, func() { print("Inside prompt scope") return 42 }) print(result) // 42
Manually installs a prompt boundary. You must call Cont.popPrompt() when done.
Removes the topmost prompt from the stack.
Capture & Resume
Captures the continuation from the current point up to the specified prompt, then transfers control back to the prompt location.
capture() inside a helper function. If you capture directly at the prompt level, the variable holding the continuation gets overwritten when resumed.var tag = Cont.newPrompt() // CORRECT: Capture inside a function func pauseHere() { var received = Cont.capture(tag) return received * 2 } Cont.pushPrompt(tag) var k = pauseHere() // k is the continuation var result = Cont.resume(k, 21) // result is 42
Resumes a captured continuation with the provided value. The continuation is consumed after this call (one-shot).
continuation— the continuation to resumevalue— value to inject (becomes the result ofcapture)
Returns: the eventual return value from the resumed computation.
Returns true if value is a continuation object.
Returns true if the continuation has not been consumed yet.
Cont.isValid(k) // true Cont.resume(k, null) Cont.isValid(k) // false — already consumed
Abort
Aborts to a prompt without capturing a continuation. Unwinds to the prompt and provides a value — like an early return.
var tag = Cont.newPrompt("bail") var result = Cont.withPrompt(tag, func() { Cont.abort(tag, "early exit") print("This never runs") }) print(result) // "early exit"
Shift
Captures the continuation and passes it to handler(k). The handler runs at the prompt boundary and its return value becomes the withPrompt result. Eliminates the isContinuation boilerplate needed with raw capture.
tag— the prompt tag to capture up tohandler— one-argument function that receives the continuation
var tag = Cont.newPrompt("demo") var result = Cont.withPrompt(tag, func() { var x = Cont.shift(tag, func(k) { return Cont.resume(k, 42) }) print("Got: " + str(x)) // "Got: 42" return "done" }) print(result) // "done"
Examples
Generator Pattern
var tag = Cont.newPrompt("gen") func yield(value) { Cont.shift(tag, func(k) { return [value, k] }) } var pair = Cont.withPrompt(tag, func() { yield(1) yield(2) yield(3) return null }) // pair = [1, <continuation>] print(pair[0]) // 1 pair = Cont.resume(pair[1], null) print(pair[0]) // 2
Early Return with Abort
var RETURN_TAG = Cont.newPrompt("return") func earlyReturn(value) { Cont.abort(RETURN_TAG, value) } func withEarlyReturn(fn) { Cont.pushPrompt(RETURN_TAG) var result = fn() Cont.popPrompt() return result } var result = withEarlyReturn(func() { var numbers = [1, 3, 5, 4, 7] var i = 0 while (i < length(numbers)) { if (numbers[i] % 2 == 0) { earlyReturn(numbers[i]) } i = i + 1 } return null }) print("First even: " + str(result)) // "First even: 4"
Preempt Module
Registers callbacks that the VM runs after a given number of instructions. A preemption is a hard yield at an instruction boundary, not an error: the VM stops between two instructions, runs the callback, and resumes with the stack, locals, and call frames untouched.
This is the script-visible half. A host embedding the VM has its own preemption surface with strictly more authority, which script cannot reach. See Preemption in the Embedding Guide for that side.
Conventions
- Slices are instruction counts, not time. A slice of
10000means “after ten thousand more VM instructions”, which is deterministic and independent of machine speed. There is no wall-clock scheduling here. - Ids are numbers. Every registration returns an id used to address the entry later.
0is never a valid id. - Unknown ids are not errors. Mutators return
falseandPreempt.remainingreturns-1. - Callbacks take no arguments. Registration is refused if
fntakes any, because the VM could not invoke it. Close over what the callback needs instead. - The table is small and fixed. Entries are shared between script and host, and the count is set when the VM is built: 8 by default, 32 in the
zymCLI. Registering when it is full raises a runtime error rather than silently doing nothing. - Slices below 1 are clamped to 1. Registering
0or a negative slice gives you1. - Errors name the method. Bad argument types raise, e.g.
Preempt.every(slice, fn): fn must be a function.
Preempt.capacity() and handle a registration that fails.Registering
Registers fn to run every slice instructions, rearming after each call.
slice(number) — instructions between calls; values below 1 clamp to 1fn(function) — zero-argument callback
Returns: the entry id, a number that is never 0.
Raises if fn takes arguments, or if no slot is free.
var processed = 0 var lastReport = 0 var reporter = Preempt.every(200000, func() { if (processed != lastReport) { print("... " + str(processed) + " rows") lastReport = processed } }) var i = 0 while (i < 1000000) { processed = processed + 1 i = i + 1 } Preempt.cancel(reporter)
The loop contains no reporting logic. The counts it prints are not round numbers, because the slice counts instructions, not iterations. The callback lands near the deadline, wherever that falls. The spacing is also the only reliable way to size a slice: 200,000 instructions buy about 22,000 rows here, which puts the loop body at roughly nine instructions. That ratio changes with the shape of the work, so measure it rather than guess.
Registers fn to run once, slice instructions from now. The entry retires as soon as it fires.
slice(number) — instructions until the call; values below 1 clamp to 1fn(function) — zero-argument callback
Returns: the entry id.
Raises if fn takes arguments, or if no slot is free.
Removes an entry.
Returns: true if it was removed, false if the id is unknown or not script-owned.
Tuning & Inspection
Changes an entry’s interval and restarts its countdown. It next fires n instructions from now, not n from when it was registered.
id(number) — the entry to retunen(number) — new slice; values below 1 clamp to 1
Returns: false if the id is unknown or not script-owned.
var id = Preempt.once(1000000, func() { print("deadline reached") }) print(Preempt.remaining(id)) // 1000000 Preempt.setSlice(id, 500) // fires 500 instructions from now print(Preempt.remaining(id)) // 500 var i = 0 while (i < 5000) { i = i + 1 } // "deadline reached" prints in here print(Preempt.remaining(id)) // -1 — the one-shot has retired
A Preempt.every entry reports its full slice again after firing, rather than -1.
Instructions left before this entry fires.
Returns: the count, or -1 if the id is unknown, not script-owned, or belongs to a one-shot that has already fired.
Makes an entry fire at the next instruction boundary instead of waiting out its countdown.
Returns: false if the id is unknown or not script-owned.
Budget
How many entries this script may hold in total, fixed for the whole run. This is not the size of the VM’s table. The host may have reserved slots for itself.
How many more entries the script can register right now. Never reports room that does not exist.
Returns the ids this script currently owns, in table order.
// Ask before spending, rather than registering and hoping. var watchers = [] var wanted = 3 var i = 0 while (i < wanted and Preempt.available() > 0) { push(watchers, Preempt.every(400000, func() { tick() })) i = i + 1 } // ids() lists only this script's own entries, which is what makes the // cleanup loop safe: it can never cancel something the host relies on. var live = Preempt.ids() var j = 0 while (j < length(live)) { Preempt.cancel(live[j]) j = j + 1 }
Critical Sections
Runs fn with every maskable entry suppressed, which means every entry the script owns. Suppression lifts when fn returns, and the deferred entries resume firing.
fn(function) — zero-argument function to run shielded
Returns: whatever fn returns, so a shield composes into an expression rather than forcing a temporary.
var account = { balance: 100, pending: 0 } var watcher = Preempt.every(5000, func() { print(str(account.balance + account.pending)) }) func transfer() { var i = 0 while (i < 40000) { account.balance = account.balance - 1 // A callback firing here would observe 99 + 0 = 99, not 100. account.pending = account.pending + 1 i = i + 1 } return account.balance } var result = Preempt.shield(transfer) // watcher observes nothing Preempt.cancel(watcher) print(str(Preempt.shieldDepth())) // 0 — back outside the shield
How many shields are currently nested. 0 when not inside one.
What Script Can and Cannot Do
Every entry a script registers is script-owned and maskable. Two consequences follow, and they are the point of the split:
- A script cannot touch host-owned entries.
cancel,setSlice, andrequestall check ownership and returnfalsefor an entry the host registered. A script cannot disarm the watchdog supervising it. - A shield only suppresses maskable entries, which means only the script’s own. A host watchdog registered non-maskable fires straight through
Preempt.shield(...), mid-critical-section, and there is nothing script can do about that.
Host entries are invisible, not merely untouchable. Preempt.remaining returns -1 for an entry the script does not own, and Preempt.ids() lists only its own, so probing the id space discovers nothing. A host that wants a script to see one of its deadlines exposes it through a native of its own; that is the host’s call to make.
The budget is the script’s own too. Preempt.capacity() reports what the host left it, which may be less than the VM’s table holds, and it cannot change while the script runs: whatever it reads at the start is still bindable at the end.
Notes
- Preemption is deterministic, not real-time. The same program preempts at the same points on every run and on every machine. A tight loop and an allocation-heavy loop cover very different amounts of wall-clock in the same slice.
- Callbacks are ordinary script. They allocate, they can raise, and they can be preempted by other entries, but not by their own, which is masked while it runs. An entry cannot re-enter itself.
- One callback runs per expiry. If several entries come due on the same instruction, the first by registration order runs and the rest keep their refreshed deadlines for a later pass.
- The table is shared with the host. A script that registers greedily exhausts its own budget and finds its later registrations failing; host entries are unaffected, because they were registered first.
- There is no way to observe being stopped. A host watchdog or stop aborts execution with no script-visible handler, no diagnostic, and no callback. The asymmetry is deliberate: anything a script could observe, it could stall inside.
Preemption & Continuations
The two subsystems meet at one point: a preemption callback is ordinary script, so it can capture a continuation. The interrupted computation needs to know nothing about either mechanism. It is suspended where it stood and handed to whoever receives the continuation at the prompt.
var FIBER = Cont.newPrompt("fiber") // Fires 10,000 instructions into whatever is running and suspends it. var slicer = Preempt.every(10000, func() { Cont.capture(FIBER) }) // Each preemption unwinds to the prompt, so withPrompt returns either // the finished value or the rest of the work. var result = Cont.withPrompt(FIBER, func() { return longRunningWork() }) while (Cont.isContinuation(result)) { var k = result result = Cont.withPrompt(FIBER, func() { return Cont.resume(k, null) }) } Preempt.cancel(slicer)
The continuation covers the interrupted work, not the callback: the callback’s own frames sit above the capture and are abandoned when control transfers to the prompt. longRunningWork itself contains nothing about either subsystem.
Cont.capture raises if its tag has no active prompt, so an entry that captures should be cancelled as soon as the work it slices is finished.Shields Across a Capture
A shield does not survive a capture. A continuation records the shield depth in effect where it was captured, and resuming restores that depth. A section captured inside a shield is still shielded when it is resumed, even if it is resumed somewhere else entirely; a section captured outside one does not inherit the resumer’s shield.
Control arriving at the prompt runs at the prompt’s own depth. A shield held by the captured section unwinds with it, and does not leak out to whatever receives the continuation.
Cont.pushPrompt/Cont.popPrompt (not withPrompt) in a scheduler loop to avoid frame accumulation across many resumes.Error Reference
| Error | Cause |
|---|---|
prompt tag not found | capture or abort with a tag that has no active prompt |
continuation already consumed | Attempting to resume a one-shot continuation twice |
prompt stack overflow | Too many nested prompts (max 64) |
maximum nesting depth exceeded | Too many nested withPrompt calls (max 64) |
no free preemption slots | Preempt.every or Preempt.once with a full preemption table |
fn must take 0 arguments | A preemption callback or shielded function declared with parameters |
Best Practices
- Always capture inside a function — never directly at the prompt level.
- Prefer
withPromptover manualpushPrompt/popPromptto avoid leaked prompts. - Check
Cont.isValid(k)before resuming. - Use named tags for easier debugging:
Cont.newPrompt("myLib.fiber"). - Keep prompt scopes small — capture only the computation you need.
- Shield critical sections: wrap multi-step updates in
Preempt.shieldso a callback cannot observe a torn intermediate state, and keep them short. - Read
Preempt.capacity()instead of hard-coding a slot count. The same script sees a different, equally correct answer under a different host. - Cancel entries you no longer need.
Preempt.ids()lists exactly the ones you own.
See also: GC API — Error Handling — Language Guide