flan/spec-conditions.md
Joseph Ferano 042b2ce4d0 A restart is not a transaction, and nothing rolls back
If a frame mutates a global and then signals, taking a retry re-runs the
mutation. Control resumes at the restart-case and runs forward; nothing is
undone. Common Lisp has the same property and offers no help either, so this
is written down rather than fixed.

The discipline is that the author chooses where the retry boundary is: a
restart-case above the mutations re-runs them, one below re-runs only what
follows. 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 the intended use is a game
loop, and a bad index signalling BoundsError rather than ending the process
made abandoning and retrying a frame an ordinary thing to do.

conditions.org and web/index.html already carried the mechanical half as a
one-line gotcha; those are rewritten in place rather than gaining a second
bullet beside them. spec-conditions.md takes it in section 5, which already
enumerates what a transfer does and does not do. No numbered case changed
meaning.
2026-09-13 09:14:46 +07:00

200 lines
10 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.
- `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.
## 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.