# Design — ISO exceptions for V3 **Status: design + M1–M4 implemented (2026-10-23).** `EXCEPT`/`RETRY`, the handler stack, `SYSTEM.THROW`, `EXCEPTIONS`, `M2EXCEPTION`, `RETURN` unwinding, signal mapping, and compiler-emitted index/range/CASE traps are implemented. `functionException`, `FINALLY`, coroutine/protection exceptions and complex arithmetic remain follow-ups. Baseline: `v3-packedset`; suite **275/275**, fixpoint OK **4,141,580 bytes**, corpus **512/612** compile-OK, 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 Implemented in M1–M3 (`compiler/run_tests.sh`): - `t_except_parse`: EXCEPT on procedure and module bodies; handlers are skipped when the body completes normally. - `t_except_throw`: `SYSTEM.THROW` caught; handler falls through. - `t_except_retry`: body restarted by `RETRY`, locals preserved. - `t_except_return`: `RETURN` from a handler pops it before the caller throws a later exception. - `t_except_nested`: an exception in a handler skips that handler and reaches the outer one. - `t_except_module`: program-module body `EXCEPT`/`RETRY`. - `t_except_signal`: NIL dereference maps to `invalidLocation`; handler retries. - `t_except_lib`: `EXCEPTIONS`/`M2EXCEPTION` `RAISE`, `CurrentNumber`, `GetMessage`, `IsCurrentSource`, `IsExceptionalExecution`, wrong-source `exException`, and `M2Exception` via `SYSTEM.THROW`. - `t_except_trap`: array index and subrange traps with `indexException`/`rangeException`. - `t_except_case`: no matching `CASE` label gives `caseSelectException`. - `t_except_unhandled`: expect_trap (rc=134). - `t_retry_bad`: `RETRY` outside `EXCEPT` rejected with `237`. `functionException` and real/complex/protection/coroutine detection are still follow-ups. 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` now compiles (corpus 512), but it is **not** a runtime gain yet: its generated QBE hits a pre-existing pointer-chain (`p^.next^.value`) codegen gap. The corpus is compile-only; fix the pointer-chain lowering separately before claiming `lazyunique` runs. - `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 ``` Current M4 gates: suite **275/275**, fixpoint OK **4,141,580 bytes**, corpus **512/612 compile-OK**, 0 crashes. Existing trap tests must stay rc=134. --- ## 10. Milestones 1. **M1 — parser/AST** — **done (2026-10-23)** - `EXCEPT`, `RETRY`; `NkExcept`/`NkRetry`; `RETRY` placement check; `EXCEPT` accepted in procedure, implementation, program, local module and class bodies. 2. **M2 — runtime + codegen** — **done (2026-10-23)** - `m2_exc_*` handler stack, `ExcBegin`/`ExcBodyDone`/`ExcHandlerBegin`/ `ExcEnd`/`ExcRetry`, `EmitRet` pop, `m2_exc_init` in `main`. - `SYSTEM.THROW`, signal mapping for `SIGSEGV`/`SIGFPE`. - Positive tests for `THROW`, `RETRY`, `RETURN`, nested handlers, module bodies and NIL dereference. 3. **M3 — libraries** — **done (2026-10-23)** - `SysExceptions`, `EXCEPTIONS`, `M2EXCEPTION`. - `t_except_lib`: `RAISE`, `CurrentNumber`, `GetMessage`, `IsCurrentSource`, `IsExceptionalExecution`, wrong-source `exException`, and `M2Exception` via `SYSTEM.THROW`. 4. **M4 — language traps** — **done (2026-10-23)** - `TrapCode`; `CheckIndex` vs `CheckRange`; CASE trap; unhandled exceptions keep rc=134; `t_except_trap`, `t_except_case`, `t_except_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.