268 lines
14 KiB
Markdown
268 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,
|
|
and a type may name one parent (`(defstruct FileError :parent Error [...])`), so
|
|
a handler for a type answers every condition below it in that static chain. A
|
|
handler matched through a parent is handed the condition's name and sentence
|
|
(the root `Error`'s two fields), not the condition's own fields.
|
|
|
|
## 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 str] (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.
|