conditions.org is how to drive what is built, as against spec-conditions.md which is what it should mean and NEXT.md which is why it is shaped that way. Every refusal message in it is verbatim rather than paraphrased, because the reason is the thing worth knowing and a remembered approximation of it is how a cheatsheet starts lying. conditions-play.flan is the program to poke at. It loops rather than exiting so flan dev can attach to it, and it is deliberately two frames deep with a defer in the middle, so that redefining probe or fetch from Emacs and watching the next pass through run-once shows the transfer crossing something. It also carries the two gotchas that are not in any spec or message: a handler closes over nothing, and invoking a restart re-runs whatever sits between it and the target - which is what "restarts go at the resync point" is actually about.
8.6 KiB
Conditions and restarts — a cheatsheet
- What exists
- Setup
- The five things you can do
- Runtime errors, verbatim
- What is refused, and the exact reason
- Gotchas
- Under the hood, in one paragraph
Everything here has been run. The refusal messages are verbatim, not paraphrased. The reference is spec-conditions.md; this is how to drive what is built, and NEXT.md says why each piece is shaped the way it is.
What exists
| Operator | Type | State |
|---|---|---|
(signal c) |
Unit | works — §1, §2 |
(error c) |
Never | works — §2, stops if nothing transfers |
(handler-bind [...] ...) |
Unit | works — §1 |
(restart-case B (n [] C)) |
B's type | works — §3, §4, §6 |
(invoke-restart 'n) |
Never | works — §4, §5, §6 |
handler-case |
refused by name | |
find-restart |
refused by name — §4 | |
compute-restarts |
refused by name — §4 | |
| restarts with parameters | refused by name — §3 | |
| the dev-build break loop | not written — §2 |
Setup
dune build
./_build/default/bin/main.exe run conditions-play.flan # prints, then waits
To poke at it live, which is the point of the whole thing:
./_build/default/bin/main.exe dev conditions-play.flan
then in Emacs, with emacs/flan-mode.el and emacs/flan-dev.el loaded:
C-c C-z |
connect (finds .flan-dev.sock upward) |
C-c C-c |
recompile the top-level form at point and install it |
C-c C-k |
the whole buffer, as one module |
C-x C-e |
evaluate the expression before point in the running program |
C-c C-o |
the program's own output |
C-c C-d |
what it currently defines |
Editing probe or fetch and hitting C-c C-c changes what the next loop
of run-once does, without restarting. That includes adding a handler-bind
or a restart-case to a body that had none.
The five things you can do
Signal, and carry on
signal returns Unit whatever it finds. A handler that returns normally
leaves the signalling function to carry on, and with nothing matching it is a
no-op — not an abort, not a message. This is the accumulation case.
(defstruct AssetMissing [id i32])
(defvar seen i64)
(handler-bind [(AssetMissing [c] (set seen (+ seen (i64 (.id c)))))]
(load-all)) ; signals twice, keeps going both times
Matching is by type, and there is no hierarchy — so a clause names one
struct and nothing else reaches it. Nesting does not displace: an inner
handler-bind and an outer one both run, innermost first.
Error, which has to be answered
Same walk, but a handler that returns normally has not answered it. Only a transfer gets past.
(defn strict [n i32] i32
(restart-case
(do (error (AssetMissing {:id n}))
0) ; unreachable: error is Never
(use-placeholder [] -2)))
Unanswered, the program stops:
handler ran unhandled AssetMissing $ echo $? 134
Because error is Never it unifies with anything, so it is also what you put
on a restart-case body's fall-through path when there is nothing sensible to
return. (exit 1) works there too.
Offer restarts
Every clause body and the body have the same type, and that is the type of
the whole form. So the fall-through — what happens when nothing transfers —
is visible in the source, which is the price of signal returning Unit.
(defn fetch [n i32] i32
(restart-case (middle n) ; its value if nothing transfers
(use-placeholder [] -1)
(retry [] 7)))
Transfer
(handler-bind [(AssetMissing [c] (invoke-restart 'use-placeholder))]
(fetch 2)) ; -1
The name is a quoted symbol and is resolved on the restart stack at run
time. Lookup walks innermost outward and takes the first frame offering the
name, so an inner restart-case shadows an outer one — and the clause yields
to its own continuation, not to the call that signalled.
Clean up on the way out
defer forms in every frame between the invoke and the target run, innermost
first, before the clause body starts.
(defn middle [n i32] i32
(defer (set ticks (+ ticks 1))) ; runs whether it returns or transfers
(+ (probe n) 1))
errdefer does not run — a restart is a chosen recovery, not a failure. It
is moot today: try~/~Result is still refused by name, so nothing can run.
Runtime errors, verbatim
file.flan:3:25: no restart named nope is active file.flan:4:7: a defer invoked a restart, which a defer may not do — it is the cleanup a transfer runs on its way out unhandled AssetMissing
All three exit 134, like every other trap.
What is refused, and the exact reason
Each of these is rejected by name rather than left to mean something else — the house rule that anything binding a name, altering control flow, or not yet implemented must be recognised explicitly.
(signal 1) a condition is a struct, not i32 — matching is by type and there is no condition hierarchy (let [n 0] (handler-bind [(C [c] (set n 1))] ...)) a handler cannot see n: it is a local of the function that established the handler, and a handler runs from wherever the signal was. Use a global, or pass it on the condition. (handler-bind [(C [c] ...)] (return 1)) return is not allowed inside handler-bind yet — the frames it established are popped on the way out and an early exit would leave them on the stack (restart-case (return 1) (skip [] 2)) return is not allowed inside restart-case yet — ... (restart-case 1 (skip [] 2) (skip [] 3)) this restart-case offers skip twice (restart-case 1 (skip [n i32] n)) a restart takes no parameters yet — spec-conditions.md §3 has them, and they need argument marshalling and a runtime arity check that this version does not do (invoke-restart skip) invoke-restart takes a quoted restart name, as in (invoke-restart 'use-placeholder) (invoke-restart 'skip 1) a restart takes no arguments yet — ... (defer (invoke-restart 'skip)) invoke-restart is not allowed inside a defer — a defer is the cleanup a transfer runs on its way out, so starting one there would leave this function's defers half run with two targets and no way to choose (handler-case 1) (find-restart 'skip) (compute-restarts) ... is not implemented yet (see the build sequence in plan.org)
Gotchas
- A handler cannot close over anything. It is lifted into a function of its own, because it runs from wherever the signal was. Accumulate into a global or put the value on the condition. Real capture is a closure with an explicit environment — milestone 5.
- Invoking a restart re-runs whatever is between it and the target. Control
resumes at the
restart-case, so side effects performed after it and before the signal happen again on aretry. Put therestart-caseat a point where re-entry is safe — that is what "restarts go at the resync point" means. - An unknown restart name is a hard stop, not a fallback. There is no
find-restartyet, so a handler cannot test before committing. - No supertype. Nothing can say "any condition", so there is no generic logging handler and no catch-all.
signalcannot hand a value back. Deliberate — §1 rejected it, because it would force every signal site to declare a default and a result type.
Under the hood, in one paragraph
Transfer is lowered explicitly, never by platform unwinding: wasm32 cannot
unwind, and a cmp~/~jne after a call reads like ordinary code. Every Flan
signature carries one extra ptr — the transfer channel — written by an
invoke-restart and checked after every call. What it carries is the address
of the restart frame, which is an alloca in the function offering it, so the
aim is exact and re-entering a restart-case needs nothing extra. Each region
that established frames gets a landing block that pops them and either catches
the transfer or forwards it outward; the function's own runs its defers and
returns early. A foreign frame cannot be crossed — the one exception is
flan_signal itself, which threads the channel through so a handler can
transfer at all.