Signal Protocol

The Signal Protocol

Signal Types

Signal types are bit positions in a 64-bit bitfield (SignalBits is u64). The lower 32 bits are reserved for the runtime; the upper 32 bits are available for user-defined signals. Within the runtime's 32 bits, the first 18 are allocated:

BitNameValueMeaning
—ok0Normal return (no bits set)
0error1Error
1yield2Cooperative suspension
2debug4Breakpoint / trace
3resume8VM-internal: fiber resume request
4ffi16Calls foreign code
5propagate32VM-internal: propagate caught signal
6abort:error + terminalVM-internal: graceful fiber termination
7query128VM-internal: read VM state
8halt256Graceful VM termination
9io512I/O request to scheduler
10terminal1024Uncatchable — passes through mask checks
11–17runtime—Runtime signals and capability bits (runtime.md)
18–31reserved—Future runtime signals
32–63user—User-defined signal types

Bit 0 is special: "ok" means no bits are set. A normal return has an empty signal bitfield.

The resume signal is how fiber/resume works — the primitive signals the VM to perform the actual context switch.

Signal Values

A signal carries a type (which bit) and a payload (an Elle Value). The return from run() is:

signal_bits: SignalBits

Where signal_bits == 0 means normal return with the result on the fiber's operand stack. Non-zero signal_bits means something happened that may require handling — the signal value is stored in fiber.signal (the canonical location).

Signal payloads are arbitrary Values. Any value can be an error payload, a yield value, or a user-defined signal payload. There is no Condition type or exception hierarchy. Pattern matching on the payload replaces hierarchy checks — the handler inspects the signal value and dispatches accordingly.

At the Rust level, the signal is stored on the fiber:

pub signal: Option<(SignalBits, Value)>

The run() function returns only the SignalBits. The value is stored on the fiber's signal field — the canonical location. The fast path (normal return) is bits == 0, which is a single branch.

Signal Composition

SignalBits is a pure bitmask. Every bit is independent and orthogonal. There are no "types" of signals — only bits. The VM and schedulers check for individual bits using intersects(), never with equality. Any combination of bits is valid and meaningful — the caller decides what the combination means.

intersects() asks whether the two sets share at least one bit. It is symmetric, and it is true for a partial overlap: (A|B).intersects(B|C) holds. It is not a subset test. SignalBits has no subset test, because no caller wants one — a mask catches a compound signal on any shared bit, and there is no exception. covers() is the routing question a fiber mask asks, and it is intersects() plus the empty-signal case; see docs/signals/capabilities.md for what that means for a mask.

Examples of valid composed signals:

three bits

catches both

No bit has a predetermined relationship with any other bit. The design makes no pre-determinations on how bits are mixed. Users and schedulers define the semantics of combinations.

Fiber masks work the same way. A fiber mask like |:yield :io| catches fibers that have either bit set. The mask is a bitmask, not an enum.

User-defined signals (bits 32–63) compose freely with built-in bits. A user-defined signal can be combined with :yield, :error, :io, or any other bit. Bits 0–31 are reserved for the runtime.

Terminal vs Resumable Signals

Whether a signal is terminal or resumable is a handler decision, not a signal property. The handler catches the signal and either resumes the child (resumable) or doesn't (terminal). The same signal type can be handled resumably in one context and terminally in another, depending on the handler's choice.

Propagation

Signal propagation (Janet model):

1. Child fiber emits signal: stores value in child.signal, sets status → Suspended 2. run() returns signal bits to the parent 3. Parent checks: child.mask & bits != 0? (The child's mask records what signals the parent should catch from it) - Caught: Parent handles the signal. Child is suspended and reachable via parent's child pointer. - Not caught: Parent also suspends (entire chain freezes). Signal propagates up until caught or reaches root. 4. Handler walks child chain to find originator. Every fiber in the chain is suspended and inspectable via fiber/value.

This is O(1) dispatch — a single AND operation. No handler chain traversal. When a handler catches a signal, it can walk the fiber chain to inspect the propagation path. Every fiber in the chain is suspended and can be resumed independently for non-unwinding recovery.

Reaching the root

The root of the program is where propagation stops. :error and :halt have answers there: the error prints with the location of the form that raised it, and the halt ends the program with its value. Every other signal reaches the root with no handler left to run, so the program stops and reports which signal went unhandled:

Unhandled signal {:io} outside fiber context

The report names the emitted bits through the signal registry. A raw mask names no signal: the reader would have to know which bit position each primitive raises. :io at the root means the program ran no scheduler. A user-defined keyword at the root means no fiber masked it.

One message answers for every bit. Nothing caught the signal, and that is the whole condition, so :yield gets no report of its own.

The Fiber Structure

Fiber {
    stack: SmallVec<[Value# 256]>           -- operand stack
    frames: Vec<Frame>                       -- call frames (closure + ip + base)
    status: FiberStatus                      -- New/Alive/Suspended/Dead/Error
    mask: SignalBits                          -- which signals parent catches
    parent: Option<WeakFiberHandle>          -- weak back-pointer (avoids Rc cycles)
    child: Option<FiberHandle>               -- most recently resumed child
    closure: Rc<Closure>                     -- the closure this fiber wraps
    env: Option<HashMap<u32, Value>>         -- dynamic bindings (future)
    signal: Option<(SignalBits, Value)>       -- signal payload or return value
    suspended: Option<Vec<SuspendedFrame>>   -- frames for resumption
    call_depth: usize                        -- stack overflow detection
    call_stack: Vec<CallFrame>               -- for stack traces
}

The closure carries its signal bits. The fiber's mask determines which signals it catches from children. There is no signals field on the Fiber — signals are a compile-time property of the closure, not the fiber.

See docs/fibers.md for the full Fiber, SuspendedFrame, and FiberHandle documentation.

The Signal System

Signal Bits (Static)

A signal (static) is a set of signal types that a function might emit. Represented as a bitfield (same type as signal bits).

type SignalBits = SignalBits  -- same bitfield type, same bit positions

Operations:

Compile-Time Inference

The compiler walks the AST and infers signals:

signal

Parametric Polymorphism

Higher-order functions propagate their arguments' signals. map's signal is "whatever f does, plus my own base signals." The compile-time representation:

Signal {
    bits: SignalBits,           -- from own body
    propagates: u32,            -- bitmask of parameter indices
}

Resolved signal at a call site:

call_signal = f.bits | union(signal(arg[i]) for i in 0..param_count if (propagates & (1 << i)) != 0)

If the compiler can see the concrete argument (e.g., it's the + primitive), it can resolve the polymorphism statically and potentially prove the call site has fewer signals than the general case.

Note: Signal bounds on parameters (constraining what signals callbacks may have) are deferred to a future phase. When needed, they'll be tracked in the analysis environment, not on the Signal struct itself — keeping Signal as a simple Copy pair.

Signal Restrictions

The programmer can restrict signals on functions using silence (compile-time total suppression):

# Require the function body to be completely silent
(defn select [flag a b]
  (silence)
  (if flag a b))

And signal bounds on parameters:

(defn fast-map (f xs)
  (silence f)   # f must be completely silent
  (map f xs))

These are compile-time contracts. The system enforces them statically and at runtime.

Compile-time Signal Absorption with muffle

muffle absorbs specific signals from a function body, allowing silence functions to contain operations that declare those signals:

# Arithmetic declares :error but we know inputs are numeric
(defn fast-add [x y]
  (silence)
  (muffle :error)
  (+ x y))

# Muffle a set of signals
(defn fast-square [x]
  (silence)
  (muffle |:error|)
  (* x x))

The function's external signal excludes muffled bits — callers see it as silent. Runtime enforcement (vm/call.rs) aborts if a muffled signal actually fires.

muffle also works without silence, subtracting the muffled bits from the inferred signal:

# Body infers {:error}, muffle removes it → external signal is silent
(defn add-quiet [x y]
  (muffle :error)
  (+ x y))
FormScopeEffect
(silence)whole bodybody must be fully silent (compile error otherwise)
(silence f)parameterf must be silent when passed
(muffle :error)specific signalabsorb :error from body; abort if it fires

Signal Enforcement with squelch

squelch is a closure transform primitive with both runtime enforcement and compile-time signal inference:

(defn f [] (yield 42))

# squelch a single signal
(def safe-f (squelch f :yield))

# squelch multiple signals with a set
(def f2 (squelch f |:yield :io|))

At runtime, when a squelched closure is called, if it emits a squelched signal, a signal-violation error is raised instead. Non-squelched signals pass through normally. Errors are never affected by squelch.

Four bit classes cross every boundary untouched, whatever the mask names: :error, :halt, the :switch trampoline, and the pause bits (:fuel). A pause is the VM's own suspension, injected at a charge site under whatever code runs there, and the metering parent owns it — so a boundary has nothing to enforce and (squelch f :fuel) is inert. The exemption removes the pause bits alone: a compound signal that carries a pause plus a squelched user bit still raises the violation for the user bit.

At compile time, when both arguments are statically known, the analyzer computes the resulting signal using the same algebra as the runtime effective_signal(). This enables (silence) on functions that call squelched closures. See inference.md for details.

Signature: (squelch closure signals)

Error cases:

I/O Signals

I/O and the Scheduler

I/O signals use the :io bit (bit 9). A fiber performing I/O signals :yield and :io because it wants to both suspend AND request I/O handling. The bits compose freely.

Signal constructors:

Predicate: may_io() — check if signal includes I/O

Stream Primitives and I/O Requests

Stream primitives (port/read-line, port/read, port/read-all, port/write, port/flush) have signal io_errors(). They do not perform I/O themselves. Instead, they:

1. Build an IoRequest (typed descriptor of the I/O operation) 2. Return (|:io|, request) to raise the request 3. Let the scheduler catch the fiber on the :io bit and dispatch the IoRequest payload to a backend

The backend (AsyncBackend) performs the actual I/O and returns (|:ok|, result) or (|:error|, error). The scheduler resumes the fiber with the result.

:io does not imply :yield

An I/O request raises |:io| and nothing else. It does suspend the calling fiber — but so does every signal. Suspension follows from raising a signal at all, not from any particular bit: signals::dispatch::is_suspending parks on anything that is neither empty, nor an error, nor a halt, and the VM and the WASM tier both ask it.

So :yield means one thing, the cooperative suspension (yield v) raises and a |:yield| mask catches. Bundling it onto I/O requests made it mean two, and a mask naming it could no longer say which one it wanted:

This is what lets a generator masked |:yield| do I/O in its body: port/lines (src/stdlib.lisp), tls/lines (lib/tls.lisp), and the SSE streams in lib/http.lisp all have that shape. Their (yield line) is caught by the consumer; their port/read-line raises |:io|, which the mask does not name, so it travels out to the scheduler. Pinned by tests/elle/io-request-carries-no-yield.lisp.

Signal Registry

The signal registry maps signal keywords to bit positions. Built-in signals occupy bits 0–17; bits 18–31 are runtime-reserved; user-defined signals use bits 32–63.

Built-in Signals

KeywordBitMeaning
:error0Error signal
:yield1Cooperative suspension
:debug2Breakpoint/trace
:ffi4Calls foreign code
:halt8Graceful VM termination
:io9I/O request to scheduler

Bits 3, 5–7 are VM-internal (resume, propagate, query). Bits 10–14 are VM-internal (terminal, exec, fuel, switch, wait). Bits 15–17 name capabilities (gpu, os-signal, fs) and bits 18–31 are reserved for future runtime signals — see ../runtime.md.

User-Defined Signals

User-defined signals are registered via the (signal :keyword) special form and allocated bits 32–63 sequentially. Up to 32 user signals are supported. Registration happens at analysis time.

(signal :heartbeat)
(signal :rate-limit)
# :heartbeat gets bit 32, :rate-limit gets bit 33

# Expression position — returns the keyword
(def my-signal (signal :custom))
my-signal  # => :custom

Duplicate registration is a compile-time error. Built-in signal keywords cannot be re-registered.


See also