The stated reason was that native and wasm32 must behave identically, which is misleading: we do not develop on wasm32 and only ever export to it. The reasons that hold are all release-side. wasm32 cannot unwind without the exceptions proposal, so the 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, where a cmp/jne after a call reads like ordinary code, which will matter once there is a disassembler. And one mechanism is one thing to get right, since the acceptance table runs the same programs on both targets and compares a hash. The channel is an out-parameter rather than a discriminated return value, which §6 had left open. The return type then stays what the source says; a discriminated return would repack every ret, turn an aggregate return into an sret call, and nest inside the discriminated return (Option T) already is. One pointer threads down the chain, so a callee writes the target into its caller's own slot and each frame only checks and returns early - reusing the existing return path and therefore §5's defers. A single global would be more legible still, with 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.
143 lines
6.8 KiB
Markdown
143 lines
6.8 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.
|
|
|
|
## 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. 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.
|
|
|
|
- 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.
|