← board

raise E() on an empty Exception subclass: segfault / silent no-op

Repro

class E(Exception):
    pass

try:
    raise E()
except E:
    print("caught")        # CPython: caught     pxx: nothing, or a segfault

The controls narrow it to one shape

variation result
raise E("m") — any argument caught
E given an __init__ caught
raise Exception("m") — the base class caught
raise E() with class E(Exception): pass segfault / nothing

So it needs the subclass to have NO constructor AND the raise to pass NO argument. Both halves of that are the idiomatic spelling — an empty exception class is the standard way to declare a domain error, and raising it bare is the standard way to use one.

Why it matters more than the repro suggests

The except clause never runs, so control does not merely produce a wrong value — it takes a wrong PATH. A program using a bare sentinel exception for flow control silently does not handle it. And the two moods (segfault vs silent) mean a test suite may see it as either a crash or a missing line.

Where to look

The generated constructor for a user Exception subclass with no __init__ and no fields. raise E("m") working suggests the message-carrying path is fine and the no-argument path either skips construction or hands raise something unconstructed. Check what E() lowers to when the class has no ctor and no fields — the class-attribute work of 2026-08-02 showed that an EMPTY member set is its own edge case in this pre-pass family.

Reach for -dPXX_HEAP_DEBUG and -dPXX_OBJTRACE before print-bisecting: an unstable segfault/silence pair is the signature of reading something that was never initialised.

Gate

A .npy diffed against CPython: bare raise E() for an empty subclass, caught by its own name and by except Exception; the same with an argument; an empty subclass raised and NOT caught (the traceback path); a subclass with __init__; two sibling empty subclasses distinguished by their except clauses; and the one-line class E(Exception): pass spelling of each.

Resolved 2026-08-02 — commit a0cf42cb6

Narrower than filed, and the ticket's "where to look" was right. Measured: CONSTRUCTION alone crashes — e = E() with no raise anywhere. The raise in the repro was incidental.

NilPy's Exception resolves to the RTL's Exception, whose Create(msg) takes a REQUIRED message. PyClassCreate's default-fill loop only supplies parameters that carry a declared default, so it stopped, and the call went out one argument short: the callee read whatever happened to be in the register as its message string. That is why raise E("m") and an own __init__ both worked — each supplies the argument — and why the failure alternated between a segfault and silence.

Exception() with no arguments is valid Python and str(Exception()) is the empty string, so the fix supplies an empty message rather than a diagnostic. Restricted to STRING parameters: those are the ones with a meaningful empty value, and the restriction keeps this from quietly papering over an under-called constructor of any other shape, which should stay visible.

A bug of my own, found by this ticket's test

Writing the gate list surfaced that [[bug-nilpy-one-line-def-and-class-bodies-do-not-parse]]'s class half, which I had landed an hour earlier in 9e5d2a80a, was not as contained as I claimed. I argued an empty class body reaches no INDENT-keyed pre-pass. Wrong: PyRegisterClassFieldsPrepass locates a class body by scanning to the first tkIndent, and a one-line body has none — so the scan ran on to the NEXT class's indent and registered that class's members against the one-line class. class G(Exception): pass followed by a class with an __init__ failed with "unresolved forward: G.create".

Fixed in the same commit: the scan stops at the header's COLON and reads the shape from what follows (COLON NEWLINE INDENT = indented, anything else = one-line), and a one-line body registers an empty member span rather than being skipped — the pass also computes the class SIZE and emits its VMT, so skipping it left the class unsized and every construction crashed. Same correction in PyParseClass's own one-line branch.

Recorded because the reasoning error is the interesting part: "an empty body has no members, so the member scanners cannot be affected" ignored that a SCANNER can be wrong about where the body ends, not just what is in it.

Verified

test/test_nilpy_empty_exception_subclass.npy (+ .expected, wired into make test-nilpy), byte-identical to CPython: bare raise E() caught by its own name and by except Exception; with a message; two sibling empty subclasses told apart by their except clauses; a subclass with __init__; and the one-line class G(Exception): pass spelling, which is the case that exercises the span scanner.

Uncaught raise E() now reports Unhandled exception: E instead of exiting 0 in silence.

gate.sh quick GREEN, self-host fixedpoint byte-identical.

Log