Algebraic effects and handlers
Handlers are the orthogonal layer that reinterprets an operation. That system calls are algebraic effects is the principle; whether to install a handler over an operation — and whether that handler reifies a continuation — are two independent choices layered on top, and ral takes the smallest on both: a restricted, continuation-free fragment.
ral installs effect handlers over a dynamic frame stack with
within [handlers: [name: handler], handler: fallback] { body }. A handler maps
an operation name to a lambda; the catch-all handler: applies when no per-name
entry matches. Handlers intercept external commands only, and only at the
bare head — lexical bindings, builtins, and the prelude are not shell aliases
and cannot be overridden, and ^name, like a path head, skips the handler
stack too and reaches the external binary directly.
A per-name handler (and every alias) must be a unary lambda { |args| … },
applied to the intercepted command’s argument list; the catch-all must be a
binary lambda { |name args| … }, applied to the command’s name and its
argument list. The calling convention is fixed by the surface position, not the
value’s runtime shape: a bare block { … }, a non-lambda value, or a lambda of
the wrong arity is rejected at install time.
A handler reinterprets an operation; it cannot re-declare its type. An arm
stands in for the head it is installed under: its type is that of the base
frame or arm already in force there, and List String → Command for any other
command. So an arm for curl, per-name or under the catch-all, has a command’s
value, its output, and a ()-returning stub is admitted and captures ""; an
arm for detach returns what detach returns. The command
world is argv in, bytes out, uniformly. A value-returning, variable-arity
command is therefore not expressible; that capability belongs to the value
world instead, as a binding of fixed arity taking a list —
let f = { |xs| … }; f [a, b, c]. A name is a value or it is handled; if it is handled, it is a command, and a command’s value is its output
(graded-f).
ral’s handlers are the tail-resumptive fragment of algebraic-effect handlers, along three axes — the first two recorded in handlers-deep-self-masking:
- Deep. A handler persists across the dynamic extent of the body, re-wrapping each continuation, so successive operations all reach it.
- Self-masking. While a handler’s own body runs, its frame is lifted from the stack, so a same-name call inside the body reaches the next outer handler (or the OS), never itself.
- Tail-resumptive. A handler receives the command name and arguments and either returns a value, forwards to the next enclosing frame, or fails. A returned value is substituted for the intercepted operation and the body resumes — that substitution is the resumption.
This is why there is no first-class resume: resumption is implicit,
exactly-once, and in tail position. What ral omits is the reified continuation
that non-tail or multi-shot resumption would require. The natural idiom is
wrap-and-forward, within [handlers: [git: { |args| my-git ...$args }]] { ... },
which self-masking keeps from diverging; the live semantic question is therefore the
masking discipline — which handler is in scope at each point — not how a captured
continuation is re-entered.
Owners and the shield are poll handlers
The same shape reaches past commands. Cancellation is one operation, poll : 1 → 1, performed at every step boundary (Shell::poll) and once per slice
inside a blocked wait; under a nonzero join on the current scope it raises
stop ν c, an escape naming the node ν that attained the join. The machine’s
stack already holds the frames that interpret it. try interprets fail and
nothing else, so a stop walks past it as exit does. A base frame — the
top of a run, a worker, a pipeline stage — is one handler: { return x ↦ R x; fail e ↦ pass; stop ν c ↦ if ν = me then R′ c else pass; poll k ↦ if j ≠ 0 then stop ν j else k () }, every other operation forwarded. Its return clause
opens with a closing poll (final_poll): the node is read once more after
the body settled, so a stop that landed after the last poll is not lost. A
worker’s stop clause is invariant I6
(await-is-the-block-run-here):
it converts a Cancelled or TimedOut stop owned by any node but the session
root into the handle’s error, and a root cause or an interrupt passes; a stage
forwards everything, and its collector consumes every stop naming that
stage’s node; the top names no node of its own, so exit and the host’s
stops are top-level effects no frame of the program interprets, read off
the empty stack as an Ending.
The shield is the fourth instance, poll k ↦ k () while it masks:
Frame::Cleanup swaps Shell::scope for a shielded child for a guard
cleanup’s extent and restores it on pop and abandon, exactly as
Frame::Capture swaps io.stdout. So Shell::scope is a dynamically scoped
cell beside io.stdout, read by every poll; the owner’s name is
Mooring::home, run-invariant and the target of every write; the types say so, Scope against ScopeNode. Owner-frame
masking here is the same shape as self-masking above: a frame lifts what an
outer one would answer for the extent of its own body
(delimiters-are-handlers-for-poll,
a-cleanup-is-shielded-within-its-grace,
cancellation).
Self-masking removes exactly the owning frame; nearer unmatched frames remain
visible too. In particular, an outer named arm wins over a nearer catch-all,
and that catch-all remains available while the named arm runs. The checked
frozen Agda CK.Machine.HandlerSelection development in dev/agda states this as ordered frame
deletion and proves preservation of predicates on the remaining code.
HandlerCompleteness connects command invocation to compositional semantics
when installed code belongs to the scoped fundamental fragment, including
recursion, capture, recovery and cleanup. Installation and capture preserve
that agreement; an example recovers from a handler’s failure through capture.
This fragment includes direct external commands inside handler bodies but
excludes named calls, so general
forwarding and recursive handler invocation remain proof obligations. The
model also omits Rust’s permanent base-handler layer between named run entries
and catch-alls.
The separate CoveragePreservation theorem now permits named calls inside
stored code and recursive handler bodies: renaming, substitution and semantic
evaluation preserve membership in the interpreted fragment. ScopeCoverage
shows that dispatch selects covered code and retains a covered scope after
masking. The result theorem needs no ambient-code premise because command
results in this core are Unit; the invocation completeness proof still
needs the scope premise. This supplies the invariant for general named
forwarding. Agreement for all finite executions of that fragment remains open.
The active CEK development now supplies a separate permanent base table in
CEK.HandlerSelection: explicit user names are searched first, base entries
second, and user catch-alls last. Its executable selector is total, sound,
complete and deterministic. Masking deletes exactly the chosen user frame,
retains all nearer unmatched frames in order and preserves the base table.
Base entries run unmasked. CEK.RuntimeHandlers evaluates user arms as typed
lexical closures, with argv alone for named arms and name then argv for
catch-alls; base entries are separate host tokens. CEK.HandlerApplication
builds typed CEK applications and returns unit/error/exit across the arm’s and
caller’s differing type scopes. Continuation and capture proofs cover the
return frame. Configurations now carry handler scope separately from lexical
bindings. Installation, named/catch-all invocation, exact owner masking,
restoration on return/error/exit and unmasked host base calls execute.
Base replies retain native unit/error/exit and bounded byte traffic; complete
error records survive scope crossing. Checked traces cover forwarding to base,
successive calls, bypass, catch-alls, capture and escaping closures using caller
handlers. Both capture simulations cover the new rules.
The provisioned base table is permanent along every finite execution prefix.
CEK.HandlerInvariant requires the same table in active and saved handler scopes;
internal computation, polymorphic continuations and capture routing preserve it.
CEK.HandlerPreservation covers every configuration step and establishes the
invariant for every state reachable from a closed program. Exact table equality
preserves all base lookups, without termination or world-determinism assumptions.
Handler entry and restoration account for exactly two steps.
CEK.HandlerAgreement relates finite runs of installation and selected
named/catch-all invocation to their body executions at an empty continuation.
The body has identical output, outcome and world effects; completion restores
the caller scope. Reverse extraction removes precisely entry and restoration,
providing a strict measure without assuming termination. Nested handlers,
captures and fallible world operations are covered. This is agreement between
machine runs.
CEK.CommandSemantics.Call unifies direct, named-arm, catch-all, native base and
external-fallback execution, independently of the machine. Arm evaluation is
an explicit parameter: arms run masked, and every completed call restores
caller handlers. CEK.CommandAgreement proves exact finite-run agreement for
both command heads when the parameter is interpreted by typed arm application
runs. Output, outcome and the complete final context are preserved. Refining
the arm relation refines calls.
CEK.TermEvaluation gives the independent arm interpretation, mutually with
term, closure, application and polymorphic-builder evaluation. Calls use the
same selection rules, while capture evaluates under the accepting stdout world
and uses real replies when flushing outward. Completion preserves the incoming
handler scope. Checked derivations include forwarding, repeated calls,
catch-alls and captured output. CEK.TermSoundness proves refinement to machine
arm runs at arbitrary caller and captured scopes, including enclosing stdout
captures. Output routing, cross-scope error conversion and the complete final
context are preserved. A continuation interpretation now supports backward
reflection of internal steps and caller-scope restoration.
CEK.TermEffectsReflection also recovers independent arm derivations from
actual dispatch and command replies. CEK.TermCompleteness gives exact
finite-run agreement at the empty continuation, preserving output, typed
result and the entire final context. Arms may be recursive and polymorphic;
worlds need be neither deterministic nor total. Contextual handler laws and
Rust refinement remain open.
See also syscalls-are-effects, control-operators, scoping, grant, system-c (self-masking as capability-set subtraction), handlers-of-algebraic-effects (the calculus this fragment restricts).
Realised in handler-dispatch.
Cite: RATIONALE §“Effect handlers reinterpret external names”;
docs/SPEC.md §9.6.