Preemption

Time-sliced execution and cooperative yielding — build fiber schedulers, fair multitasking, and sandboxed execution entirely from script.

Overview

Preemption allows the VM to automatically interrupt long-running code after a configurable number of instructions. When combined with continuations, this enables powerful patterns like cooperative fibers, round-robin schedulers, and resource-bounded execution — all written in pure Zym script.

All preemption functions are accessed through the global Preempt module. Preemption is disabled by default and has zero overhead when not in use.

How It Works

Every instruction the VM executes decrements a yield budget. When the budget reaches zero (or a yield is manually requested), the VM triggers a preemption event. What happens next depends on whether you have registered a callback:

Key Concepts

ConceptDescription
TimesliceNumber of instructions before a preemption event fires
Yield BudgetRemaining instructions in the current timeslice
CallbackA script function invoked automatically on preemption
Disable DepthLayered counter that suppresses preemption when > 0

Getting Started

Basic Preemption

The simplest use of preemption is to detect when code has run for too long:

detecting preemption
var preempted = false

// Register a callback that fires on preemption
Preempt.setCallback(func() {
    preempted = true
    print("Preempted!")
})

// Enable with a 1000-instruction timeslice
Preempt.enable()
Preempt.setTimeslice(1000)

// This loop will be interrupted after ~1000 instructions
var i = 0
while (i < 100000) {
    i = i + 1
}

print("Was preempted: " + str(preempted))  // true

Cooperative Yielding

You can also yield explicitly without waiting for the timeslice to expire:

manual yield
Preempt.setCallback(func() {
    print("Yielded!")
})
Preempt.enable()

// Do some work...
print("Working...")

// Yield control at a convenient point
Preempt.request()

// Execution continues after the callback returns
print("Resumed!")

API Reference

Enable / Disable

Preempt.enable()

Enables preemption. The VM will start checking the yield budget on every instruction. Has no effect if the disable depth is greater than zero (preemption remains suppressed until all layers are popped).

Preempt.disable()

Globally disables preemption. The VM stops checking the yield budget entirely. This is a hard disable — it ignores the disable depth.

Preempt.isEnabled()

Returns true if preemption is enabled and the disable depth is zero. This reflects whether preemption will actually fire.

Preempt.enable()
print(Preempt.isEnabled())    // true

Preempt.pushDisable()
print(Preempt.isEnabled())    // false (suppressed by depth)

Preempt.popDisable()
print(Preempt.isEnabled())    // true

Timeslice Control

Preempt.setTimeslice(instructions)

Sets the number of instructions per timeslice. When the yield budget reaches zero, a preemption event fires and the budget is reset to this value. Minimum is 1.

Preempt.setTimeslice(10000)   // Default-like: ~10k instructions per slice
Preempt.setTimeslice(500)     // Aggressive: frequent preemption
Preempt.setTimeslice(100000)  // Relaxed: less overhead
Preempt.getTimeslice()

Returns the current timeslice setting.

Preempt.remaining()

Returns how many instructions remain in the current timeslice before the next preemption event.

Preempt.reset()

Resets the yield budget to a full timeslice and clears any pending preemption request. Useful when resuming a fiber to give it a fresh time allocation.

Yield Control

Preempt.request()

Requests a preemption event at the next opportunity. The event fires at the next instruction boundary, regardless of remaining budget.

Preempt.yield()

Requests immediate preemption by setting the remaining budget to zero and flagging a request. Equivalent to calling Preempt.request() with zero budget.

Callback

Preempt.setCallback(fn)

Registers a zero-argument function as the preemption callback. When preemption fires, the VM calls this function directly. The callback runs with preemption automatically disabled to prevent recursive preemption.

Preempt.setCallback(func() {
    // This runs when preemption fires
    // Preemption is disabled inside the callback
    print("Tick!")
})
Important: The callback runs with preemption disabled. You do not need to manually disable preemption inside it. When the callback returns, the previous preemption state is restored.

Scoped Disable

Preempt.pushDisable()

Increments the disable depth by one. While the depth is greater than zero, preemption events will not fire even if preemption is enabled.

Preempt.popDisable()

Decrements the disable depth by one. When the depth returns to zero, preemption can fire again.

Preempt.getDisableDepth()

Returns the current disable depth as a number.

Preempt.withDisabled(fn)

Executes fn (zero-argument function) with the disable depth incremented. The depth is automatically decremented when fn returns, even if a continuation is captured.

scoped disable
// Protect a critical section from preemption
Preempt.withDisabled(func() {
    // Preemption cannot fire in here
    updateSharedState()
    commitTransaction()
})
// Preemption can fire again here

Layered Disable

The disable depth is a counter, not a boolean. This means nested disables stack correctly:

nested disable
Preempt.enable()
print(Preempt.isEnabled())       // true

Preempt.pushDisable()            // depth = 1
print(Preempt.isEnabled())       // false

Preempt.pushDisable()            // depth = 2
print(Preempt.isEnabled())       // false

Preempt.popDisable()             // depth = 1
print(Preempt.isEnabled())       // false (still suppressed)

Preempt.popDisable()             // depth = 0
print(Preempt.isEnabled())       // true

Preemption & Continuations

Preemption becomes truly powerful when combined with continuations. The preemption callback can capture a continuation to save the interrupted computation, then a scheduler can decide what to run next.

The Pattern

The general approach for building a fiber scheduler is:

  1. Create a prompt tag for your fiber boundary
  2. Register a callback that captures a continuation on preemption
  3. Run each fiber inside a prompt boundary
  4. On preemption, the callback captures the fiber’s state as a continuation
  5. The scheduler picks the next fiber and resumes it
Key insight: The preemption callback sees the fiber’s continuation as a normal Cont.capture() result. The scheduler doesn’t need to know whether the fiber yielded voluntarily or was preempted — the continuation works the same way.

Fiber Scheduler Example

This example demonstrates a round-robin fiber scheduler using preemption. It uses Cont.pushPrompt / Cont.popPrompt for the prompt boundary, which is important for long-running schedulers.

Why pushPrompt instead of withPrompt? Using Cont.withPrompt(tag, fn) inside a scheduler loop creates a new frame each iteration. When a fiber is preempted and captured, that wrapper frame is included in the continuation. On the next resume, yet another wrapper is added. After N preemptions, the continuation contains N wrapper frames — unbounded growth that will eventually overflow. Using raw pushPrompt/popPrompt installs the prompt at the current frame level without adding a frame, keeping continuation size constant across preemption cycles.
fiber scheduler
var FIBER_TAG = Cont.newPrompt("fiber")
var fiberQueue = []
var currentFiberId = 0

// Preemption callback: captures the interrupted fiber
func onPreempt() {
    Cont.capture(FIBER_TAG)
}

// Create a dormant fiber from a function
func createFiber(id, fn) {
    return Cont.withPrompt(FIBER_TAG, func() {
        func start() {
            var received = Cont.capture(FIBER_TAG)
            fn()
            return "done"
        }
        return start()
    })
}

func spawn(id, fn) {
    var k = createFiber(id, fn)
    push(fiberQueue, { id: id, continuation: k })
}

// Spawn some fibers
spawn(1, func() {
    var i = 0
    while (i < 5) { print("Fiber 1: " + str(i)); i = i + 1 }
})
spawn(2, func() {
    var i = 0
    while (i < 5) { print("Fiber 2: " + str(i)); i = i + 1 }
})

// Configure preemption
Preempt.setCallback(onPreempt)
Preempt.enable()
Preempt.setTimeslice(500)

// Scheduler loop
while (length(fiberQueue) > 0) {
    var fiber = shift(fiberQueue)
    currentFiberId = fiber.id

    // Install prompt and resume the fiber
    Preempt.pushDisable()
    Cont.pushPrompt(FIBER_TAG)
    Preempt.popDisable()
    Preempt.reset()

    var result = Cont.resume(fiber.continuation, null)

    // Preemption is now disabled (callback ran with it disabled)
    if (Cont.isContinuation(result)) {
        // Fiber was preempted — re-queue it
        fiber.continuation = result
        push(fiberQueue, fiber)
    } else {
        print("Fiber " + str(fiber.id) + " completed")
    }
}

print("All fibers done.")

Common Patterns

Protecting Critical Sections

Use Preempt.withDisabled or pushDisable/popDisable around code that must not be interrupted:

critical section
func atomicTransfer(from, to, amount) {
    Preempt.pushDisable()
    from.balance = from.balance - amount
    to.balance = to.balance + amount
    Preempt.popDisable()
}

// Or with the scoped helper:
func atomicTransfer(from, to, amount) {
    Preempt.withDisabled(func() {
        from.balance = from.balance - amount
        to.balance = to.balance + amount
    })
}

Sandboxed Execution

Use preemption to limit how many instructions untrusted code can run:

sandboxing
var tooLong = false
var SANDBOX_TAG = Cont.newPrompt("sandbox")

Preempt.setCallback(func() {
    tooLong = true
    Cont.abort(SANDBOX_TAG, "timeout")
})

Preempt.enable()
Preempt.setTimeslice(50000)  // 50k instruction limit

var result = Cont.withPrompt(SANDBOX_TAG, func() {
    // Run untrusted code here
    return untrustedFunction()
})

if (tooLong) {
    print("Code exceeded instruction limit!")
} else {
    print("Result: " + str(result))
}

Preempt.disable()

Voluntary Yield Points

In cooperative multitasking, fibers can yield explicitly at convenient points:

cooperative yield
func processItems(items) {
    var i = 0
    while (i < length(items)) {
        doExpensiveWork(items[i])
        i = i + 1

        // Yield after each item to let other fibers run
        Preempt.yield()
    }
}

Preemption State & Continuations

When a continuation is captured, the current preemption disable depth is saved as part of the continuation’s state. When the continuation is resumed, the saved disable depth is restored.

This means:

state preservation
// Fiber was preempted with disable depth = 0
// Scheduler runs with disable depth = 1
Preempt.pushDisable()
// ... scheduler bookkeeping ...

// Resume fiber — disable depth is restored to 0
Cont.resume(fiber.continuation, null)

// After capture, disable depth returns to scheduler's state

Error Reference

ErrorCause
argument must be a functionsetCallback or withDisabled received a non-function argument
argument must be a numbersetTimeslice received a non-number argument
function must take 0 argumentswithDisabled callback must be a zero-argument function
stack overflowMaximum call depth reached during preemption handling

Best Practices


See also: ContinuationsCLI OverviewGarbage Collection