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:

Preempt:

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.

ModulePurpose
ContDelimited continuation primitives (prompt tags, capture, resume, abort, shift)
PreemptScheduled 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.

Capture Scope
┌─────────────────────────────────────────────┐
│  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:

  1. Install prompts — Mark where continuations can be captured to
  2. Capture — Specify which prompt to capture up to
  3. 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!

Basic Example
// 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

Cont.newPrompt()

Creates a new, unique prompt tag.

Cont.newPrompt(name)

Creates a prompt tag with a debug name (shown in error messages).

var tag = Cont.newPrompt("fiber")
Cont.isPromptTag(value)

Returns true if value is a prompt tag.

Installing Prompts

Cont.withPrompt(tag, fn)

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.

withPrompt
var tag = Cont.newPrompt("work")

var result = Cont.withPrompt(tag, func() {
    print("Inside prompt scope")
    return 42
})
print(result)   // 42
Cont.pushPrompt(tag)

Manually installs a prompt boundary. You must call Cont.popPrompt() when done.

Cont.popPrompt()

Removes the topmost prompt from the stack.

Capture & Resume

Cont.capture(tag)

Captures the continuation from the current point up to the specified prompt, then transfers control back to the prompt location.

Critical: Always call capture() inside a helper function. If you capture directly at the prompt level, the variable holding the continuation gets overwritten when resumed.
capture pattern
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
Cont.resume(continuation, value)

Resumes a captured continuation with the provided value. The continuation is consumed after this call (one-shot).

Returns: the eventual return value from the resumed computation.

Cont.isContinuation(value)

Returns true if value is a continuation object.

Cont.isValid(continuation)

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

Cont.abort(tag, value)

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

Cont.shift(tag, handler)

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.

shift
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

generator with shift
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

early return
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

Do not hard-code the capacity: a script that needs to know how many entries it may hold should read Preempt.capacity() and handle a registration that fails.

Registering

Preempt.every(slice, fn)

Registers fn to run every slice instructions, rearming after each call.

Returns: the entry id, a number that is never 0.

Raises if fn takes arguments, or if no slot is free.

progress ticker
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.

Preempt.once(slice, fn)

Registers fn to run once, slice instructions from now. The entry retires as soon as it fires.

Returns: the entry id.

Raises if fn takes arguments, or if no slot is free.

Preempt.cancel(id)

Removes an entry.

Returns: true if it was removed, false if the id is unknown or not script-owned.

Tuning & Inspection

Preempt.setSlice(id, n)

Changes an entry’s interval and restarts its countdown. It next fires n instructions from now, not n from when it was registered.

Returns: false if the id is unknown or not script-owned.

moving a deadline
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.

Preempt.remaining(id)

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.

Preempt.request(id)

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

Preempt.capacity()

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.

Preempt.available()

How many more entries the script can register right now. Never reports room that does not exist.

Preempt.ids()

Returns the ids this script currently owns, in table order.

working within the budget
// 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

Preempt.shield(fn)

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.

Returns: whatever fn returns, so a shield composes into an expression rather than forcing a temporary.

critical section
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
A shield is not a way to become uninterruptible. It defers the script’s own callbacks so a short critical section is not re-entered partway through. It does not extend the instruction budget, it does not suppress a host entry, and it cannot outlast a host stop. Keep shielded sections short. While one is up, the script’s own scheduled work is not running.
Preempt.shieldDepth()

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:

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 & 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.

suspending the running computation
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.

Keep a prompt live while the entry is armed: 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.

Scheduler pattern: Use Cont.pushPrompt/Cont.popPrompt (not withPrompt) in a scheduler loop to avoid frame accumulation across many resumes.

Error Reference

ErrorCause
prompt tag not foundcapture or abort with a tag that has no active prompt
continuation already consumedAttempting to resume a one-shot continuation twice
prompt stack overflowToo many nested prompts (max 64)
maximum nesting depth exceededToo many nested withPrompt calls (max 64)
no free preemption slotsPreempt.every or Preempt.once with a full preemption table
fn must take 0 argumentsA preemption callback or shielded function declared with parameters

Best Practices


See also: GC APIError HandlingLanguage Guide