flan/spec-conditions.md
Joseph Ferano 91d1368279 Say in the specs what the break loop actually does now
spec-conditions.md §4 gains the rule the shadowing bug was hiding: a handler
matches by name, a debugger identifies by position, and the two are not the
same question. With it, the snapshot — a position means nothing against a
stack that moves — and the fact that a visible restart may still be
unreachable, which §6's explicit lowering makes possible.

§3 records the open one: a clause should carry a report string.
is what invoke-restart needs and not what a person reading a list needs. It
wants settling before restarts with parameters, which is where a bare name is
least sufficient.

conditions.org had the break loop under "Not yet", which it has not been for
some time, and now says why find-restart and compute-restarts still are: they
are blocked on a Restart type and a list to return one in, not on effort.
2026-09-12 04:58:42 +07:00

178 lines
9.0 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 `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.
**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).
## 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.