diff --git a/docs/PORTING.md b/docs/PORTING.md index f4f643a..d47f29d 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -186,10 +186,23 @@ Neither implementation returns errors as values. Both use the host's exception o condition system throughout. Porting this game exercises `signal`/`error`/`restart-case` and never wants a `Result`. -### `handler-case` — one site, and `handler-bind` already covers it +### `handler-case` — one site, and it is now the natural spelling of it -`engine.clj`'s `reload-config!` is `(try … (catch Exception e (println e)))`. That is -"log it and carry on", which is what a `handler-bind` clause returning normally does. +`engine.clj`'s `reload-config!` is `(try … (catch Exception e (println e)))`. The ranking +below was written when the operator did not exist, and said `handler-bind` covers it, +which it does: "log it and carry on" is what a clause returning normally does. It is no +longer the closer translation. `handler-case` landed on 2026-09-19 and is Clojure's +try/catch by construction, so that site ports across as itself: + +``` +(handler-case (read-config path) + [(FileError [c] (do (println (.path c)) default-config))]) +``` + +The difference that decides it is not spelling. A `handler-bind` clause is lifted into a +function of its own and cannot see `reload-config!`'s locals; a `handler-case` clause runs +at the form and can, which is what a catch block is assumed to do everywhere it is +written. ### `Handle` and pools — nothing to pool @@ -642,9 +655,11 @@ not compete for the same slot. **Not ranked, because this game does not need them:** escaping closures and capture (one site, fixed by one parameter), `Handle` and pools (nothing to pool), `Result`/`try` -(neither host uses that discipline), `handler-case` (one site, `handler-bind` covers it), -`loop`/`recur` and tail calls (nothing recurses), user-written allocators, structural -typing. +(neither host uses that discipline), `loop`/`recur` and tail calls (nothing recurses), +user-written allocators, structural typing. `handler-case` was on this list for the same +reason and has since been built anyway — it cost one function in the checker and nothing +in either backend, being a `handler-bind` whose clause invokes a restart the form +established around itself. --- diff --git a/lib/check.ml b/lib/check.ml index f5d6b10..adfb863 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -2407,7 +2407,13 @@ and check_fn ctx ~want loc (params : string list) body = that has gone. *) and check_handler_bind ctx ?want ?(what = "handler-bind") loc clauses body = let frames = - List.map + (* Left to right, and not [List.map], whose order is unspecified: each of + these calls lifts a function onto [ctx.env.lifted] and names it after + the count already there, so an order nobody chose would number the + clauses of one handler-bind differently between builds. The names go in + a redefinition module, which is where that would be noticed — see the + argument in [check_fn]. *) + map_lr (fun (c : Ast.hclause) -> let ty = resolve ctx.env c.Ast.hty in let name = diff --git a/test/programs/handler-case.flan b/test/programs/handler-case.flan index 23a5569..b469b99 100644 --- a/test/programs/handler-case.flan +++ b/test/programs/handler-case.flan @@ -14,6 +14,12 @@ ;;;; handler-bind, and a clause that signals — which must not be caught by the ;;;; handler-case it belongs to, because by the time it runs that form's frames ;;;; are off the stack. +;;;; +;;;; [log] is a digit trace rather than a running sum, which is cleanup.flan's +;;;; device and is here for cleanup.flan's reason: a sum commutes, so a backend +;;;; that ran the defers outermost-first would print exactly the same total as +;;;; one that got them right. A shift records the *order* and a wrong order is +;;;; a different number. (defstruct Missing [id i32]) (defstruct Corrupt [id i32]) (defstruct Late [id i32]) @@ -21,15 +27,18 @@ (defvar log i64) (defvar frame Allocator) +(defn note [n i64] () (set log (+ (* log 10) n))) + ;;; Two frames below any handler-case here, each with a defer, so an unwind has -;;; something to cross and leaves a mark saying it crossed it (§5). +;;; something to cross and leaves a mark saying it crossed it — and says in +;;; which order it crossed them (§5: innermost first). (defn inner [n i32] i32 - (defer (set log (+ log 1))) + (defer (note 1)) (error (Missing {.id n})) 0) (defn middle [n i32] i32 - (defer (set log (+ log 10))) + (defer (note 2)) (+ (inner n) 1)) ;;; Nothing signals: the body's own value stands, which is the case a form that @@ -72,7 +81,7 @@ ;;; handler-bind, which returns normally, so the body carries on; the Missing ;;; that follows unwinds to the handler-case in between. (defn hc-in-hb [n i32] i32 - (handler-bind [(Corrupt [c] (set log (+ log 1000)))] + (handler-bind [(Corrupt [c] (note 4))] (handler-case (do (signal (Corrupt {.id n})) (error (Missing {.id n})) @@ -84,7 +93,7 @@ ;;; which is the same landing pad a restart transfer already uses. (defn hb-in-hc [n i32] i32 (handler-case - (handler-bind [(Corrupt [c] (set log (+ log 1)))] + (handler-bind [(Corrupt [c] (note 5))] (do (signal (Corrupt {.id n})) (error (Missing {.id n})) 0)) @@ -95,7 +104,7 @@ ;;; unbounded loop rather than a wrong number. It has to leave this function, ;;; running the defer below on the way, and land further out. (defn arm-signals [n i32] i32 - (defer (set log (+ log 100))) + (defer (note 3)) (handler-case (middle n) [(Missing [c] (do (signal (Missing {.id 99})) (.id c)))])) @@ -104,26 +113,48 @@ (handler-case (arm-signals n) [(Missing [c] (+ 5000 (.id c)))])) +;;; A clause that returns. A clause runs at the form, in the function that +;;; wrote it, so a [return] there is an ordinary return from *this* function — +;;; not the refusal the body gets, where the frames are still standing. It has +;;; to run this function's own defer on the way out, after the two the unwind +;;; already ran, and it has to leave nothing on the handler stack: main signals +;;; once more afterwards with nothing listening, which would be a call into a +;;; frame that has gone if anything leaked. +(defn return-from-clause [n i32] i32 + (defer (note 9)) + (handler-case (middle n) + [(Missing [c] (return (+ 700 (.id c))))]) + 0) + +;;; A handler-case inside a defer. A defer is itself the cleanup an unwind +;;; runs, so establishing frames in one has to work like establishing them +;;; anywhere — what a defer may not do is start a transfer that leaves it, and +;;; this one begins and ends inside. Two defers, so the order is pinned here +;;; too: the handler-case one is innermost and notes first. +(defn hc-in-defer [n i32] i32 + (defer (note 8)) + (defer (note (i64 (handler-case (error (Corrupt {.id n})) + [(Corrupt [d] (.id d))])))) + n) + ;;; Two handler-cases written inside handler-bind clauses. Such a clause is ;;; lifted into a function of its own, so both of these mint their made-up ;;; restart name out of one shared bucket rather than out of a function's — ;;; which is exactly where two forms landing on the same name would show, the -;;; inner shadowing the outer wherever their extents overlapped. Each answers -;;; its own condition, so the total says they are different names. +;;; inner shadowing the outer wherever their extents overlapped. Each notes the +;;; id of the condition *it* caught, so the trace says they are two names. (defn in-clause-a [] () (handler-bind [(Late [c] - (set log - (+ log (i64 (handler-case (error (Corrupt {.id 3})) - [(Corrupt [d] (.id d))])))))] + (note (i64 (handler-case (error (Corrupt {.id 6})) + [(Corrupt [d] (.id d))]))))] (signal (Late {.id 0})))) (defn in-clause-b [] () (handler-bind [(Late [c] - (set log - (+ log (i64 (handler-case (error (Corrupt {.id 40})) - [(Corrupt [d] (.id d))])))))] + (note (i64 (handler-case (error (Corrupt {.id 7})) + [(Corrupt [d] (.id d))]))))] (signal (Late {.id 0})))) ;;; A with-allocator on the way out. It rebinds the context allocator for its @@ -143,46 +174,58 @@ (defn main [] i32 ;; Normal completion. - (print (quiet 41)) (println "") ; 42 - (print log) (println "") ; 0 — nothing unwound + (print (quiet 41)) (println "") + (print log) (println "") - ;; Caught, with both defers between the signal and the form having run. - (print (caught 5)) (println "") ; 105 - (print log) (println "") ; 11 + ;; Caught, with both defers between the signal and the form having run, and + ;; the trace saying inner's ran before middle's. + (print (caught 5)) (println "") + (print log) (println "") ;; The arm that matches is the arm that runs. - (print (two 0)) (println "") ; 101 - (print (two 1)) (println "") ; 202 + (print (two 0)) (println "") + (print (two 1)) (println "") ;; Unmatched: past this form and out to a handler-bind around it, and the ;; body's own value still stands. - (handler-bind [(Corrupt [c] (set log (+ log 10000)))] - (print (unmatched 3)) (println "")) ; 7 - (print log) (println "") ; 10011 + (handler-bind [(Corrupt [c] (note 6))] + (print (unmatched 3)) (println "")) + (print log) (println "") - ;; The two nestings. Neither signals from under [middle], so neither moves - ;; the defer count: what each adds is its own handler-bind's clause. - (print (hc-in-hb 6)) (println "") ; 6 - (print log) (println "") ; 10011 + 1000 = 11011 - (print (hb-in-hc 8)) (println "") ; 16 - (print log) (println "") ; 11011 + 1 = 11012 + ;; The two nestings. Neither signals from under [middle], so what each adds + ;; to the trace is its own handler-bind's clause and nothing else. + (print (hc-in-hb 6)) (println "") + (print (hb-in-hc 8)) (println "") + (print log) (println "") ;; A clause that signals, caught by the handler-case outside it. Two unwinds, ;; so both defers under [middle] run and then the one in [arm-signals] does. - (print (arm-caught-outside 4)) (println "") ; 5099 - (print log) (println "") ; 11012 + 11 + 100 = 11123 + (print (arm-caught-outside 4)) (println "") + (print log) (println "") + + ;; A clause that returns, and then a signal nothing is listening for. The + ;; second is the leak check: it must be the no-op §2 says it is. + (print (return-from-clause 5)) (println "") + (print log) (println "") + (signal (Missing {.id 0})) + (print log) (println "") + + ;; A handler-case established inside a defer. + (print (hc-in-defer 4)) (println "") + (print log) (println "") ;; Two forms out of the one bucket of made-up names, each answering its own. (in-clause-a) (in-clause-b) - (print log) (println "") ; 11123 + 3 + 40 = 11166 + (print log) (println "") ;; And the allocator scope. The arena is destroyed straight after, so the ;; heap allocation below is only possible if the context was put back. (set frame (arena-new 4096)) - (print (scoped 3)) (println "") ; 23 + (print (scoped 3)) (println "") (arena-destroy frame) (let [h (vec-new i32)] (push h 9) - (print (len h)) (println "")) ; 1 + (print (len h)) (println "") + (free h)) 0) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 6bbb9f3..15470a0 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -456,16 +456,23 @@ let () = body still running after it, a handler-bind on the path out having its frame taken off as the transfer passes through, and a clause that signals landing outside the form it belongs to rather than back in it. + A clause that returns, which is an ordinary return from the function + that wrote the form and must leave the handler stack empty behind it. And two of them written inside handler-bind clauses, which is where the made-up restart names come out of one shared bucket and where two forms minting the same one would show. At -O0 as well, because the guard after every call is control flow the optimiser would otherwise launder, and under --x86, where the transfer exit and the with-allocator restore share one epilogue and there is no - second copy to forget. *) + second copy to forget. + + The long numbers are cleanup.flan's digit trace and not a running sum, + which is what makes them worth comparing: a sum commutes, so defers run + outermost-first would total the same, and a trace that says 12 where the + order is wrong says 21. *) let handler_case_out = - "42\n0\n105\n11\n101\n202\n7\n10011\n6\n11011\n16\n11012\n5099\n11123\n\ - 11166\n23\n1\n" + "42\n0\n105\n12\n101\n202\n7\n126\n6\n16\n12645\n5099\n12645123\n705\n\ + 12645123129\n12645123129\n4\n1264512312948\n126451231294867\n23\n1\n" in outputs "handler-case" "programs/handler-case.flan" handler_case_out; outputs ~opt:"-O0" "handler-case, -O0" "programs/handler-case.flan" diff --git a/test/test_flan.ml b/test/test_flan.ml index ee07cba..7bf416d 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -1709,6 +1709,25 @@ let () = rejects_check "return inside a handler-case body" (boom ^ "(defn f [] i32 (handler-case (return 1) [(Boom [c] 2)]))") ~needle:"not allowed inside handler-case"; + (* A clause is the other side of that rule and not an exception to it. It + runs at the form, in the function that wrote it, with the frames already + off the stack — so a [return] there is an ordinary return and there is + nothing left for it to strand. *) + accepts "return inside a handler-case clause" + (boom ^ "(defn f [] i32 (handler-case 1 [(Boom [c] (return 2))]))"); + (* A defer is not, though, and for the reason every nested form is refused + one: it is copied onto every exit path of the *function*, so a defer + written where it looks scoped to the clause would run whether the clause + did or not. The same answer a restart-case clause gets. *) + rejects_check "defer inside a handler-case clause" + (boom ^ "(defn f [] i32 (handler-case 1 [(Boom [c] (defer (println \"\")) 2)]))") + ~needle:"defer is not allowed inside a nested form"; + (* The other way round works. A defer is the cleanup an unwind runs, so + establishing frames inside one is ordinary — what a defer may not do is + start a transfer that leaves it, and a handler-case begins and ends its + own. *) + accepts "handler-case inside a defer" + (boom ^ "(defn f [] i32 (defer (println (handler-case 1 [(Boom [c] 2)]))) 0)"); (* The shape. The clauses go in a vector after the body, which is the opposite of handler-bind's order, so a form written the other way round has to say so rather than parse as something else. *) diff --git a/test/test_sanitize.ml b/test/test_sanitize.ml index 8fbdca5..d180cc9 100644 --- a/test/test_sanitize.ml +++ b/test/test_sanitize.ml @@ -141,6 +141,14 @@ let corpus = same directory; the new C here is three more path buffers, which is exactly what this tool is for. *) "programs/files.flan", []; + (* The unwinding handler, and here for the frames rather than for the heap: + every path it takes leaves a function through the transfer exit, where + a handler frame or a restart frame left on its stack is a pointer into + an alloca that has gone. An output comparison cannot see that until + something later calls through it; ASan sees it at the store. The + with-allocator case is the one that reaches the heap — the region it + rebound is released after the unwind has carried a value out of it. *) + "programs/handler-case.flan", []; (* The JSON reader, which is the corpus's densest allocator: every string in the document is a (Vec u8) grown a byte at a time and then handed out as a view of its own block, and the block is never freed because