The report was that handler-case segfaults in a top-level global
initialiser. It does not, and never did. What crashes is one frame
further down, in a position that has nothing to do with startup:
(defn read-file [path string] dyn
(let [src (slurp path (heap-allocator))]
(defer (free src))
(read (as-slice src))))
slurp signals FileError, a handler further out unwinds, and the
transfer leaves this frame through its defers -- all of them. But src
was never written: the form that would have written it is the form
that transferred. free then reads whatever the stack held under that
slot, which at -O0 in a small program is zero and at -O2 is a live
pointer, which is why the same program looked like an optimiser bug
from one direction and a startup bug from the other.
return never had this. The checker splices the defers registered above
it and no others, and says so where it does it. The transfer exit took
the whole list, because it is one landing block per function and
nothing in the IR said where each defer had come into being.
So the count is kept. The first defer in a function mints an i64 slot
zeroed at the top of the body; each defer leaves a store of its own
number where it was written; and fdefers -- the transfer path's copy,
and only that copy -- tests the count before running each one. The
normal paths are untouched and still need no test. It is all in the
checker: what reaches a backend is a slot, a store and an if, so
neither emitter learned anything and the x86 one needed no frame of
its own.
test/programs/init-conditions.flan is the survey. The top half is the
part of the report that was never true: handler-case with its
condition firing and with its body completing, handler-bind, and a
restart-case, all four in a global initialiser, all four answering
what they answer anywhere. The bottom half is the part that was: a
defer below the signalling form, which must not run, beside one above
it, which must -- a fix that took the unregistered one off by taking
them all off would have traded the crash for a leak, and 302/2 is the
line that would catch it. Three acceptance rows, LLVM, -O0 and --x86.
The detector was valgrind, not ASan: reading a stack slot nobody wrote
is not ASan's bug class and it reported nothing on the broken binary,
while memcheck named the conditional jump in flan_vec_free with the
unwinding frame directly above it. The program is in both lists --
test_valgrind.ml because that is what saw it, test_sanitize.ml because
that is where the transfer exit's frames are already watched.
spec-conditions.md section 5 now says which defers a transfer runs.
The author's (defvar game-data dyn (handler-case (edn/read-file ...)
[(FileError [c] nil)])) works.
265 lines
14 KiB
Markdown
265 lines
14 KiB
Markdown
# Spec 2 — Conditions and restarts, operational semantics
|
|
|
|
Status: **frozen** for the six hard cases below. Everything not listed here is
|
|
still open, but nothing in the implementation may depend on the unlisted parts.
|
|
|
|
Four operators: `handler-bind`, `handler-case`, `restart-case`, `invoke-restart`.
|
|
No condition class hierarchy — condition types are structs, matching is by type
|
|
plus an optional predicate.
|
|
|
|
## 1. `signal` returns `()`
|
|
|
|
`(signal c)` has type `()`, always. When every applicable handler returns
|
|
normally without transferring, `signal` returns `()` and the signalling
|
|
function simply carries on. This is the accumulation case.
|
|
|
|
The alternative — `signal` producing a value supplied by the handler — was
|
|
rejected: it forces every signal site to declare a default value and a result
|
|
type, which is a much heavier language for one convenience.
|
|
|
|
The consequence is visible in the syntax. A `restart-case` in value position
|
|
must produce its type on the *fall-through* path too:
|
|
|
|
```
|
|
(defn load-texture [path string] (Handle Texture)
|
|
(if (file-exists? path)
|
|
(rl/load-texture path)
|
|
(restart-case
|
|
(do (signal (AssetMissing {.path path}))
|
|
(abort "unhandled AssetMissing")) ; fall-through must not return
|
|
(use-placeholder [] placeholder-texture)
|
|
(retry [] (load-texture path)))))
|
|
```
|
|
|
|
`abort` has type `Never`, which unifies with anything. Any expression of type
|
|
`Never` (a `return`, a call to a diverging function) is equally acceptable there.
|
|
|
|
## 2. No handler
|
|
|
|
`signal` with no matching handler on the handler stack is a **no-op** that
|
|
returns `()`. It does not abort, does not print, does not enter a break loop.
|
|
`(error c)` is the diverging variant: same lookup, but with type `Never` and, if
|
|
nothing handles it, it enters the dev-build break loop or aborts in release.
|
|
|
|
The cost when unused is the intended one: `handler-bind` is a couple of stores
|
|
onto a stack-allocated linked-list frame, and `signal` with an empty stack is a
|
|
null check.
|
|
|
|
## 3. Restart signatures
|
|
|
|
```
|
|
(restart-case BODY
|
|
(name [p1 T1 p2 T2] BODY-1)
|
|
...)
|
|
```
|
|
|
|
- Parameters are annotated inline, like any other binding form.
|
|
- **Every clause body and the `restart-case` body must have the same type**, and
|
|
that is the type of the whole form.
|
|
- `(invoke-restart 'name arg ...)` has type `Never` — it never returns to the
|
|
invoking handler. Control resumes at the `restart-case`, which yields the
|
|
clause's value to *its* continuation.
|
|
- Argument count and types are checked at **runtime** in the first
|
|
implementation, because restarts are dynamically scoped and named. A statically
|
|
tracked restart set (Zig's error-set model) remains a nice-to-have.
|
|
|
|
**Open: a clause should carry a report string.** `use-placeholder` is an
|
|
identifier, which is what `invoke-restart` needs and not what a person reading a
|
|
break loop's list needs — "carry on with a blank asset" is. SBCL's restart
|
|
struct has a `report-function` for exactly this prompt, and an
|
|
`interactive-function` for the parameters §3 already has. Nothing here mentions
|
|
either, and the break loop today shows names because names are all there are.
|
|
The cost is a string constant per clause, a field beside the name in the restart
|
|
frame, and one accessor: it is not hard, it is simply not written. It should be
|
|
settled before restarts with parameters, which is the feature that makes a bare
|
|
name least sufficient.
|
|
|
|
## 4. Name shadowing
|
|
|
|
Restart lookup walks the dynamic restart stack from innermost outward and takes
|
|
the **first** frame offering the name. An inner `restart-case` therefore shadows
|
|
an outer one with the same name for the duration of its body. This is what makes
|
|
"restarts go at the resync point" composable: an inner parser's `skip-form` is
|
|
found before an outer one's.
|
|
|
|
`(find-restart 'name)` returns `(Option Restart)` so a handler can test before
|
|
committing; `(compute-restarts)` lists the visible frames for the debugger.
|
|
|
|
**A debugger identifies a restart by its position, not by its name.** The rule
|
|
above is what a handler wants — an inner `skip-form` should win — and it is
|
|
exactly wrong for a human being shown a list: a shadowed frame is on that list
|
|
and by name is unreachable, so offering it and resolving by name means taking a
|
|
different restart than the one that was pointed at. So the break loop numbers
|
|
its list, innermost first, and a choice is a position. `invoke-restart` is
|
|
unchanged and stays by name. This is why SBCL's debugger is positional too.
|
|
|
|
A position only means something against a stack that is holding still, which
|
|
the stopped thread's is not — the break loop runs evaluations, and each one
|
|
pushes and pops this list. The list a debugger shows is therefore a **snapshot**
|
|
taken when the break was entered, and the positions are positions in it.
|
|
|
|
**Not every visible restart is reachable.** Transfer is lowered explicitly (§6),
|
|
so it cannot cross a frame that does not carry the channel. An evaluation run
|
|
into a stopped program is called through such a frame, and a restart below it
|
|
must be refused with the reason rather than accepted and dropped.
|
|
|
|
## 5. Cleanup during a transfer
|
|
|
|
Invoking a restart transfers control outward past zero or more frames.
|
|
|
|
- `defer` forms in every frame between the `invoke-restart` and the target
|
|
`restart-case` **do run**, innermost first, before the clause body starts —
|
|
every one that had *registered* when the transfer started, and no others. A
|
|
`defer` is registered where it is written, so a transfer that begins above it
|
|
leaves it alone. The shape that makes this matter is `slurp`'s own:
|
|
|
|
(let [src (slurp path (heap-allocator))]
|
|
(defer (free src))
|
|
...)
|
|
|
|
If `slurp` signals and a handler further out unwinds, `src` was never
|
|
written; a `free` there reads whatever the stack held under that slot. This
|
|
is the same rule `return` has always had — the defers above it run and the
|
|
ones below it do not — and the transfer exit now has it too.
|
|
- `errdefer` forms **do not run**. `errdefer` is bound to the `Result` failure
|
|
path (`try` returning `Err`) only. A restart transfer is not a failure — it is
|
|
a chosen recovery, and the recovery may well want the resource.
|
|
- The condition object lives on the *signalling* frame's stack. Nothing has
|
|
unwound when a handler runs, so it is valid there; but once a transfer starts,
|
|
the signalling frame dies. Anything a handler keeps must be copied out
|
|
(conditions are value structs, so `(push errors c)` copies).
|
|
|
|
**A restart is not a transaction.** The list above is the whole of what a
|
|
transfer does: it runs `defer`s and it moves control. It does not undo. Control
|
|
resumes at the `restart-case` and runs forward from there, so a `retry` re-runs
|
|
every effect between the restart and the target — a global the frame already
|
|
assigned stays assigned, and is assigned again. This is not a gap to be closed
|
|
later. Common Lisp has exactly this property and offers no help either; rollback
|
|
would mean journalling every store, which is a different language.
|
|
|
|
What follows is a discipline rather than a mechanism: **the author chooses where
|
|
the retry boundary is.** A `restart-case` at the top of a frame re-runs
|
|
everything, mutations included; one placed after the mutations re-runs only what
|
|
follows them. So either put the restart before anything mutates, make the
|
|
retried section idempotent, or snapshot what will be re-applied.
|
|
|
|
It matters more here than in most Lisps because of the intended use. A game loop
|
|
means to skip a frame and carry on rather than die, and a failed bounds check
|
|
signalling `BoundsError` rather than ending the process makes abandoning and
|
|
retrying a frame an ordinary thing to do — which is precisely the case where a
|
|
non-idempotent mutation bites. §3's rule that every clause body and the body
|
|
share a type places the restart syntactically; nothing places it *semantically*,
|
|
and that choice is the author's.
|
|
|
|
**Which of the runtime's own conditions establish a restart, and why only some
|
|
do.** Four are signalled from below the program with `error`: `StorageExhausted`
|
|
when an allocator cannot satisfy a request, `FileError` when a file operation
|
|
fails, `BoundsError` for an index or a slice outside its container, and
|
|
`ArithError` for an arithmetic operation that has no answer — a divide or
|
|
remainder by zero, `INT64_MIN / -1`, and a float-to-integer cast whose value does
|
|
not fit, each of which was a raw `SIGFPE` or an undefined result before it was a
|
|
condition. The first two establish a `retry` restart at the failing site, because
|
|
their attempt is repeatable: a handler frees something or supplies another path
|
|
and the same operation then succeeds. The last two establish **nothing**, and
|
|
that is a decision rather than an omission. Nothing a handler can do makes index
|
|
51 valid for a length-50 array or makes a division by zero have a quotient, so
|
|
there is no attempt to resume into. A site restart would also have to be
|
|
allocated by the `restart-case` that offers it, on its own stack (§3), which
|
|
means an `alloca` and a push/pop pair emitted at every indexing and every
|
|
division in every checked build — and what it would buy is a *different* answer,
|
|
silently.
|
|
|
|
So the rule this section describes is unchanged by them: the restarts that matter
|
|
for a bad index or a bad division are the ones the program already established —
|
|
a frame loop's `continue` — and those are on the restart stack and reachable from
|
|
a handler or from the break loop without anything being pushed at the failing
|
|
site. Allocation and file failure are the named exceptions, and spec-memory.md's
|
|
"Allocation failure" says why they have to be.
|
|
|
|
## 6. Crossing compiler-generated frames
|
|
|
|
Transfer is lowered **explicitly** — result propagation plus branch targets — not
|
|
via platform unwinding. Three reasons, none of them about dev builds:
|
|
|
|
- **wasm32 cannot unwind** without the exceptions proposal, so a release export
|
|
would not work at all.
|
|
- **Native unwinding is not cheaper and is much less legible.** Every call
|
|
becomes an `invoke` with a landing pad, plus a personality function and an
|
|
exception table; a `cmp`/`jne` after a call reads like ordinary code and that
|
|
matters once there is a disassembler.
|
|
- **One mechanism is one thing to get right.** The acceptance table runs the
|
|
same programs on both targets and compares a hash, and that hash is the only
|
|
tripwire two implementations would have.
|
|
|
|
That means
|
|
every function on the path between the invoke and the target must be
|
|
transfer-aware: it carries a "normal / transferring to frame N" channel, checks
|
|
it after each call, and forwards.
|
|
|
|
**The channel is an out-parameter**, a `ptr` appended to the signature, and not
|
|
a discriminated return value. The return type then stays what the source says,
|
|
which keeps a function's disassembly readable as the release one plus a guard;
|
|
a discriminated return would repack every `ret`, turn an aggregate return into
|
|
an `sret` call, and nest awkwardly inside the discriminated return `(Option T)`
|
|
already is. One pointer threads down the whole chain, so a callee writes the
|
|
target into its caller's own slot and each frame only has to check and return
|
|
early — which reuses the existing `return` path, and therefore §5's defers, for
|
|
free.
|
|
|
|
A single global slot would be more legible still — no signature change at all —
|
|
but it is not re-entrant: §5 runs defers *during* a transfer, so a defer that
|
|
signals and invokes a restart would start a second transfer over the first. A
|
|
per-frame slot nests correctly with no threads involved.
|
|
|
|
- **Every function carries the channel, and that is the ABI.** Uniformity is
|
|
what keeps an indirect call and a hot-reload cell safe: a cell holds a bare
|
|
pointer, so the honest answer to "what can this call?" is "anything", and a
|
|
signature that depended on the answer could not be reloaded into. An earlier
|
|
draft had escape analysis decide which functions are transfer-transparent;
|
|
that is now an *optimisation over what a function does with the channel* —
|
|
a function that provably cannot transfer need not check it after a call, and
|
|
can pass the pointer straight through. It may not drop the parameter. See
|
|
plan.org, Hot reload.
|
|
- **Foreign frames cannot be crossed.** A restart transfer whose path passes
|
|
through a C frame (a raylib callback, an `extern` function calling back into
|
|
Flan) is a runtime error, not undefined behaviour. Handlers installed across an
|
|
FFI boundary must therefore either return normally or use `handler-case`
|
|
installed inside the callback.
|
|
- With the async state-machine transform, the handler and restart stacks live in
|
|
the **task** state, not thread-local, so a handler established before an
|
|
`await` is still in scope after resumption.
|
|
|
|
## What this does not settle
|
|
|
|
Condition inheritance/predicate-matching details, the break-loop UI, and restart
|
|
interaction with threads. None of these block milestone 5.
|
|
|
|
`handler-case` **is** a `handler-bind` plus a transfer, decided 2026-09-19 and
|
|
built that way. `(handler-case B [(T [c] A)])` is
|
|
|
|
```
|
|
(restart-case (handler-bind [(T [c] (invoke-restart 'R c))] B)
|
|
(R [c T] A))
|
|
```
|
|
|
|
with `R` a name the form makes up for itself, which is Common Lisp's own
|
|
definition of the operator. Everything the unwinding form needs it inherits
|
|
rather than re-implements: §5's defers and the `with-allocator` restore,
|
|
because a transfer already runs both for every frame it leaves; §3's rule that
|
|
the body and every clause share one type; §4's shadowing, which is why the name
|
|
has to be unique per form; and both backends, which needed no new node. The
|
|
clause runs at the `handler-case` and therefore sees the establishing
|
|
function's locals, which a `handler-bind` clause cannot — that is the whole of
|
|
the difference between the two, and it is a consequence of where a restart
|
|
clause runs rather than something arranged for it.
|
|
|
|
The condition crosses as the restart's single argument, which means by value
|
|
into a buffer the form owns. §5 requires it: the signalling frame the condition
|
|
was living on dies the moment the transfer starts.
|
|
|
|
The visible cost is that the made-up restart is on the restart stack like any
|
|
other, so a break loop entered under a `handler-case` lists it. Taking it from
|
|
there is refused loudly — nothing filled its argument buffer in — rather than
|
|
answered wrongly, and hiding it would mean a field in a frame layout spelled
|
|
out in three places. Left as it is.
|