design-exceptions.md 24 KB

Design — ISO exceptions for V3

Status: design + M1 parser/AST + M2 runtime/codegen + M3 libraries (2026-10-23). EXCEPT/RETRY, the handler stack, SYSTEM.THROW, EXCEPTIONS, M2EXCEPTION, RETURN unwinding and basic signal mapping are implemented. Compiler-emitted range/index/CASE traps (M4) remain. Baseline: v3-packedset; suite 272/272, fixpoint OK 4,108,488 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):

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

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

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:

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.

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

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:

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

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.

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:

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:
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:

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:

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_retry_bad: RETRY outside EXCEPT rejected with 237.

Still planned:

  • t_except_basic: a dedicated nested-handler state-restoration test (the current t_except_lib covers the public queries).
  • t_except_trap: integer divide-by-zero and CASE/range traps.
  • t_except_unhandled: expect_trap (rc=134).

Use expect_run_files with:

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:

cd compiler && ./build.sh && ./run_tests.sh
cd .. && TMP="$PWD/.scratch/fixpoint" ./bootstrap/fixpoint.sh
tools/v3-corpus/corpus.sh

Current M3 gates: suite 272/272, fixpoint OK 4,108,488 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. EXCEPT reports 230 until M2.
  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
    • TrapCode; split index/range; CASE trap; range-check traps; keep rc=134 for unhandled; finish t_except_trap.
  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.