← board

Quat(...).normalized() leaked the construction -- a parser route trap, not an ownership one

A NilPy construction consumed as a METHOD RECEIVER is never released. One instance per call, forever. Quat(1,0,0,0).normalized() -- the ordinary spelling of fluent vector maths.

Measured 2026-09-14 against pin v410 and against HEAD, CPython oracle 0 for every row:

shape leaked per call
P(1, 2).sum() -- method on a temporary 1 instance
P(1, 2) as a bare statement 1 instance
p = P(1, 2) 0
P(1, 2).a -- ATTRIBUTE on a temporary 0
f(P(1, 2)) -- argument 0
[P(), P()] / {k: P()} 0
return P(1, 2) 0
Q(...) * s -- operand of an operator 0

So an assignment, an argument bind, a container store, a return and an operand all drop the construction's rc=1 correctly. A METHOD RECEIVER and a DISCARDED STATEMENT do not.

THE MECHANISM IS ALREADY DOCUMENTED, INCLUDING THE GAP

compiler/defs.inc (SymIsCtorResultTemp): the conduit local "holds the construction's rc=1 for whoever consumes the expression and never releases it". compiler/ir.inc's arg-spill arm owns that rc=1 for every ARGUMENT position and excludes param 0, and its own comment names the gap:

Param 0 (a method receiver) is excluded: the for-in and method desugars route the SAME construction subtree through receiver position with their own binding plumbing, and spilling it there double-consumed the construction (test_nilpy_forin SIGSEGV). A bare [...].method() receiver still leaks -- rare shape.

It is not a rare shape. lekkerzeilen has three sites, all Quat, all on the per-body physics path: math3d.py:168 and :201 (Quat(...).normalized()) and vessel.py:471 (Quat(...).slerp(...)).

BUT THEY ARE NOT THIS DEMO'S LEAK, AND THAT WAS MEASURED AFTER THE FIX. The first version of this ticket said the shape was the largest single producer in the demo's leaked-object histogram -- 880,508 unfreed 40-byte objects in 300 s, 73% of the total, 40 bytes being exactly Quat's instance size. That was an ATTRIBUTION from a size match, never an isolation. lekkerzeilen-c8 priced the fix directly, interleaved, pre-registered at >=30% confirm / <10% refute:

binary mean leak
lz7 1641.8 kB/s with the operator fix, without this one
lz8 1635.0 kB/s with both

0.41% -- noise. The three receiver sites are not a meaningful share. (For contrast, the operator fix 14b184eb7 measured on the same harness at 24.4%, so the harness does move when something real lands.) Landing this anyway: it is a real one-per-call object leak, correct for the language, and "it does not help one demo" is not an argument against correctness -- it is only an argument against expecting that demo's slope to move.

A CENSUS WARNING, because it nearly cost this ticket its site list: a LINE-BASED grep for Ctor(...).method( reports zero sites here. All three constructions span multiple lines. Match against whole-file text with paren balancing, or the population reads as empty.

A module-level loop leaks NOTHING and a function-scope loop leaks every time. The two lowerings differ. Measure inside a def or you will measure zero and conclude it is fixed.

THE OBVIOUS FIX PASSES 963 TESTS AND KILLS THE DEMO IN 3 SECONDS

Extending the arg-spill arm to param 0 for the user-class-NEW shape only (patch parked at the end of this ticket) measures perfectly:

and then lekkerzeilen SIGSEGVs after 3.0 s of frame loop, deterministically.

The 2x2 that establishes it, one compiler axis and one builtin axis, all --open-water, back to back on a quiet box:

compiler builtin at e4c72bd15 builtin at 17e5731a7
HEAD 44a0066 survives 87.2 s (to timeout) survives 87.2 s
HEAD + this patch SIGSEGV, in-loop 3.00 s SIGSEGV, in-loop 2.99 s

The builtin is not the variable. The patch is.

WHERE IT DIES

PyCallablePartsP+0x52 (pylib.pas), reading p^.VType -- i.e. the PPyVarRec the caller handed it is itself a dangling pointer. The compiled bytes of that function are byte-identical in the crashing and surviving binaries, so it is a LIFETIME bug and not a miscompile. A callable variant is a {code, recv} pair block; the spill is releasing something a live callable still points at.

WHAT THIS SAYS ABOUT THE LANE GATE

963 fixtures green, one real program dead in three seconds. make test-nilpy does not cover object lifetime across a long-running frame loop, and nothing in it holds a bound method across statements the way an application does. Do not take a green Track N tier as evidence for a refcounting change. Build the demo and run it -- --open-water reaches the frame loop in ~3 s.

WHAT IT ACTUALLY WAS: ONE CONSTRUCTION, TWO PARSE ROUTES, ONE OF THEM BALANCED

Fixed 2026-09-15, and the cause is a route trap in the PARSER, one level above where everyone (me included) was looking.

C(...) followed by . leaves the construction arm at pyparser.inc's PyClassCreateExpr call site to be "picked up downstream" -- the arm's own comment says so, and it handled only the [ spelling. Downstream there are two routes and they disagree:

Confirmed from the compiler's own dumps rather than read out of the source: PXXDBG=a.ast shows the construction hoisted under an AN_ASSIGN for .a and sitting inline as arg 0 for .m(), and PXXDBG=a.ir shows the attribute form emitting a release-old/store-new into __py_recv_3 that the method form does not emit at all.

The fix is to hoist at the construction arm -- one PyEvalOnce when the next token is . and the node is a user-class construction. The receiver then lives in an ARC-eligible local for the rest of the function.

THE DOOR MATTERS, AND THE FIRST ONE I TRIED WAS WRONG

The tempting one-character version is to widen that arm's existing CurTok.Kind = tkLBrack test to in [tkLBrack, tkDot], so . goes through PyParseClassRecordSelectors like [ does. It fixes the leak, the output is correct, and it breaks test_nilpy_ctor_suffix_defaults: routing . there also changes which parser reads the ARGUMENT LIST, and that one cannot resolve a bare function name passed as a value -- A().analyze(["a", "b"], notes) fails with undefined variable (notes).

Hoisting in place leaves the parse path exactly as it was and changes only the NODE the downstream path receives, from the construction to an ident bound to it. Same fix, different door, and only one of them is green.

WHY THE ARG-SPILL PATCH SEGFAULTED, WHICH IS THE REUSABLE PART

Scope-exit ownership is what makes the hoist safe, and it is exactly what the arg spill could not give. The spill releases the receiver at call end; a bound callable is a {code, recv} pair that outlives the call that made it, so PyCallablePartsP was later handed a PPyVarRec whose target had been freed. The compiled bytes of that function were byte-identical in the crashing and surviving binaries, which is what said "lifetime, not miscompile" and is what pointed at the parser in the end.

Kept below, unlanded, because it is the patch the next person will also think of first.

THE PARKED PATCH -- DO NOT LAND THIS ONE

compiler/ir.inc, the NilPy construction-in-argument spill: replace the (pathIdx >= 1) guard on the construction arm with one that also admits pathIdx 0 when the arg is a user-class NEW:

       ( { user-class NEW allowed at pathIdx 0 too }
         (((Integer(ASTIVal[argAST]) = -Ord(tkGetMem)) and
           (Integer(ASTRight[argAST]) >= REC_UCLASS_BASE)))
         or ((pathIdx >= 1) and (IntToTypeKind(ASTTk[argAST]) = tyClass)) )

Do not land it as-is. It is recorded so the next session does not spend the evening re-deriving a change that measures clean everywhere except in a running program.

REPRO FOR THE LEAK ITSELF

test/test_nilpy_a_user_object_does_not_leak_because_of_how_its_value_is_consumed.npy carries a ctor_recv row that asserts this leak DELIBERATELY, as a positive control. When this ticket is fixed that row goes red and the fixture says, in its own output, to flip it.

Log