# 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 `Unit` `(signal c)` has type `Unit`, always. When every applicable handler returns normally without transferring, `signal` returns `Unit` 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 `Unit`. 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. ## 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. ## 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). ## 6. Crossing compiler-generated frames Transfer is lowered **explicitly** — result propagation plus branch targets — not via platform unwinding, so that native and wasm32 behave identically. That means every function on the path between the invoke and the target must be transfer-aware: it returns a discriminated "normal value / transferring to frame N" result, checks it after each call, and forwards. - The compiler marks a function transfer-transparent if it can call, directly or indirectly, anything that may invoke a restart. Escape analysis narrows this set; functions outside it pay nothing. - **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.