plan.org now says every Flan function carries the transfer channel, that uniformity is what keeps indirect calls and hot reload ABI-safe, and that a later optimisation cannot change the ABI. spec-conditions.md §6 still read the other way round - escape analysis deciding which functions are transfer-transparent, with the rest paying nothing - which describes a signature that depends on an analysis, and a cell cannot hold one of those. So the analysis is demoted to what it can still honestly do: a function that provably cannot transfer need not check the channel after a call and can pass the pointer straight through. It may not drop the parameter. NEXT.md said the same thing as a for-later note and now says it is settled.
7.2 KiB
Spec 2 — Conditions and restarts, operational semantics
Status: frozen for the six hard cases below. Everything not listed here is still open, but nothing in the implementation may depend on the unlisted parts.
Four operators: handler-bind, handler-case, restart-case, invoke-restart.
No condition class hierarchy — condition types are structs, matching is by type
plus an optional predicate.
1. signal returns Unit
(signal c) has type Unit, always. When every applicable handler returns
normally without transferring, signal returns Unit and the signalling
function simply carries on. This is the accumulation case.
The alternative — signal producing a value supplied by the handler — was
rejected: it forces every signal site to declare a default value and a result
type, which is a much heavier language for one convenience.
The consequence is visible in the syntax. A restart-case in value position
must produce its type on the fall-through path too:
(defn load-texture [path string] (Handle Texture)
(if (file-exists? path)
(rl/load-texture path)
(restart-case
(do (signal (AssetMissing {:path path}))
(abort "unhandled AssetMissing")) ; fall-through must not return
(use-placeholder [] placeholder-texture)
(retry [] (load-texture path)))))
abort has type Never, which unifies with anything. Any expression of type
Never (a return, a call to a diverging function) is equally acceptable there.
2. No handler
signal with no matching handler on the handler stack is a no-op that
returns Unit. It does not abort, does not print, does not enter a break loop.
(error c) is the diverging variant: same lookup, but with type Never and, if
nothing handles it, it enters the dev-build break loop or aborts in release.
The cost when unused is the intended one: handler-bind is a couple of stores
onto a stack-allocated linked-list frame, and signal with an empty stack is a
null check.
3. Restart signatures
(restart-case BODY
(name [p1 T1 p2 T2] BODY-1)
...)
- Parameters are annotated inline, like any other binding form.
- Every clause body and the
restart-casebody must have the same type, and that is the type of the whole form. (invoke-restart 'name arg ...)has typeNever— it never returns to the invoking handler. Control resumes at therestart-case, which yields the clause's value to its continuation.- Argument count and types are checked at runtime in the first implementation, because restarts are dynamically scoped and named. A statically tracked restart set (Zig's error-set model) remains a nice-to-have.
4. Name shadowing
Restart lookup walks the dynamic restart stack from innermost outward and takes
the first frame offering the name. An inner restart-case therefore shadows
an outer one with the same name for the duration of its body. This is what makes
"restarts go at the resync point" composable: an inner parser's skip-form is
found before an outer one's.
(find-restart 'name) returns (Option Restart) so a handler can test before
committing; (compute-restarts) lists the visible frames for the debugger.
5. Cleanup during a transfer
Invoking a restart transfers control outward past zero or more frames.
deferforms in every frame between theinvoke-restartand the targetrestart-casedo run, innermost first, before the clause body starts.errdeferforms do not run.errdeferis bound to theResultfailure path (tryreturningErr) only. A restart transfer is not a failure — it is a chosen recovery, and the recovery may well want the resource.- The condition object lives on the signalling frame's stack. Nothing has
unwound when a handler runs, so it is valid there; but once a transfer starts,
the signalling frame dies. Anything a handler keeps must be copied out
(conditions are value structs, so
(push errors c)copies).
6. Crossing compiler-generated frames
Transfer is lowered explicitly — result propagation plus branch targets — not via platform unwinding. Three reasons, none of them about dev builds:
- wasm32 cannot unwind without the exceptions proposal, so a release export would not work at all.
- Native unwinding is not cheaper and is much less legible. Every call
becomes an
invokewith a landing pad, plus a personality function and an exception table; acmp/jneafter a call reads like ordinary code and that matters once there is a disassembler. - One mechanism is one thing to get right. The acceptance table runs the same programs on both targets and compares a hash, and that hash is the only tripwire two implementations would have.
That means every function on the path between the invoke and the target must be transfer-aware: it carries a "normal / transferring to frame N" channel, checks it after each call, and forwards.
The channel is an out-parameter, a ptr appended to the signature, and not
a discriminated return value. The return type then stays what the source says,
which keeps a function's disassembly readable as the release one plus a guard;
a discriminated return would repack every ret, turn an aggregate return into
an sret call, and nest awkwardly inside the discriminated return (Option T)
already is. One pointer threads down the whole chain, so a callee writes the
target into its caller's own slot and each frame only has to check and return
early — which reuses the existing return path, and therefore §5's defers, for
free.
A single global slot would be more legible still — no signature change at all — but it is not re-entrant: §5 runs defers during a transfer, so a defer that signals and invokes a restart would start a second transfer over the first. A per-frame slot nests correctly with no threads involved.
- Every function carries the channel, and that is the ABI. Uniformity is what keeps an indirect call and a hot-reload cell safe: a cell holds a bare pointer, so the honest answer to "what can this call?" is "anything", and a signature that depended on the answer could not be reloaded into. An earlier draft had escape analysis decide which functions are transfer-transparent; that is now an optimisation over what a function does with the channel — a function that provably cannot transfer need not check it after a call, and can pass the pointer straight through. It may not drop the parameter. See plan.org, Hot reload.
- Foreign frames cannot be crossed. A restart transfer whose path passes
through a C frame (a raylib callback, an
externfunction calling back into Flan) is a runtime error, not undefined behaviour. Handlers installed across an FFI boundary must therefore either return normally or usehandler-caseinstalled inside the callback. - With the async state-machine transform, the handler and restart stacks live in
the task state, not thread-local, so a handler established before an
awaitis still in scope after resumption.
What this does not settle
Condition inheritance/predicate-matching details, the break-loop UI, restart
interaction with threads, and whether handler-case should be a macro over
handler-bind + a transfer. None of these block milestone 5.