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.