Przeglądaj źródła

docs: design ISO exceptions (handler model, codegen, libraries)

Eric Streit 1 dzień temu
rodzic
commit
2a30d8cb1b

+ 3 - 0
docs/INDEX.md

@@ -37,6 +37,9 @@ bisecting *why* a decision was made, not for reading end-to-end).
 - **`plan-separate-compilation.md`** — future work: stable symbols,
   per-unit objects, init order, linker, and the dialect library-folder
   layout (deferred until the compiler is finished).
+- **`design-exceptions.md`** — ISO exceptions design: grammar/AST,
+  handler stack, `sigsetjmp`/`siglongjmp` codegen, `EXCEPTIONS` /
+  `M2EXCEPTION`, trap mapping, tests.  Design only; not implemented.
 - `blaise-phases-map.md`, `language-report.md`, `grammar.md` — the
   grammar/phases reference.
 - `summary_l3_selfhost_fix.md` — the self-host root cause; also where

+ 3 - 3
docs/STATUS.md

@@ -171,9 +171,9 @@ declarations still work (`t_fwdproc`, `t_fwdok`).
    `INC`/`DEC`, leading CASE `|`, `__FUNCTION__`).  Corpus is **511/612**
    at the stop point.  The remaining `LANG` entries are mostly:
    **declaration order** cases V3 rejects by policy (`docs/gaps/`); ISO
-   exceptions; multi-dimensional open arrays (representation, see
-   `docs/handoff-2026-10-23-next.md`); and gm2-only extensions.  See
-   `docs/analysis_gaps.md`.
+   exceptions (design in `docs/design-exceptions.md`); multi-dimensional
+   open arrays (representation, see `docs/handoff-2026-10-23-next.md`);
+   and gm2-only extensions.  See `docs/analysis_gaps.md`.
 2. ~~**The size-sensitive V3 miscompile**~~ — **root-caused and fixed**
    (2026-10-19; see `docs/summary_l3_selfhost_fix.md`).  It was never
    size-sensitive: the fixpoint gate matched the literal string

+ 675 - 0
docs/design-exceptions.md

@@ -0,0 +1,675 @@
+# Design — ISO exceptions for V3
+
+**Status: design only (2026-10-23).**  Nothing in this document is
+implemented yet.  Baseline: `v3-packedset`; suite **263/263**, fixpoint
+OK **4,075,853 bytes**, corpus **511/612**, 0 crashes.
+
+The handoff calls this the largest remaining language feature.  This
+document fixes the syntax, AST, runtime model, code-generation contract,
+library modules, trap mapping and test strategy before code is written.
+
+---
+
+## 1. Scope
+
+ISO 10514-1 exceptions include:
+
+- `EXCEPT` after a procedure or module body, with `RETRY` in the handler;
+- the system modules `EXCEPTIONS` and `M2EXCEPTION`;
+- the language exceptions (`indexException`, `rangeException`, …,
+  `exException`);
+- detection of implementation traps (array bounds, subranges, `CASE`,
+  `NIL` dereference, division by zero, …) as language exceptions.
+
+Out of scope for the first implementation:
+
+- ISO `FINALLY` / module termination (V3 has no finalization yet);
+- exception handling in coroutines (V3 coroutines are PIM-style);
+- protection exceptions;
+- complex-number exceptions (complex arithmetic is a placeholder);
+- debugger integration for unhandled exceptions.
+
+The design does include the gm2 `SYSTEM.THROW` extension because the
+corpus uses it.
+
+---
+
+## 2. ISO surface
+
+### 2.1 Syntax
+
+Add two reserved words: `EXCEPT` and `RETRY`.
+
+The block grammar becomes (subject to a final check against the ISO
+grammar):
+
+```text
+Block = DeclSeq
+        [ "BEGIN" [ StatSeq ] ]
+        [ "EXCEPT" [ StatSeq ] ]
+        "END" ident .
+```
+
+`EXCEPT` is part of the block, not a statement.  A handler is a
+`StatSeq` and may be empty.  `RETRY` is a statement only valid inside a
+handler.
+
+The same shape applies to program, implementation and local-module
+bodies.  Definitions have no body.
+
+### 2.2 Dynamic semantics
+
+- If the body completes normally, the handler is skipped.
+- If an exception is raised in the body, control passes to the nearest
+  active handler on the call stack.
+- An exception raised **inside a handler** is not caught by that same
+  handler; it propagates to an outer handler.
+- `RETRY` resumes the body from its beginning.  It does not re-push the
+  handler, does not re-run parameter initialisation, and does not reset
+  locals.
+- Falling out of a handler normally means the exception is handled and
+  the block completes normally.
+- `RETURN` inside the body or handler returns from the procedure.
+- If no handler exists, raising an exception is a termination event.
+- **Exception state is block-scoped.**  A procedure (or module body)
+  with an `EXCEPT` part executes its body in the normal state and
+  restores the caller's state when it leaves, even when it was called
+  from a handler.  A block without an `EXCEPT` part simply inherits the
+  caller's state.  This is why the runtime needs a saved-state stack,
+  not just one global flag.
+
+### 2.3 `EXCEPTIONS`
+
+```text
+TYPE ExceptionSource;                 (* opaque *)
+     ExceptionNumber = CARDINAL;
+
+PROCEDURE AllocateSource(VAR newSource: ExceptionSource);
+PROCEDURE RAISE(source: ExceptionSource; number: ExceptionNumber;
+                message: ARRAY OF CHAR);
+PROCEDURE CurrentNumber(source: ExceptionSource): ExceptionNumber;
+PROCEDURE GetMessage(VAR text: ARRAY OF CHAR);
+PROCEDURE IsCurrentSource(source: ExceptionSource): BOOLEAN;
+PROCEDURE IsExceptionalExecution(): BOOLEAN;
+```
+
+Semantics:
+
+- `CurrentNumber` returns the number only when the current state is
+  exceptional **and** the source matches; otherwise it raises
+  `exException`.
+- `GetMessage` returns the current message, or the empty string in
+  normal state.
+- `IsCurrentSource` is true iff exceptional and source matches.
+- `AllocateSource` allocates a unique opaque source token; allocation
+  failure would raise `exException` (the token allocator is practically
+  inexhaustible).
+- `RAISE` associates source, number and message with the current
+  context, then raises.  It does not return.
+
+### 2.4 `M2EXCEPTION`
+
+```text
+TYPE M2Exceptions =
+  (indexException, rangeException, caseSelectException, invalidLocation,
+   functionException, wholeValueException, wholeDivException,
+   realValueException, realDivException, complexValueException,
+   complexDivException, protException, sysException, coException,
+   exException);
+
+PROCEDURE M2Exception(): M2Exceptions;
+PROCEDURE IsM2Exception(): BOOLEAN;
+```
+
+A **language** exception has a distinguished source that users cannot
+allocate.  `IsM2Exception` is true only for that source.
+`M2Exception` returns the corresponding enumeration value and otherwise
+raises `exException`.
+
+### 2.5 `SYSTEM.THROW`
+
+gm2 extension used by `except5` and `except7`:
+
+```text
+PROCEDURE THROW(i: INTEGER);   (* noreturn *)
+```
+
+Map `i` to `M2Exceptions(i)`; out-of-range values map to `exException`.
+The exception is a language exception, so `M2EXCEPTION` can identify it.
+
+---
+
+## 3. Current V3 baseline
+
+- `QbeGen.Trap` emits `call $abort()`; it does not raise a catchable
+  exception.
+- `EXCEPT` / `RETRY` are still identifiers/keywords absent from the
+  grammar.
+- `FileIO.mod` has the ISO `EXCEPT` clauses commented out.
+- `SYSTEM` is hard-coded in `SymTab`; it has no `THROW`.
+- There is no `EXCEPTIONS` or `M2EXCEPTION` module.
+- Traps currently used:
+  - `QbeGen.CheckRange`: array index and subrange checks;
+  - `QbeGen.CopySet` / `CopyRecord`: malformed descriptor/copy checks;
+  - explicit `$abort` elsewhere.
+- Existing trap tests expect **rc=134** (SIGABRT).  Unhandled
+  exceptions must keep that.
+
+### Corpus reality
+
+The handoff lists seven exception files, but six of them still hit V3's
+strict declaration-order rule before reaching `EXCEPT`:
+
+- `except2`, `except4`, `except5`, `except7`, `except8`, `except9` use
+  the module variable `ip` inside procedures declared before
+  `VAR ip: POINTER TO INTEGER` at the bottom.
+- `lazyunique` declares `head` before its procedures and is a genuine
+  exception test.
+
+So the realistic corpus gain is **+1 (`lazyunique`)** unless the
+declaration order policy changes.  We should keep test copies with `ip`
+moved above the procedures to prove that the exception implementation
+itself handles `except2/4/5/7/8/9`.  This mirrors the PACKEDSET
+`onebyte*` finding; do not relax declaration order.
+
+---
+
+## 4. Runtime model
+
+### 4.1 Handler stack and saved state
+
+Use a fixed-size stack in the C shim.  No C++ exceptions, no unwind
+library, no `malloc` on the raise path.
+
+```c
+typedef struct {
+    int active;
+    int language;              /* language exception vs EXCEPTIONS.RAISE */
+    void *source;              /* opaque source token, or language token */
+    unsigned number;
+    char message[1024];
+} ExcState;
+
+typedef struct {
+    void *env;                 /* pointer to the sigjmp_buf in the frame */
+    int in_handler;
+    ExcState saved;            /* caller state to restore on leave */
+} ExcHandler;
+
+static ExcState current;
+static ExcHandler handlers[1024];
+static int top;
+```
+
+Operations:
+
+- `push(env)`:
+  - copy `current` into `handlers[top].saved`;
+  - set `current` to the normal state;
+  - store `env`, `in_handler = 0`;
+  - `top++`.
+- `leave()`:
+  - `top--`;
+  - `current = handlers[top].saved`.
+- `retry()`:
+  - `current` becomes normal (not the caller state);
+  - `handlers[top-1].in_handler = 0`;
+  - `siglongjmp(env, 2)`.
+- `raise(new_state)`:
+  - search from `top-1` downwards for the first entry with
+    `in_handler == 0`;
+  - discard entries above the target (`top = target + 1`);
+  - mark the target `in_handler = 1`;
+  - `current = new_state`;
+  - `siglongjmp(target.env, 1)`.
+- If no target: write the message to stderr and `abort()` (termination
+  event; preserves rc=134).
+
+The `saved` copy is what makes the block-scoped rule in §2.2 work:
+a procedure with `EXCEPT` sees normal state in its body even when its
+caller is already handling an exception, and restores the caller's
+state when it leaves.
+
+### 4.2 C ABI
+
+Add to `runtime/syslib/shim.c` (or a separate `exceptions.c` linked by
+the same commands):
+
+```c
+void m2_exc_init(void);
+void m2_exc_push(void *env);
+void m2_exc_leave(void);
+void m2_exc_retry(void) __attribute__((noreturn));
+void m2_exc_raise_lang(int code) __attribute__((noreturn));
+void m2_exc_raise_user(void *source, unsigned number,
+                       const long *msg_desc) __attribute__((noreturn));
+void m2_exc_throw(long code) __attribute__((noreturn));
+
+int  m2_exc_active(void);
+int  m2_exc_language(void);
+int  m2_exc_is_source(void *source);
+unsigned m2_exc_number(void);
+void m2_exc_copy_message(long *text_desc);
+void *m2_exc_new_source(void);
+```
+
+- `msg_desc` / `text_desc` are V3 string descriptors:
+  `[0] = length`, then NUL-terminated bytes.
+- `m2_exc_new_source` returns a unique non-NULL token; it is never
+  dereferenced.  This keeps `ExceptionSource` opaque and avoids
+  allocation.
+- `m2_exc_init` is idempotent and installs the signal handlers.
+- `m2_exc_throw` is the C link for `SYSTEM.THROW`.
+
+### 4.3 Signals and traps
+
+Install handlers for `SIGSEGV`, `SIGBUS`, `SIGFPE` (and optionally
+`SIGILL`) with `SA_SIGINFO`.  The handler must only classify the signal
+and call `m2_exc_raise_lang`; it must not allocate or print on the
+caught path.
+
+Mapping:
+
+| Signal / source | Exception |
+| --- | --- |
+| `SIGSEGV`, `SIGBUS` | `invalidLocation` |
+| `SIGFPE` / `FPE_INTDIV` | `wholeDivException` |
+| `SIGFPE` / `FPE_INTOVF` | `wholeValueException` |
+| `SIGFPE` / `FPE_FLTDIV` | `realDivException` |
+| `SIGFPE` / `FPE_FLTOVF`, `FLTUND`, `FLTRES`, `FLTINV` | `realValueException` |
+| `SIGFPE` / `FPE_FLTSUB` | `indexException` |
+| `SIGILL` (optional) | `sysException` |
+
+Compiler-emitted checks use the same runtime:
+
+- array index -> `indexException`;
+- subrange assignment/actual/`RETURN`/`INC`/`DEC`, set element -> `rangeException`;
+- `CASE` with no matching label and no `ELSE` -> `caseSelectException`;
+- descriptor/copy mismatch, invalid `SYSTEM` operation -> `sysException`.
+
+`QbeGen.CheckRange` gains a code parameter (or split into
+`CheckIndex`/`CheckRange`) so the same compare-and-branch machinery can
+trap with the right number.  `QbeGen.Trap` becomes
+`TrapCode(code)` and calls `$m2_exc_raise_lang(w code)` instead of
+`$abort()`.
+
+`m2_exc_init` must run before user code.  Emit a call at the start of
+generated `main` (`QbeGen.BeginBody`).  If no handler exists, the
+signal handler still calls the runtime, which reports and aborts;
+existing trap tests remain rc=134.
+
+**Signal-mask note:** generated code must use a setjmp that saves the
+signal mask (`sigsetjmp` / glibc `__sigsetjmp`), otherwise a fault
+handler can leave `SIGSEGV` blocked after `longjmp`.  V3 targets
+Linux/amd64 today; keep this binding behind a small target macro.
+
+### 4.4 `setjmp` in generated code
+
+`setjmp` cannot be hidden behind a normal C helper: the helper's frame
+would be gone.  Generated code must call the real symbol directly.
+
+Use a stack `sigjmp_buf` per handler-bearing block.  The current target
+is glibc/amd64; allocate 512 bytes and statically assert
+`sizeof(sigjmp_buf) <= 512` in the shim.  The environment lives in the
+QBE frame and remains valid until the block returns or a `RETRY`
+re-enters it.
+
+---
+
+## 5. Code generation
+
+### 5.1 Procedure with `EXCEPT`
+
+Pseudo-QBE for a procedure whose body has a handler:
+
+```qbe
+export function w $P(l %sl) {
+@start
+  ...
+  %env =l alloc8 512
+  call $m2_exc_push(l %env)
+@dispatch
+  %r =w call $__sigsetjmp(l %env, w 1)
+  %zero =w ceqw %r, 0
+  jnz %zero, @body, @check
+@check
+  %two =w ceqw %r, 2
+  jnz %two, @body, @handler
+@body
+  ... body statements ...
+  call $m2_exc_leave()
+  jmp @after
+@handler
+  ... handler statements ...
+  call $m2_exc_leave()
+@after
+  ret ...
+}
+```
+
+- `RETRY` emits `call $m2_exc_retry()` followed by `hlt`; the runtime
+  longjmps back to the saved environment, `sigsetjmp` returns 2, and
+  control goes to `@body` without re-pushing the handler.
+- `RETURN` in the body or handler emits `call $m2_exc_leave()` before
+  the QBE `ret`.  QbeGen can track `funcHasExc` and make `EmitRet`
+  emit the pop.
+- Normal fall-through of the body goes to `@after` after popping.
+- Normal fall-through of the handler also goes to `@after` after
+  popping.
+- If there is no `EXCEPT`, none of this is emitted; behaviour is
+  unchanged.
+
+### 5.2 QbeGen API
+
+Add (names illustrative):
+
+```text
+ExcBegin        -- allocate env, push, sigsetjmp, @body/@handler dispatch
+ExcBodyDone     -- leave + jump @after
+ExcHandlerBegin -- emit @handler
+ExcEnd          -- leave + @after
+ExcRetry        -- call m2_exc_retry, dead
+```
+
+`EndFunc`, `EndInit` and the program `main` path must not emit an extra
+pop; the body/handler paths already balance the stack.  `EmitRet`
+handles early returns.  `funcHasExc` is reset in `AbortFunc`.
+
+Module-body handling:
+
+- Implementation/local module `BEGIN ... END`: `BeginInit` →
+  `ExcBegin` → body → `EndInit` (with the same handler shape).
+- Program module body: `BeginBody` emits `main` and the imported
+  module-init calls; `ExcBegin` follows that, so the program handler
+  covers its own body, not imported module initialisation.
+- No handler: no change.
+
+`QbeGen.BeginBody` also emits `call $m2_exc_init()` before the init
+calls, so hardware faults outside a handler still terminate through the
+runtime and existing trap tests keep rc=134.
+
+### 5.3 State restoration at returns
+
+Every return path from a block with a handler must call
+`m2_exc_leave` exactly once.  This includes:
+
+- normal fall-through of the body;
+- normal fall-through of the handler;
+- `RETURN` in the body;
+- `RETURN` in the handler.
+
+`RETRY` must not call `leave`; it stays inside the same block and
+restarts the body.
+
+---
+
+## 6. Front end
+
+### 6.1 Scanner and grammar
+
+- `EXCEPT` and `RETRY` become reserved words in the Coco/R grammar.
+- Add `"EXCEPT" [ StatSeq ]` to `Block`, `ImplUnit`, `NestedModule` and
+  the class-body path that reuses `Block`.
+- Add `RETRY` to `Statement`.
+- `RETRY` outside a handler is a semantic error.  Add a specific
+  message (proposal: code 237, `RETRY outside EXCEPT`).
+- Two-phase defer/reparse: the handler lives inside the deferred body.
+  The token skipper must treat `EXCEPT`/`RETRY` as ordinary tokens
+  while looking for the matching `END`, and the phase-2 reparse must
+  rebuild `NkExcept`/`NkRetry` like any other body node.  Do not add
+  handler state to the skipper.
+
+### 6.2 AST
+
+Preferred shape: a wrapper node so Lower has one place to handle the
+pair and ordinary statement sequences stay unchanged.
+
+```text
+NkExcept = 56;   (* [0] = body statements, [1] = handler statements *)
+NkRetry  = 57;
+```
+
+- A block with no `EXCEPT` keeps its old body node.
+- A block with `EXCEPT` sets its body to an `NkExcept` node.
+- `NkRetry` is a leaf statement.
+- `NkProcDecl` / `NkUnit` / local-module bodies keep their current
+  child positions; only the body node's kind changes.
+
+### 6.3 Static checks
+
+- `RETRY` only in the handler of the current block, including nested
+  control statements inside that handler.
+- `RETRY` in a nested procedure is invalid for the outer handler; each
+  procedure has its own handler context.
+- `RETURN` is allowed in a handler.
+- `EXCEPT` cannot appear as a statement or in a definition module.
+- No new type rules; `EXCEPTIONS`/`M2EXCEPTION` are ordinary library
+  modules.
+
+---
+
+## 7. Library implementation
+
+### 7.1 `runtime/syslib/SysExceptions.def/.mod`
+
+A V3 binding module over the C runtime, in the style of `SysShim`:
+
+```text
+PROCEDURE RaiseLang(code: CARDINAL);
+PROCEDURE RaiseUser(source: ADDRESS; number: CARDINAL;
+                    VAR message: ARRAY OF CHAR);
+PROCEDURE Retry;
+PROCEDURE Leave;
+PROCEDURE Active(): BOOLEAN;
+PROCEDURE IsLanguage(): BOOLEAN;
+PROCEDURE IsSource(source: ADDRESS): BOOLEAN;
+PROCEDURE Number(): CARDINAL;
+PROCEDURE CopyMessage(VAR text: ARRAY OF CHAR);
+PROCEDURE NewSource(): ADDRESS;
+```
+
+`RaiseLang`/`RaiseUser`/`Retry` map to `noreturn` C functions.  The
+Modula declarations do not need pragmas.
+
+### 7.2 `stdlib/exceptions.def/.mod`
+
+- Copy the ISO definition without the `<* noreturn *>` pragma (V3 does
+  not parse declaration pragmas yet).
+- Implementation:
+
+```text
+TYPE ExceptionSource = ADDRESS;   (* opaque in the definition *)
+```
+
+`AllocateSource` calls `SysExceptions.NewSource`.  Since the source is
+an opaque token and is never dereferenced, no heap allocation is
+needed.  The implementation imports `M2EXCEPTION` for `exException`;
+`M2EXCEPTION` does not import `EXCEPTIONS`, so there is no module
+cycle.
+
+`CurrentNumber`:
+
+```text
+IF SysExceptions.IsSource(source) THEN
+  RETURN SysExceptions.Number()
+ELSE
+  SysExceptions.RaiseLang(ORD(M2EXCEPTION.exException));
+  RETURN 0
+END
+```
+
+`GetMessage` calls `CopyMessage`; `IsExceptionalExecution` calls
+`Active`; `IsCurrentSource` calls `IsSource`; `RAISE` calls
+`RaiseUser`.
+
+### 7.3 `stdlib/m2exception.def/.mod`
+
+`M2Exception` returns `VAL(M2Exceptions, SysExceptions.Number())` when
+`IsLanguage` is true; otherwise it raises `exException`.
+`IsM2Exception` returns `SysExceptions.IsLanguage()`.
+
+### 7.4 `SYSTEM.THROW`
+
+Add to the hard-coded `SYSTEM` setup in `SymTab.Init`:
+
+```text
+pr := PredefCProc("THROW", "m2_exc_throw", InvalidType);
+AddFormal(pr, dInt, FALSE)
+```
+
+`m2_exc_throw` raises language code `i` (or `exException` if out of
+range) and never returns.  `FROM SYSTEM IMPORT THROW` then works
+through the existing external-procedure path.
+
+---
+
+## 8. Language-exception mapping
+
+| Value | Trigger in V3 |
+| --- | --- |
+| `indexException` | array index check; `FPE_FLTSUB` |
+| `rangeException` | subrange assignment/actual/`RETURN`/`INC`/`DEC`; set element; set span |
+| `caseSelectException` | `CASE` no match, no `ELSE` (add explicit trap) |
+| `invalidLocation` | `SIGSEGV`/`SIGBUS`, NIL/uninitialised pointer |
+| `functionException` | function body ends without `RETURN` (follow-up; current V3 returns 0) |
+| `wholeValueException` | `SIGFPE` integer overflow |
+| `wholeDivException` | `SIGFPE` integer divide-by-zero; optional negative divisor check |
+| `realValueException` | `SIGFPE` real overflow/underflow/invalid |
+| `realDivException` | `SIGFPE` real divide-by-zero |
+| `complexValueException` | not detected (no complex arithmetic) |
+| `complexDivException` | not detected |
+| `protException` | not detected (no protection model) |
+| `sysException` | descriptor/copy mismatch, invalid `SYSTEM` operation |
+| `coException` | not detected (coroutine contexts deferred) |
+| `exException` | wrong source/state in `EXCEPTIONS`/`M2EXCEPTION`; bad `THROW` |
+
+Messages are implementation-defined; use a short table, e.g.
+`"array index out of range"`, `"range check failed"`, `"invalid memory
+location"`, `"divide by zero"`.
+
+---
+
+## 9. Testing
+
+### 9.1 Unit / regression tests
+
+Add to `compiler/run_tests.sh`:
+
+- `t_except_basic`: `RAISE` from a nested procedure, handler checks
+  `CurrentNumber`, `GetMessage`, `IsCurrentSource`,
+  `IsExceptionalExecution`; exit 42.
+- `t_except_retry`: two-level retry like `except4`/`except8`; exit 42.
+- `t_except_throw`: `SYSTEM.THROW` caught, then `M2Exception` checked.
+- `t_except_nested`: handler calls a procedure with its own handler;
+  verifies state restoration (outer `CurrentNumber` after inner returns).
+- `t_except_return`: `RETURN` from body and from handler; outer handler
+  still catches a later exception.
+- `t_except_module`: module-body `EXCEPT`/`RETRY`.
+- `t_except_trap`: integer divide-by-zero and NIL dereference caught;
+  `M2Exception` returns `wholeDivException` / `invalidLocation`.
+- `t_except_unhandled`: expect_trap (rc=134).
+- `t_except_retry_bad`: `RETRY` outside `EXCEPT` rejected with the new
+  message.
+- `t_except_lib`: `EXCEPTIONS` source allocation and wrong-source
+  `CurrentNumber` raises `exException`.
+
+Use `expect_run_files` with:
+
+```text
+stdlib/exceptions.def stdlib/exceptions.mod
+stdlib/m2exception.def stdlib/m2exception.mod
+runtime/syslib/SysExceptions.def runtime/syslib/SysExceptions.mod
+```
+
+### 9.2 Corpus
+
+- `lazyunique` should become a genuine +1 (corpus 511 -> 512).
+- `except2/4/5/7/8/9` still fail declaration order because `ip` is
+  declared after the procedures.  Keep temporary copies with `ip`
+  moved above the procedures and run them outside the corpus count.
+- Do not claim the handoff's "+7" in the status until the declaration
+  order cases are re-counted.
+
+### 9.3 Gates
+
+For every step:
+
+```sh
+cd compiler && ./build.sh && ./run_tests.sh
+cd .. && TMP="$PWD/.scratch/fixpoint" ./bootstrap/fixpoint.sh
+tools/v3-corpus/corpus.sh
+```
+
+Expected after the first full implementation: suite 263 + new tests,
+fixpoint OK (size will change), corpus **512/612** (only
+`lazyunique`), 0 crashes.  Existing trap tests must stay rc=134.
+
+---
+
+## 10. Milestones
+
+1. **M1 — parser/AST**
+   - `EXCEPT`, `RETRY`; `NkExcept`/`NkRetry`; `RETRY` placement check;
+     `EXCEPT` accepted in procedure, implementation, program and local
+     module bodies.  No codegen yet.
+2. **M2 — runtime + codegen for user `RAISE`**
+   - `m2_exc_*` handler stack, `ExcBegin`/`ExcBodyDone`/`ExcHandlerBegin`/
+     `ExcEnd`/`ExcRetry`, `EmitRet` pop, `m2_exc_init` in `main`.
+   - Positive tests for `RAISE`, `RETRY`, `RETURN`, nested handlers.
+3. **M3 — libraries**
+   - `SysExceptions`, `EXCEPTIONS`, `M2EXCEPTION`, `SYSTEM.THROW`.
+   - Library tests; `except5/7` variants.
+4. **M4 — language traps**
+   - `TrapCode`; split index/range; CASE trap; signal handlers and
+     mapping; `lazyunique`; `t_except_trap`; keep rc=134 for unhandled.
+5. **M5 — docs and corpus**
+   - Update `features.md`, `STATUS.md`, comparison table, gaps, handoff,
+     summary; commit with `v3-exceptions`; record corpus reality.
+
+`functionException`, real/complex/protection/coroutine detection can
+follow separately; they should not block M1–M4.
+
+---
+
+## 11. Risks and deferred work
+
+- **`setjmp` ABI:** generated code must call `__sigsetjmp` directly;
+  the environment size and signal-mask behaviour are glibc/amd64
+  specific.  Keep a target macro and a static assert.
+- **QBE and returns-twice:** test early.  All V3 locals are memory
+  objects, which helps, but a small standalone test should verify that
+  SSA values used after the handler branch are still valid.
+- **Signal safety:** no allocation/printf on the caught path; use
+  `siglongjmp`; do not catch signals while the runtime is already
+  unwinding.  A stack-overflow `SIGSEGV` cannot be handled on the
+  overflowed stack; an optional `sigaltstack` is a later hardening
+  item.
+- **Handler depth:** fixed 1024 entries; overflow should report
+  `sysException` or abort, not corrupt memory.
+- **No finalization:** longjmp bypasses any future cleanup; `FINALLY`
+  needs its own design.
+- **Coroutines:** the state/handler stack is global, not per-coroutine.
+  `coException` remains undetected.
+- **Function result:** current V3 returns 0 when a function falls off
+  the end; raising `functionException` is a follow-up because it can
+  change existing behaviour.
+- **Separate compilation:** the runtime ABI (`m2_exc_*`) is stable, but
+  per-unit exception tables/handler metadata would be needed once
+  objects are separate.  Not part of this milestone.
+- **Pragmas:** local `EXCEPTIONS.def` omits `<* noreturn *>`; adding
+  ISO declaration pragmas is independent.
+
+---
+
+## 12. Open questions
+
+1. Confirm the ISO block grammar allows `EXCEPT` without a preceding
+   `BEGIN`; accept both forms in V3 unless the spec forbids it.
+2. Decide whether `RETRY` in a program-module body is accepted (gm2
+   allows module and procedure blocks; ISO mentions module
+   initialization/finalization).  Proposed: yes.
+3. Decide the exact `THROW(i)` mapping when `i` is outside
+   `0 .. 14` (proposed: `exException`).
+4. Confirm whether `functionException` should be raised now for
+   functions with a handler whose body/handler falls through without
+   `RETURN`; proposed: follow-up, not M1.

+ 3 - 0
docs/features.md

@@ -154,6 +154,9 @@ Legend: ✅ done · 🔄 partial · ⏸ not started / deferred.
   composite function results (record/array/set/class, sret) and chained
   post-call method calls (`F().M()`); ⏸ per-file object emission, import
   cycles.
+- ⏸ ISO exceptions (`EXCEPT`/`RETRY`, `EXCEPTIONS`, `M2EXCEPTION`,
+  language exceptions); design in `docs/design-exceptions.md`, not
+  implemented.
 
 ## Backend (QBE)
 - ✅ Length-prefixed arrays (count + elements), deep record copy,

+ 6 - 0
docs/gaps/README.md

@@ -94,6 +94,12 @@ gap22_file_line.mod             invalid Fact            (__LINE__)
 
 ## Genuine gaps (worth fixing, standard Modula-2)
 
+- **ISO exceptions — design ready, not implemented** (2026-10-23):
+  `EXCEPT`/`RETRY`, `EXCEPTIONS`, `M2EXCEPTION`, language-exception
+  detection.  `lazyunique` is the only corpus file that should become a
+  genuine +1; `except2/4/5/7/8/9` still fail declaration order because
+  `ip` is declared after the procedures.  See
+  `docs/design-exceptions.md`.
 - **`libc` prototypes — fixed** (2026-10-22): the known C functions
   now have real signatures (`exit`, `write`/`read`/`open`/`close`/
   `lseek`, `malloc`, `memcpy`/`memset`/`strncpy`, `system`/`getenv`,

+ 17 - 9
docs/handoff-2026-10-23-next.md

@@ -26,20 +26,28 @@ inside `assert` before its declaration (declaration order, rejected by
 policy).  With `r` moved before the procedure they compile and run.
 Do not relax declaration order for this corpus case.
 
-## 2. ISO exceptions — 7 files (`except2/4/5/7/8/9`, `lazyunique`)
+## 2. ISO exceptions — design first (`docs/design-exceptions.md`)
 
-A dedicated feature session, not a small fix:
+A dedicated feature session, not a small fix.  Start with the design in
+`docs/design-exceptions.md` (grammar/AST, handler stack, `sigsetjmp`/
+`siglongjmp` codegen, `EXCEPTIONS`/`M2EXCEPTION`, trap mapping, tests):
 
-- `EXCEPT` / `RETRY` after a normal statement sequence (procedures and
-  module bodies);
+- `EXCEPT` / `RETRY` after a normal statement sequence (procedures,
+  implementation/program/local module bodies);
 - `EXCEPTIONS` (`AllocateSource`, `RAISE`, `CurrentNumber`,
   `GetMessage`, `IsCurrentSource`, `IsExceptionalExecution`) and
   `M2EXCEPTION`;
-- runtime handler model (handler stack / `setjmp`-`longjmp` or
-  equivalent), re-raise on unhandled;
-- `lazyunique` additionally needs ISO `UNIQUE`.
-
-Design the handler model first; then implement with trap tests.
+- runtime handler model (handler stack + saved state, `sigsetjmp`/
+  `siglongjmp`, signal-to-language-exception mapping), re-raise on
+  unhandled;
+- `SYSTEM.THROW` (gm2 extension) for `except5`/`except7`.
+
+**Corpus reality:** `lazyunique` is the genuine +1 (no declaration-order
+issue).  `except2/4/5/7/8/9` declare `ip` after the procedures and still
+hit the strict declaration-order rule, like `onebyte*`; keep moved-`ip`
+copies as semantic tests but do not count them in the corpus until the
+policy changes.  `lazyunique` also needs no `EXCEPTIONS` import, so the
+corpus count target is **512**, not 518.
 
 ## 3. Multi-dimensional open arrays — 8 files