A cheatsheet for playing with conditions

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.
This commit is contained in:
Joseph Ferano 2026-09-11 09:17:15 +07:00
parent 18db822095
commit b50e8d6cad
4 changed files with 278 additions and 0 deletions

1
.gitignore vendored
View File

@ -41,3 +41,4 @@ old-ocaml/
# Built executables from `flan build`
/calc-me
/sand
/conditions-play

View File

@ -95,6 +95,8 @@ reader ✅ → parse ✅ → load ✅ → check ✅ → emit ✅ → clang ✅
| `test/test_dev.ml` | **the daemon, driven the way an editor drives it** |
| `test/test_repl.ml` | **`C-x C-e`: an expression evaluated inside a running program** |
| `test/programs/conditions.flan` | **`handler-bind` and `signal`, the accumulation case** |
| `conditions.org` | **a cheatsheet for driving conditions: what works, the exact refusals, the gotchas** |
| `conditions-play.flan` | **a program to poke at them with, built to be attached to by `flan dev`** |
| `test/programs/restarts.flan` | **`restart-case` and `invoke-restart`: the transfer, across two frames** |
| `test/test_emacs.ml` | **the client, driven against a real daemon and a real program** |
| `test/reload_host.c` | the C host that loads and installs two rebuilds, in one process |

51
conditions-play.flan Normal file
View File

@ -0,0 +1,51 @@
;;;; A program to poke at conditions and restarts with — see conditions.org.
;;;;
;;;; It loops instead of exiting, so `flan dev conditions-play.flan` can attach
;;;; and you can redefine `probe` and `run-once` from Emacs while it runs. The
;;;; socket written here is only a fallback; the daemon overrides it.
(import agent "vendor:agent")
(defstruct AssetMissing [id i32])
(defstruct Corrupt [id i32])
;;; Handlers cannot see the establishing function's locals yet, so anything a
;;; handler accumulates into has to be a global.
(defvar seen i64)
(defvar ticks i64)
;;; Two frames under the restart-case, so a transfer has something to cross.
(defn probe [n i32] i32
(signal (AssetMissing {:id n}))
100)
(defn middle [n i32] i32
(defer (set ticks (+ ticks 1))) ; runs on the way out, transfer or not
(+ (probe n) 1))
;;; The shape spec-conditions.md §1 uses: a restart-case in value position,
;;; whose fall-through has to produce the type too.
(defn fetch [n i32] i32
(restart-case (middle n)
(use-placeholder [] -1)
(retry [] 7)))
(defn run-once []
(print-i64 (i64 (fetch 1))) (newline)
(handler-bind [(AssetMissing [c] (set seen (+ seen (i64 (.id c)))))]
(print-i64 (i64 (fetch 2))) (newline))
(handler-bind [(AssetMissing [c] (invoke-restart 'use-placeholder))]
(print-i64 (i64 (fetch 3))) (newline))
(print-i64 seen) (newline)
(print-i64 ticks) (newline))
(defn main [] i32
(agent/start "/tmp/flan-conditions.sock")
(run-once)
(while (= (agent/wait 200) 0) 0)
(run-once)
(while (= (agent/wait 200) 0) 0)
(run-once)
0)

224
conditions.org Normal file
View File

@ -0,0 +1,224 @@
#+TITLE: Conditions and restarts — a 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.
* 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.
#+begin_src lisp
(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
#+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
(use-placeholder [] -1)
(retry [] 7)))
#+end_src
** Transfer
#+begin_src lisp
(handler-bind [(AssetMissing [c] (invoke-restart 'use-placeholder))]
(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.
** 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.
#+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
* 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 a ~retry~. Put the ~restart-case~ at 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-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.