From b50e8d6cadf321a85df793d202f290713f4611c7 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Fri, 11 Sep 2026 09:17:15 +0700 Subject: [PATCH] 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. --- .gitignore | 1 + NEXT.md | 2 + conditions-play.flan | 51 ++++++++++ conditions.org | 224 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 278 insertions(+) create mode 100644 conditions-play.flan create mode 100644 conditions.org diff --git a/.gitignore b/.gitignore index 299b109..f5e5ab7 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ old-ocaml/ # Built executables from `flan build` /calc-me /sand +/conditions-play diff --git a/NEXT.md b/NEXT.md index cda7a40..3de934a 100644 --- a/NEXT.md +++ b/NEXT.md @@ -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 | diff --git a/conditions-play.flan b/conditions-play.flan new file mode 100644 index 0000000..b7aa2ec --- /dev/null +++ b/conditions-play.flan @@ -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) diff --git a/conditions.org b/conditions.org new file mode 100644 index 0000000..c6d486a --- /dev/null +++ b/conditions.org @@ -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.