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.
ISO 10514-1 exceptions include:
EXCEPT after a procedure or module body, with RETRY in the handler;EXCEPTIONS and M2EXCEPTION;indexException, rangeException, …,
exException);CASE,
NIL dereference, division by zero, …) as language exceptions.Out of scope for the first implementation:
FINALLY / module termination (V3 has no finalization yet);The design does include the gm2 SYSTEM.THROW extension because the
corpus uses it.
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.
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.RETURN inside the body or handler returns from the procedure.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.EXCEPTIONSTYPE 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.M2EXCEPTIONTYPE 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.
SYSTEM.THROWgm2 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.
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.EXCEPTIONS or M2EXCEPTION module.QbeGen.CheckRange: array index and subrange checks;QbeGen.CopySet / CopyRecord: malformed descriptor/copy checks;$abort elsewhere.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.
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):
current into handlers[top].saved;current to the normal state;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):
top-1 downwards for the first entry with
in_handler == 0;top = target + 1);in_handler = 1;current = new_state;siglongjmp(target.env, 1).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.
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.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:
indexException;RETURN/INC/DEC, set element -> rangeException;CASE with no matching label and no ELSE -> caseSelectException;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.
setjmp in generated codesetjmp 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.
EXCEPTPseudo-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.@after after popping.@after after
popping.EXCEPT, none of this is emitted; behaviour is
unchanged.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:
BEGIN ... END: BeginInit →
ExcBegin → body → EndInit (with the same handler shape).BeginBody emits main and the imported
module-init calls; ExcBegin follows that, so the program handler
covers its own body, not imported module initialisation.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.
Every return path from a block with a handler must call
m2_exc_leave exactly once. This includes:
RETURN in the body;RETURN in the handler.RETRY must not call leave; it stays inside the same block and
restarts the body.
EXCEPT and RETRY become reserved words in the Coco/R grammar."EXCEPT" [ StatSeq ] to Block, ImplUnit, NestedModule and
the class-body path that reuses Block.RETRY to Statement.RETRY outside a handler is a semantic error. Add a specific
message (proposal: code 237, RETRY outside EXCEPT).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.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;
EXCEPT keeps its old body node.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.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.EXCEPTIONS/M2EXCEPTION are ordinary library
modules.runtime/syslib/SysExceptions.def/.modA 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.
stdlib/exceptions.def/.mod<* noreturn *> pragma (V3 does
not parse declaration pragmas yet).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.
stdlib/m2exception.def/.modM2Exception returns VAL(M2Exceptions, SysExceptions.Number()) when
IsLanguage is true; otherwise it raises exException.
IsM2Exception returns SysExceptions.IsLanguage().
SYSTEM.THROWAdd 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.
| 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".
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
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.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.
EXCEPT, RETRY; NkExcept/NkRetry; RETRY placement check;
EXCEPT accepted in procedure, implementation, program, local
module and class bodies. EXCEPT reports 230 until M2.m2_exc_* handler stack, ExcBegin/ExcBodyDone/ExcHandlerBegin/
ExcEnd/ExcRetry, EmitRet pop, m2_exc_init in main.SYSTEM.THROW, signal mapping for SIGSEGV/SIGFPE.THROW, RETRY, RETURN, nested handlers,
module bodies and NIL dereference.SysExceptions, EXCEPTIONS, M2EXCEPTION.t_except_lib: RAISE, CurrentNumber, GetMessage,
IsCurrentSource, IsExceptionalExecution, wrong-source
exException, and M2Exception via SYSTEM.THROW.TrapCode; split index/range; CASE trap; range-check traps; keep
rc=134 for unhandled; finish t_except_trap.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.
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.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.sysException or abort, not corrupt memory.FINALLY
needs its own design.coException remains undetected.functionException is a follow-up because it can
change existing behaviour.m2_exc_*) is stable, but
per-unit exception tables/handler metadata would be needed once
objects are separate. Not part of this milestone.EXCEPTIONS.def omits <* noreturn *>; adding
ISO declaration pragmas is independent.EXCEPT without a preceding
BEGIN; accept both forms in V3 unless the spec forbids it.RETRY in a program-module body is accepted (gm2
allows module and procedure blocks; ISO mentions module
initialization/finalization). Proposed: yes.THROW(i) mapping when i is outside
0 .. 14 (proposed: exException).functionException should be raised now for
functions with a handler whose body/handler falls through without
RETURN; proposed: follow-up, not M1.