flan/test/programs/handler-case.flan
Joseph Ferano e3565b30e8 The survey was counting the defers, not watching them
Every mark it left was an addition, and addition commutes, so a backend that
ran the defers outermost-first produced byte-identical output and the row that
was supposed to be watching the order could not have told. The claim was in the
comments and not in the numbers. cleanup.flan already had the device for this —
a shift rather than a sum — so the log here is a digit trace now, and the two
frames under a catch read 12 where a wrong order reads 21.

Rewriting the trace made room for the three behaviours that worked and nothing
pinned. A return inside a clause is an ordinary return from the function that
wrote the form, because that is where a clause runs: it leaves through the
function's own exit, runs the defer registered there after the two the unwind
already ran, and leaves the handler stack empty behind it, which the bare
signal that follows in main is the check on. A defer inside a clause is refused
for the reason every nested form is refused one. And a handler-case inside a
defer works, because a defer may not start a transfer that leaves it and this
one begins and ends its own.

The program is registered with the sanitizers, where the interesting failure is
not the heap but a handler or restart frame left on a stack pointing into an
alloca that has gone — an output comparison cannot see that until something
much later calls through it. It is clean; it was also leaking sixteen bytes out
of the vector main allocates to prove the allocator context came back, which is
the test's own litter and is freed now.

docs/PORTING.md ranked handler-case as one site handler-bind covers. It still
is one site, and handler-bind still covers it, but it is no longer the closer
translation: a catch block is assumed everywhere it is written to see the
locals around it, and only the clause that runs at the form does.

The handler clauses are lifted left to right rather than by List.map, whose
order is unspecified. Each lift names itself after the count already on the
list, so an order nobody chose would number the clauses of one handler-bind
differently between builds, and those names go into a redefinition module.
2026-09-19 21:13:03 +07:00

232 lines
9.0 KiB
Plaintext

;;;; handler-case — the unwinding handler, spec-conditions.md.
;;;;
;;;; handler-bind runs its clause at the signal, with everything below still
;;;; standing, and carries on from there. This one is the other half: a listed
;;;; condition unwinds the stack back to the form, the clause runs *here*, and
;;;; its value is the value of the whole handler-case. Clojure's try/catch and
;;;; Common Lisp's handler-case, and built out of the two operators that were
;;;; already here — a handler-bind whose clause invokes a restart the form
;;;; established around itself.
;;;;
;;;; What this program pins is the list of things that only an unwind can get
;;;; wrong: the defers between the signal and the form, a condition nobody
;;;; listed carrying on outward untouched, the two nestings against
;;;; 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])
(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 — and says in
;;; which order it crossed them (§5: innermost first).
(defn inner [n i32] i32
(defer (note 1))
(error (Missing {.id n}))
0)
(defn middle [n i32] i32
(defer (note 2))
(+ (inner n) 1))
;;; Nothing signals: the body's own value stands, which is the case a form that
;;; only ever answers its clauses would quietly get wrong.
(defn quiet [n i32] i32
(handler-case (+ n 1)
[(Missing [c] -1)]))
;;; Caught, and the clause reads a local of the function that established the
;;; form. That is the whole difference from handler-bind, whose clause is
;;; lifted into a function of its own and can see no such thing.
(defn caught [n i32] i32
(let [bonus 100]
(handler-case (middle n)
[(Missing [c] (+ bonus (.id c)))])))
;;; Two clauses, and the one whose type was signalled is the one that runs.
(defn raise [k i32] i32
(cond
(= k 0) (error (Missing {.id 1}))
(= k 1) (error (Corrupt {.id 2}))
:else (error (Late {.id 3}))))
(defn two [k i32] i32
(handler-case (raise k)
[(Missing [c] (+ 100 (.id c)))
(Corrupt [c] (+ 200 (.id c)))]))
;;; A condition no clause lists installs no frame that matches it, so this form
;;; never sees it and it goes on outward unchanged. The outer handler-bind is
;;; what proves it arrived, and the 7 is what proves signal still returned ()
;;; and the body carried on from where it was.
(defn unmatched [n i32] i32
(handler-case
(do (signal (Corrupt {.id n}))
7)
[(Missing [c] -1)]))
;;; A handler-case inside a handler-bind. The Corrupt goes out to the
;;; 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] (note 4))]
(handler-case
(do (signal (Corrupt {.id n}))
(error (Missing {.id n}))
0)
[(Missing [c] (.id c))])))
;;; And the other way round. The inner handler-bind is on the path the unwind
;;; takes, so its frame has to come off as the transfer passes through it —
;;; which is the same landing pad a restart transfer already uses.
(defn hb-in-hc [n i32] i32
(handler-case
(handler-bind [(Corrupt [c] (note 5))]
(do (signal (Corrupt {.id n}))
(error (Missing {.id n}))
0))
[(Missing [c] (* 2 (.id c)))]))
;;; A clause that signals. It runs with its own handler-case's frames already
;;; off the stack, so this Missing must not be caught here — that would be an
;;; 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 (note 3))
(handler-case (middle n)
[(Missing [c] (do (signal (Missing {.id 99}))
(.id c)))]))
(defn arm-caught-outside [n i32] i32
(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 notes the
;;; id of the condition *it* caught, so the trace says they are two names.
(defn in-clause-a [] ()
(handler-bind
[(Late [c]
(note (i64 (handler-case (error (Corrupt {.id 6}))
[(Corrupt [d] (.id d))]))))]
(signal (Late {.id 0}))))
(defn in-clause-b [] ()
(handler-bind
[(Late [c]
(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
;;; extent and the transfer passes straight through it, so the restore has to
;;; happen on that path as well as on the normal one — otherwise the clause,
;;; and everything after the whole form, would be allocating out of a region
;;; nobody else knows about. main destroys the arena before it allocates
;;; again, so a context left pointing at it would not be a wrong number.
(defn scoped [n i32] i32
(handler-case
(with-allocator frame
(let [v (vec-new i32)]
(push v 1)
(error (Missing {.id n}))
(i32 (len v))))
[(Missing [c] (+ 20 (.id c)))]))
(defn main [] i32
;; Normal completion.
(print (quiet 41)) (println "")
(print log) (println "")
;; 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 "")
(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] (note 6))]
(print (unmatched 3)) (println ""))
(print log) (println "")
;; 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 "")
(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 "")
;; 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 "")
(arena-destroy frame)
(let [h (vec-new i32)]
(push h 9)
(print (len h)) (println "")
(free h))
0)