The cheatsheet was a document. It should fit on a screen: the syntax, what is
missing, the traps that are not in any error message, and the three ways it
stops. What was cut is either in spec-conditions.md or in the compiler's own
refusal, and both say it better.
This commit is contained in:
Joseph Ferano 2026-09-11 09:22:44 +07:00
parent b50e8d6cad
commit ca954a47b5

View File

@ -1,224 +1,57 @@
#+TITLE: Conditions and restarts — a cheatsheet
#+TITLE: Conditions and restarts — cheatsheet
#+STARTUP: showeverything
Everything here has been run. The refusal messages are verbatim, not
paraphrased. The reference is [[file:spec-conditions.md][spec-conditions.md]]; this is how to drive what is
built, and [[file:NEXT.md][NEXT.md]] says why each piece is shaped the way it is.
Why it is shaped this way: [[file:spec-conditions.md][spec-conditions.md]]. Something to poke at:
=conditions-play.flan==flan dev conditions-play.flan=, then ~C-c C-c~.
* 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
#+begin_src sh
dune build
./_build/default/bin/main.exe run conditions-play.flan # prints, then waits
#+end_src
To poke at it live, which is the point of the whole thing:
#+begin_src sh
./_build/default/bin/main.exe dev conditions-play.flan
#+end_src
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.
* Works
#+begin_src lisp
(defstruct AssetMissing [id i32])
(defvar seen i64)
(signal c) ; Unit. Handler returns -> carry on. No handler -> no-op.
(error c) ; Never. Only a transfer gets past; else the program stops.
(handler-bind [(AssetMissing [c] (set seen (+ seen (i64 (.id c)))))]
(load-all)) ; signals twice, keeps going both times
(handler-bind [(Type [c] body ...) ...] body ...) ; match by type, no hierarchy
(restart-case BODY ; BODY and every clause have the same type = the form's
(name [] CLAUSE) ...)
(invoke-restart 'name) ; Never. Innermost frame offering the name wins.
#+end_src
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.
#+begin_src lisp
(defn strict [n i32] i32
(restart-case
(do (error (AssetMissing {:id n}))
0) ; unreachable: error is Never
(use-placeholder [] -2)))
#+end_src
Unanswered, the program stops:
#+begin_example
handler ran
unhandled AssetMissing
$ echo $?
134
#+end_example
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~.
#+begin_src lisp
(defn fetch [n i32] i32
(restart-case (middle n) ; its value if nothing transfers
(restart-case (middle n) ; its value if nothing transfers
(use-placeholder [] -1)
(retry [] 7)))
#+end_src
** Transfer
#+begin_src lisp
(handler-bind [(AssetMissing [c] (invoke-restart 'use-placeholder))]
(fetch 2)) ; -1
(fetch 2)) ; -1
#+end_src
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.
~defer~ between the invoke and the target runs, innermost first, before the
clause body. ~errdefer~ does not.
** Clean up on the way out
* Not yet
~defer~ forms in every frame between the invoke and the target run, innermost
first, before the clause body starts.
#+begin_src lisp
(defn middle [n i32] i32
(defer (set ticks (+ ticks 1))) ; runs whether it returns or transfers
(+ (probe n) 1))
#+end_src
~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
#+begin_example
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
#+end_example
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.
#+begin_example
(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)
#+end_example
~handler-case~ · ~find-restart~ · ~compute-restarts~ · restarts with
parameters · the dev-build break loop. Each refused by name with its reason.
* 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.
- *A handler closes over nothing.* It is lifted into its own function.
Accumulate into a global, or put the value on the condition.
- *A restart re-runs whatever sits between it and the target.* Control resumes
at the ~restart-case~, so a ~retry~ repeats side effects after it. Put the
~restart-case~ where re-entry is safe.
- *An unknown restart name is a hard stop.* No ~find-restart~ to test with.
- *No supertype*, so nothing can say "any condition".
- *~signal~ cannot hand a value back.* Deliberate (§1).
- A condition must be a *struct*. ~return~ is refused inside either form.
- *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 a ~retry~. Put the ~restart-case~ at a point where
re-entry is safe — that is what "restarts go at the resync point" means.
* Stops the program, exit 134
- *An unknown restart name is a hard stop*, not a fallback. There is no
~find-restart~ yet, 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.
- *~signal~ cannot 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.
#+begin_example
unhandled AssetMissing
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 — ...
#+end_example