flan/spec-conditions.md
2026-09-10 14:40:34 +07:00

5.3 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 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, so that native and wasm32 behave identically. That means every function on the path between the invoke and the target must be transfer-aware: it returns a discriminated "normal value / transferring to frame N" result, checks it after each call, and forwards.

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