flan/conditions.org
Joseph Ferano b50e8d6cad 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.
2026-09-11 09:17:15 +07:00

8.6 KiB

Conditions and restarts — a cheatsheet

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 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.