← board

A call whose managed-string result is DISCARDED leaks it

def flabel(v: int) -> str:
    return str(v)

while i < n:
    flabel(7)          # result dropped
    i = i + 1

RSS slope, 20k vs 320k iterations: 952 KB -> 10296 KB. Binding the same result to a name is flat (s = flabel(7)), and so is consuming it (len(flabel(7))). Only the DROPPED result leaks — the callee hands back a handle at +1 and nothing ever releases it, because there is no store to carry the ownership.

Affects plain defs and methods alike, so it is not the method-vs-def divergence [[bug-nilpy-method-returning-a-fresh-string-leaks]] fixed (that one was a double RETAIN at the store; this one is a missing RELEASE where there is no store). Both were measured in the same session; this one was left because it is a different mechanism in a different place.

~32 bytes an iteration, silent. The shape is ordinary: a method called for its side effect that happens to return a string (buf.append_line(...) returning the new text, a logger returning what it logged).

Recon 2026-07-30 — why the obvious hook does not work

Picked up and put back down deliberately; recording the dead ends so the next attempt does not re-walk them.

Attempt 2026-07-30 — IRDiscardValue is NOT the hook (measured, reverted)

Tried and REVERTED, so the next attempt does not repeat it. The idea was sound and needs no new IR op: store the discarded result into a hidden temp belonging to that SITE, because the managed store releases the slot's previous value before taking the new one — so a loop keeps at most one handle alive and the scope-exit release frees the last.

The hook was wrong. IRDiscardValue (ir.inc), which the AN_BLOCK statement-list arm calls for every item, looked like the place; a branch there for PyProgramMode + tyAnsiString + a call kind, placed BEFORE IRMarkStatementNode and returning, compiled and self-hosted byte-identical — and changed nothing. The RSS slope was identical (952 KB -> 10296 KB), and PXXDBG=a.ir:<routine> still shows the call with ival=1, i.e. something ELSE marks a NilPy statement call as a statement.

So the first job for the next attempt is to find who sets IRIVal := 1 on a NilPy statement-level call — it is not IRDiscardValue. Put the store there instead, and keep the "before the mark, then return" discipline: a store is itself a statement root that drags its operand tree in, so marking the call as a statement TOO would emit it twice.

Where to look

The statement-expression path: when an expression statement's value is a managed type and is not stored anywhere, it needs a release after evaluation — the same scope-exit treatment a hidden temp gets. IRIVal[node] := 1 marks a call emitted for effect (see IRAppendCall's callers); that marker is the natural place to decide the result needs dropping.

Check the same shape for a discarded OBJECT result and a discarded variant while there — an object result carries the callee's return-retain ([[bug-nilpy-returning-a-construction-leaks-one-ref]]), so it should leak identically.

Gate

make test-nilpy + self-host byte-identical, plus RSS-slope pairs for a discarded string result from a def, from a method, and a discarded object result — all flat.

Log