← board

A Nil Python managed local in an unwound frame is never released

What was measured

-dPXX_ALLOC_CENSUS, slope of live between N=2000 and N=8000 raises, one binary (ca07aca86948, commit 999c76dc6):

probe per raise
raise inline in the loop, dynamic message 0.000
raise in a callee holding NO managed local 0.000
callee, 1 string local, CONSTANT message 0.900
callee, 2 string locals 1.854
callee, 3 string locals 3.022
callee, 1 list local 1.897
callee, 1 string local, NO raise 0.000

One block per managed heap object live in the unwound frame. The 0.9/1.85/3.02 rather than 1/2/3 is the census's geometric threshold, not a partial leak — the last line it prints is ~0.94 of the true total, and the RATIOS are 1 : 2 : 3.

The first two rows are what name the trigger. Row 3 raises a CONSTANT message and still leaks, so it is not "the message was built in this frame"; row 2 has a callee and a raise and does not leak, so it is not the exception object. It is what the frame HOLDS at raise time, and only when the frame is unwound past.

The Pascal equivalent of every row is flat, which is what pointed at the cause.

The cause

A proc that can be unwound past needs a landing pad or its managed locals are never released: neither its own epilogue nor the eventual handler's frame runs. The machinery for that is entirely shared and was entirely complete — ProcCleanupFrameWanted / ProcCleanupFrameLateArmed in defs.inc, the late gate inside CompileAST (ir_codegen.inc), EmitProcCleanupLandingPadForTarget next to it, six backends' IR_EXC_ENTER behind TargetHasProcCleanupFrame.

grep -rn 'ProcCleanupFrameWanted := True' compiler/ returned two hits, both in pasparser_proc.inc. Nothing else in the compiler ever asked for a pad, so no .npy def, method, lambda or comprehension body has ever had one.

This is the shape [[the-substrate-is-ast-and-ir-not-the-parser]] predicts and the shape [[ir-as-substrate]] is meant to prevent: the capability lives in the substrate, the REQUEST for it lives per-frontend, and a frontend that never learned to ask is silently without it. Nothing errors — an unwind leak prints nothing and corrupts nothing.

The fix

PyArmCleanupFrame / PyTakeCleanupPad in pyparser.inc, called at the two body sites (PyParseDef, PyParseMethod). Only the LATE gate is used: the Pascal frontend also asks an early one from the prologue, and that one is structurally unable to see the hidden temps lowering mints, so the late gate is a superset of it. ProcExceptionCleanupFrameActive is stacked, because a nested def is drained by re-entering PyParseDef.

The C, Rust and Zig frontends have the same gap by the same grep. Not measured here and not claimed — filed as [[bug-a-only-the-pascal-frontend-ever-asks-for-an-unwind-landing-pad]].

The guard

test/test_nilpy_managed_local_in_unwound_frame.npy, wired in test-nilpy with BOTH expect_same and assert_no_leak, because only one of them can fail:

pin v403 (pre-fix) HEAD
printed output total=66890 / msg=fixed identical
census live at 102900 allocs 18509 4

An expect_same row alone certifies the leak as correct. The value row still earns its second: the pad releases and then RE-RAISES, so msg=fixed is what says the re-raise still carries the original exception.

Not this

Log

Targets

The pad now emits on all six register backends for .npy bodies (TargetHasProcCleanupFrame: x86-64, i386, arm32, aarch64, riscv32, xtensa).

Measured on every target where a .npy program builds at all, control from pin v403 on the same source:

target pin v403 live HEAD live allocs
x86-64 18509 4 102900
i386 27146 7 115763
aarch64 18509 4 102900
arm32 18509 4 102900

All four print total=66890 / msg=fixed on both binaries. The three cross rows are wired in test-i386, test-aarch64 and test-arm32 next to the existing .npy census rows.

riscv32 and xtensa do not build a .npy program at all, on the pin AND at HEAD: a heap arena needs mmap, which this profile has not, already filed as [[bug-a-nilpy-on-cross-targets-four-remaining-walls]]. So the pad emits there and is unexercised — an absence, said out loud rather than left as a gap in the table.

wasm32 is a no-op and was never affected. That predicate means "goes through the PARSE-TIME hook", not "has no cleanup frame" — ir_codegen_wasm32.inc:6611 asks ExceptionUsed and ProcHasManagedLocalCleanup(...) itself at codegen, frontend-agnostically, so NilPy on wasm32 already had a pad. Reading the predicate the other way would have been the expensive kind of wrong, and its own comment says so.

There is no wasm32 row for the test regardless: it fails to compile for that target on the pin AND at HEAD, identically — undefined variable (SYS_openat) in lib/rtl/platform/posix/platform_backend.pas. That has an owner and a design fork of its own, filed by frankA the same night: [[bug-n-the-nilpy-pal-issues-raw-syscalls-so-every-file-body-traps-on-wasm32]] (compiler/builtin/pypal.pas carries no wasm/wasi conditional at all, while lib/rtl/platform/wasi exists and the Pascal RTL uses it). The wasm32 row for this test becomes available when that closes, not before.

And the thing that made me suspect a stale ticket was my own misreading. I flagged bug-a-managed-locals-leak-on-an-unwind-on-wasm32-and-xtensa as looking stale on both halves of its title. It is in done/ and has been: wasm32 got its frame in 83018bb5e, xtensa's half in af5d2b534. A done ticket keeps the title of the BUG, never of the current state — that title is a correct description of what was wrong and stays so forever, and reading it as a claim about HEAD is the same error as reading working/ as a lock. Locate a ticket's FOLDER before flagging its title; the summary opens with DONE (sha) and says the rest.

FOLLOW-UP, same day: the first fix segfaulted on a re-raise

4edf60ff9 landed the pad and introduced a use-after-free, live on origin/master until the commit below. The pad releases a frame's managed locals, and a NilPy except V as e: binder is one — but on the unwind path it may hold the object that is IN FLIGHT. Releasing it freed the exception under the outer handler.

shape pin v403 4edf60ff9 fixed
raise (bare re-raise), caught outside t=690 SIGSEGV t=690
raise e, caught outside t=690 SIGSEGV t=690
raise KeyError(..) from a handler t=1090 t=1090 t=1090
escape with no matching handler t=690 t=690 t=690

Row three is what says the rule is about being IN FLIGHT and not about being a binder: that handler's binder holds the OLD object, nothing else references it, and releasing it there was and stays correct.

The full NilPy tier was green through all of it — 803 rows, none of which re-raises from a NilPy handler. The gate did not miss a red; the population had no such row. test_nilpy_reraise_from_a_handler_does_not_free_the_in_flight_object.npy is that row now, wired native plus i386/aarch64/arm32.

The fix is an ownership rule stated once, SymSkipScopeExitRelease in symtab.inc: a binder's reference is BORROWED from the in-flight exception and becomes the binder's own only when the handler completes normally — which is exactly what the pad running means did not happen. InUnwindCleanupPad scopes it to the pad, so the ordinary epilogue is untouched. One predicate rather than a condition in six emitter copies, for the reason StacklessPersistentSlotSym already exists in that shape.

It is deliberately CONSERVATIVE and the residual is filed rather than left to be rediscovered: [[bug-nilpy-a-handler-binder-unwound-past-by-a-different-exception-still-leaks]].