Every caught exception leaks its object — 640k raises cost 105 MB
while i < n:
try:
v = int("notanumber")
except ValueError:
c = c + 1
i = i + 1
RSS grows linearly with the number of exceptions raised and caught. Nothing is reclaimed when the handler completes.
Measured — RSS slope
| program | 40 000 | 160 000 | 640 000 | per raise |
|---|---|---|---|---|
try with no raise (control) |
264 KB | 264 KB | 264 KB | flat |
raise ValueError("x") + catch |
3 472 KB | 12 816 KB | 50 320 KB | ~78 B |
int("notanumber") + catch |
6 928 KB | 26 640 KB | 105 360 KB | ~164 B |
The control is flat, so the try/except machinery itself is fine — it is the
exception OBJECT that survives. The int() row leaks about twice as much,
consistent with the message AnsiString (invalid literal for int() with base 10: 'notanumber') leaking alongside the object.
Why this one matters more than the number suggests
Raising is on a designed-in hot path, and pylib.pas says so at the raise site:
Python's two-argument
int(s, base). RAISES ValueError on a bad parse rather than halting, which is the whole point: a Forth interpreter tries EVERY input word as a number, so a non-numeric token is the ordinary case and a fatal error there would kill the interpreter on its first word.
So the uforth corpus raises once per non-numeric token, by design — and each one
leaks. Any parser, config reader, or try: int(x) except: validation loop has
the same shape. This is not an exotic path.
It also compounds with the recent conversions: [[bug-nilpy-pytypeerror-halts-instead-of-raising]] turned ~20 previously-fatal diagnostics into raises, which is correct, and each of those now allocates an object that is never freed.
Shape of a fix
Exception objects are ordinary class instances (Exception = class ... msg: AnsiString), so they should be reclaimed by the same ARC path everything else
uses — the control row proves that path works for other objects in the same
loop. The question is ownership across the unwind: the handler binds the
instance (except ValueError as e:) and the object must outlive the unwind but
die when the handler scope exits, including when the handler re-raises or
returns.
Check whether the raise path retains without a matching release, and whether an
exception caught WITHOUT an as binding (as in every row above) has any owner
at all — none of the measured cases names the exception, so the leak is not
about the as variable.
Gate
make test-nilpy + self-host byte-identical, plus the RSS-slope table above:
the raise rows must go flat and the control must stay flat. Measure at 40k /
160k / 640k with /usr/bin/time -f %M — the SLOPE is the evidence, a single
run proves nothing. Keep test/test_nilpy_typeerror_is_catchable.npy and the
exceptions test green (the object must still be alive inside the handler).
MOSTLY FIXED — the object's own leak; a separate, smaller leak remains and is filed on its own
Confirmed at the codegen level (no reasoning needed once found): IR_EXC_CLEAR
only zeroed the three exception-bookkeeping BSS globals, never released the
object. The single owning reference the constructor gave the exception (raise
transfers it, does not add a second) had NO owner when the handler had no as
binder — confirmed this is the common/every-measured-row case, exactly as this
ticket already suspected.
Fixed: a hidden release-only temp captures the exception object whenever the
matched handler has no as binder, and PXXObjRelease runs on it right after
the handler body, before IR_EXC_CLEAR. A bound except C as e: is left
alone — e's own scope-exit release is what already frees it, unchanged.
Re-measured per the gate's own instruction (40k/160k/640k, /usr/bin/time -f %M):
| program | pre-fix 640k | post-fix 640k |
|---|---|---|
| control (no raise) | 264 KB | 264 KB (unchanged, already flat) |
raise ValueError() (no message) |
not separately measured before | flat (1056 KB at 40k AND 640k) |
raise ValueError("x") + catch |
~50 MB (from this ticket's original table, scaled) | 20 MB |
int("notanumber") + catch |
105 MB | 75 MB |
The no-message row is now provably flat — the object-lifecycle fix is
complete for that shape. The message-bearing rows improved substantially but
are NOT flat: pinpointed the remainder to the exception's OWN message-string
FIELD, not the object — reproduces with raise ValueError("x") internally,
e = ValueError("x") never raised, and even a plain user class with a
string field, all at the same ~31-32 B/iter slope, while an int-field class is
flat. That is a distinct, more general bug (affects any class with a managed
string field, not just exceptions) and is filed separately as
[[bug-a-nilpy-class-variant-field-string-not-released-on-finalize]] rather
than patched here — it needs its own root-cause measurement (the class-finalize
walker's variant-slot path, per that ticket's notes), not a guess bolted onto
this fix.
Test: test/test_nilpy_exception_no_leak.npy, an RSS-guard at 640k raises (threshold 90 MB — between the fixed ~75 MB and the pre-fix ~105 MB, so a regression back to the old completely-unreleased behavior is caught; the follow-on ticket's own fix will let this threshold tighten later). Gate: make test-nilpy green, self-host fixedpoint, testmgr --tier quick.
This ticket's OWN bug (the object never had an owner when uncaught-by-name) is closed. Leaving it here rather than filing a fresh "exception leak, part 2" ticket would just be organizational churn — the remaining leak belongs to the class/field-finalize investigation, not to the exception-specific mechanism this ticket was about.
Ticket closed (see the follow-on for the remaining, unrelated leak).
Log
- 2026-07-31 — resolved, commit 365d46028.