Fiber Primitives

User-facing fiber operations and patterns.

Fiber Primitives

PrimitiveSignaturePurpose
fiber/new(fn mask) → fiberCreate fiber from closure with signal mask
fiber/resume(fiber value) → valueResume fiber, delivering a value# returns signal value
emit(bits value) → (suspends)Emit signal from current fiber
fiber/status(fiber) → keyword:new, :alive, :suspended, :dead, :error
fiber/value(fiber) → valueSignal payload or return value
fiber/bits(fiber) → intSignal bits from last signal
fiber/mask(fiber) → intCapability mask
fiber/parent(fiber) → fiber\nilParent fiber
fiber/child(fiber) → fiber\nilMost recently resumed child
fiber/propagate(fiber) → (propagates)Propagate caught signal, preserve chain
fiber/cancel(fiber value?) → valueHard-kill: set to :error, no unwinding
fiber/abort(fiber value?) → valueGraceful: inject error, resume for unwinding
fiber?(value) → boolType predicate

Primitives that need VM-side execution (fiber/resume) signal the VM to perform the context switch.

Coroutines

A coroutine is a usage pattern, not a type. It's a fiber whose closure yields:

(def gen (fiber/new (fn () (yield 1) (yield 2) (yield 3)) |:yield|))
(fiber/resume gen nil)  # → SIG_YIELD, (fiber/value gen) → 1
(fiber/resume gen nil)  # → SIG_YIELD, (fiber/value gen) → 2
(fiber/resume gen nil)  # → SIG_YIELD, (fiber/value gen) → 3
(fiber/resume gen nil)  # → SIG_OK, (fiber/value gen) → nil

The coroutine pattern — a fiber whose closure yields values — is the basis of Elle's stream and generator abstractions.

Error Handling

Errors are values: {:error :keyword :message "message"} structs. error_val(kind, msg) constructs them; format_error(value) extracts human-readable text.

There is no Condition type, no exception hierarchy, no handler-case. Error handling is signal handling:

1. Code signals SIG_ERROR with an error struct 2. Signal propagates up the fiber chain 3. A fiber with SIG_ERROR in its mask catches it 4. The handler inspects fiber/value and decides: handle, resume, or propagate further

Errors are not implicitly unwinding. An errored fiber remains suspended (not dead). A parent that catches an error can resume the errored fiber, delivering a recovery value — this enables restart-style error handling. Uncaught errors crash the runtime; no ev/join is needed for error propagation.

try/catch will be sugar for this pattern (blocked on macro system).

Terminal vs. Resumable Signals

Whether a signal is terminal or resumable is a handler decision, not a signal property. Any signal leaves the child in Suspended status. The handler either:

An uncaught SIG_ERROR at the root fiber is terminal by convention.

Exception: SIG_TERMINAL signals are uncatchable. They pass through mask checks regardless of the fiber's mask. This is how fiber/cancel self-cancel works — the terminal signal cannot be caught by protect or defer, ensuring the fiber dies immediately.

Cancel vs. Abort

Two distinct operations for ending a fiber's execution:

fiber/cancel — hard kill

Sets the fiber to :error status immediately. No VM dispatch, no frame execution, no defer/protect unwinding. The fiber is dead.

dispatch loop without unwinding. The terminal signal is uncatchable — it propagates through all masks including protect and defer

(def f (fiber/new (fn [] (defer (print :cleanup) (yield) :done)) |:error :yield|))
(fiber/resume f)          # f is now :paused
(fiber/cancel f :reason)  # f is now :error, :cleanup never printed

fiber/abort — graceful termination

Injects an error into a :paused fiber and resumes it. The fiber's error handlers (protect) and cleanup blocks (defer) execute during unwinding. The result is handled identically to fiber/resume — the child's actual outcome determines what the parent sees.

the fiber may end up :dead instead of :error

(def f (fiber/new (fn [] (defer (print :cleanup) (yield) :done)) |:error :yield|))
(fiber/resume f)          # f is now :paused
(fiber/abort f :reason)   # :cleanup printed, f is now :error

Aliases

cancel is an alias for fiber/cancel. abort is an alias for fiber/abort.

Fiber Swap Protocol

VM::with_child_fiber() is the single entry point for switching execution from a parent fiber to a child fiber. It owns the full lifecycle of a fiber resume: wiring the parent/child chain, swapping the active fiber and heap, executing the child's bytecode, and restoring everything before returning the result to the caller. All fiber/resume calls go through this function.

The protocol is more complex than a simple stack switch because each fiber owns its own heap. Swapping fibers therefore requires saving the current heap pointer, installing the child's heap, running the child, then restoring the parent's heap — in that order, with no leaks.

sequenceDiagram
    participant Caller as Caller (fiber/resume)
    participant VM as VM.with_child_fiber()
    participant Parent as Parent Fiber
    participant Child as Child Fiber
    participant Heap as Thread-Local Heap

    Note over VM: Step 1: Take child from handle
    VM->>Child: FiberHandle::take()
    activate Child
    Note over Child: child_fiber = owned Fiber

    Note over VM: Step 2: Wire parent/child chain
    VM->>Parent: parent.child = child_handle
    VM->>Parent: parent.child_value = child_value
    VM->>Child: child.parent = weak(parent_handle)
    VM->>Child: child.parent_value = parent_value

    Note over VM: Step 3: Swap fibers
    VM->>VM: mem::swap(vm.fiber, child_fiber)
    Note over VM: vm.fiber = child, child_fiber = parent

    Note over VM: Step 3a: Install child's heap
    VM->>Heap: save_current_heap() [parent's]
    VM->>Heap: install_fiber_heap(child.heap)
    VM->>Child: heap.init_active_allocator()

    Note over VM: Step 3b: Shared allocator (if child may yield/IO)
    alt Parent has shared_alloc (chain: A→B→C)
        VM->>Child: propagate parent's shared_alloc ptr
    else Parent has no shared_alloc
        VM->>Parent: create_shared_allocator() on parent's heap
        VM->>Child: set_shared_alloc(new ptr)
    end

    Note over VM: Step 4: Execute
    VM->>Child: execute(vm) [runs child's bytecode]
    Child-->>VM: SignalBits (e.g., SIG_YIELD)

    Note over VM: Step 5: Update child status
    alt SIG_OK
        VM->>Child: status = Dead
    else Any other signal
        VM->>Child: status = Paused
    end

    Note over VM: Step 6: Extract result
    VM->>Child: read fiber.signal → (bits, value)

    Note over VM: Step 7a: Clear child's shared_alloc
    VM->>Child: heap.clear_shared_alloc()

    Note over VM: Step 7: Swap back
    VM->>Heap: restore_saved_heap() [parent's]
    VM->>VM: mem::swap(vm.fiber, child_fiber)
    Note over VM: vm.fiber = parent, child_fiber = child

    Note over VM: Step 8: Return child to handle
    VM->>Child: FiberHandle::put(child_fiber)
    deactivate Child

    VM-->>Caller: (result_bits, result_value)

Swap protocol invariants

Heap pointer is never lost. The parent's heap pointer is saved before the child's is installed (step 3a), and restored before the swap-back (step 7). These two operations are always paired; there is no code path that installs the child's heap without a corresponding restore.

Shared allocator is gated on signal. A shared allocator is only provisioned for children whose signal type includes yield or I/O (step 3b). Silent children do not participate in shared allocation, so no unnecessary allocator is created.

Child's shared allocator is cleared before swap-back. Step 7a runs before step 7. This ordering prevents a dangling pointer: once the parent's heap context is restored, any pointer into the child's shared allocator would be invalid. Clearing first ensures the child holds no reference it cannot safely access after the context changes.

Child fiber is always returned to its handle. Step 8 runs unconditionally, including on error paths. A fiber that was taken from its handle at step 1 is always put back. Callers can always re-resume a paused fiber or inspect a dead one; the handle is never left empty by a failed resume.

Per-resume shared allocator accumulation (tech debt)

Each resume of a yielding or I/O child that has no pre-existing shared allocator creates a new shared allocator on the parent's heap (step 3b, else branch). Old shared allocators accumulate in FiberHeap::owned_shared and are not reclaimed until FiberHeap::clear() is called (typically at fiber death). For long-lived fibers that are resumed many times, this means unbounded growth of owned_shared. The fix is to reuse the existing shared allocator across resumes rather than creating a new one each time.

Source: src/vm/fiber.rs

What's Not Implemented Yet

FeatureStatus
fiber/closure, fiber/stack, fiber/envNot started
Dynamic bindings (dyn/setdyn)env field exists, no primitives

See also