# 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. - `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, restart interaction with threads, and whether `handler-case` should be a macro over `handler-bind` + a transfer. None of these block milestone 5.