flan/spec-conditions.md
Joseph Ferano df73f87b2f The return type stops being a guess: the slot is mandatory, unit is ()
The slot after a defn's parameters is unconditionally a type. Parse.decl no
longer takes a set of type names, and is_type_form, qualified_type, types_in,
declared_types and prelude_types are gone with the pre-pass that fed them.

What they were for: (Option f64) and (Some 1) are the same s-expression, so the
parser decided which it had by looking the head up in a set of the file's own
type names. Sound -- one top-level namespace means a name cannot be both a type
and a value -- and brittle, because the set had to be complete. It was wrong
twice in one day, the second time parsing (defn f [] (Rune {.code 65}) (bar))
as a function returning a Rune with a one-form body, silently, in every file in
the language.

Two things fall out. A type the parser could not have known -- a struct
declared further down the file, rl/Vector2 behind an unresolved alias, a
prelude type -- never needed recognising, only placing. And a mistyped type is
a mistyped type: (defn f [] f65 0.0) reaches the resolver's near-miss check and
says did you mean f64, where it used to be read as the first form of the body
and reported as an unknown name.

Unit is written (). The old spelling is refused with a message naming the new
one, the rule the colon-to-dot change followed. Internally it is still
Tname "Unit" and Types.Unit, so the resolver, the shim and the emitter did not
change; Cimport still builds Tname "Unit" for C's void without going through
the parser. Types.to_string prints () though -- that printer prints what a
person would write for every other type it knows, [i32], {K V}, (Ptr T), and
Unit was the odd one out once the source spelling moved.

Dropping prelude_types removes one of the two reasons Macro.reduce may only
drop defns: the memoised set a bootstrap build could have poisoned is gone, so
the remaining reason is the plain one.
2026-09-12 23:18:28 +07:00

9.0 KiB

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).

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.