← board

Runtime-raised errors bypass try/except entirely (division by zero, index, key)

try:
    print(3 % 0)
except:
    print("caught")
print("after")

CPython prints caught then after. pxx prints Runtime error 200 (division by zero) and exits 200 — the handler never runs and the statement after the try block never executes.

// behaves the same. / is worse in a different way: it does not raise at all, it prints garbage (see [[bug-nilpy-large-float-str-overruns-into-garbage]]).

So the three division operators have three different behaviours for the same input, and none of them is CPython's ZeroDivisionError.

The Pascal runtime's error 200 is being raised outside the NilPy exception machinery, so the try frame never sees it. NilPy has working try/except ([[project_nilpy_exceptions_landed]]), so the fix is to raise a catchable NilPy exception from the integer-division path rather than to let the runtime trap escape.

Not just division — every error the RUNTIME raises

The same sweep, extended to exceptions, found that NOTHING raised by the runtime is catchable, while everything raised by user code is:

program CPython pxx
x = [1]x[5] under bare except: bare caught / after IndexError: list index out of range, exit 1
d = {"a": 1}d["zz"] under except KeyError: caught key / after KeyError, exit 1
same under bare except: bare caught / after KeyError, exit 1
raise IndexError("mine") under except IndexError: caught caught — correct

[[bug-nilpy-int-parse-halts-instead-of-raising]] (opened 2026-07-27) is the SAME defect seen through int("abc") — one more entry in the table, not a separate bug. Fix them in one pass.

The mechanism is visible in compiler/builtin/pylib.pas:

procedure PyIndexError;
begin
  writeln('IndexError: list index out of range');
  Halt(1);
end;

PyKeyError is the same three lines. Meanwhile the exception CLASSES are already declared right there (ValueError, TypeError, IndexError, KeyError, OSError, … all class(Exception)), pylib already raises elsewhere (raise Exception.Create('TypeError: ...')), and a NilPy raise IndexError("mine") is caught correctly. So the fix is to turn these halt-helpers into raises of the class they already name.

So the exception machinery works; the runtime's own error paths simply do not go through it. They print a message and exit instead of unwinding to the enclosing handler. Ordinary defensive Python — try: v = d[k] except KeyError: — cannot be written at all.

That makes this one fix, not four: give the runtime error paths (index bound, missing key, division by zero, and whatever else exits directly) a raise that the NilPy try frame can see. The exception types already exist and already match by name, as the raise IndexError row shows.

Everything else in the exception sweep matched CPython exactly: raise / except <Type> / bare except / except ... as e / finally / finally with a return in the try body / handler ordering.

Found by the operator × operand-type sweep against CPython, extended to exceptions.

Gate

make test-nilpy + self-host byte-identical, plus a regression test that catches x % 0, x // 0 and x / 0 and keeps running.

RESOLVED — the runtime raises instead of halting

Four halt sites in compiler/builtin/pylib.pas became raises of the classes declared at the top of that same unit:

was now
PyIndexErrorwriteln + Halt(1) raise IndexError.Create('list index out of range')
PyKeyErrorwriteln + Halt(1) raise KeyError.Create('key not found')
int("abc")writeln + Halt(219) raise ValueError.Create('invalid literal for int() with base 10: ' + quoted)
x // 0, x % 0 — bare Pascal div, trapping as runtime error 200 raise ZeroDivisionError from pyfloordiv_i / pyfloormod_i / pyfloordiv_f

ZeroDivisionError had no class at all and was added beside the others.

True division needed more than a guard. 3 / 0 was not trapping — it produced a saturated Int64 that the large-float formatter printed as garbage BYTES on stdout. Plain IEEE division can neither raise nor yield Python's always-float result, so NilPy's / now routes through a new pytruediv_f. That makes every NilPy division a call: a real cost, accepted because the alternative is a silently wrong answer. Promotable-int operands stay on the old path — their rvalue is a frame SLOT ADDRESS ([[project_promotable_int_stages123]]), so handing one to a Double parameter would pass the address as a number.

The first attempt at that routing was RED, and the gate caught it. The arm fired for every / in PyProgramMode, including Path("a") / "b" — pathlib's join operator — which compiled a path handle as a Double and segfaulted test_nilpy_pathlib. Both operands must be NUMBERS: TypeIsPyNumeric (symtab.inc) now gates it, excluding tyClass, tyPointer, tyChar, tyVariant and the string kinds, each of which has its own / meaning or none. Booleans are in, since True / 2 is 0.5 in Python.

Verified against CPython: x % 0, x // 0, x / 0 and int("abc") each caught by their own exception type and by a bare except:, with execution continuing afterwards; message text matches CPython's for all four. Ordinary division unaffected (7/2, -7/2, 7.5/2.5, 10/5, // and % over int/float pairings all match).

This also closes [[bug-nilpy-int-parse-halts-instead-of-raising]].

Two findings the fix exposed, filed separately

Both were invisible before, because the process died first:

Gate

tools/gate.sh full.

Log