A discarded list or bytes concat leaks because its builder reads as borrowed
Measured 2026-10-02 (frankuser), x86-64, census at N=1 and N=1001:
| statement in a loop | live |
|---|---|
y = bv + b"z" |
11 -> 11 |
bv + b"x" |
13 -> 2013 |
y = a + b |
11 -> 11 |
a + b |
13 -> 2013 |
PXXDBG=p.fresh:* reports pylist_concat FALSE arg=-1 and
pybytes_concat FALSE arg=-1. Both bodies return a local built by
Create and filled with r.put(i, ...). The rule in LocalUseEscapes is
deliberately conservative: a method call on the local may keep Self.
Shape of a fix
Per-method "Self does not escape": run LocalUseEscapes over a method's own
body with Self as the local, and record the verdict per proc. Methods
compile before their callers within pylib, so a later
ClassifyProcResultFresh can treat r.m(...) as harmless when m's verdict is
"does not escape". put, append, at and the like qualify. Anything with a
cast or a store of Self stays an escape. This widens what the discard binder
releases, so it needs a tier and a HEAP_DEBUG sweep, not just this repro.
Low priority: a bare a + b statement is rare in real code. The lambda
return of the same value, which used to give None, was fixed separately
(bug-n-a-lambda-returning-a-captured-heap-value-yields-none).
Fixed 2026-10-02
Generalised past the shape above: ProcParamStays is a per-routine bitmask of
class-typed VALUE parameters (Self is parameter 0) whose body never lets them
out, judged by LocalUseEscapes with Result := p / Exit(p) counted as
escapes. A STATIC call handed a local bare in a slot whose bit is set no
longer counts as an escape of that local; a virtual call still does. Two
supporting changes: p = nil / p <> q is not an escape (PySeqIndexError
compared its list to nil), and TPyList.append no longer routes through
append_self, which returns Self -- both now call PyListAppendRaw.
Verdicts that changed to fresh (x86-64, a program importing the common modules): pylist_concat/repeat/slice/slice_step, pybytes_concat/repeat, pyenumerate(2), pyzip, pymap_int/float/str, pysys_argv, PySelectReady, pyselect_select, TPyDict.itemlist/most_common, getaddrinfo, socket.read/ readline, DecodeAt, struct unpack/unpack_from. Each builds and returns a new object.
test/test_nilpy_a_discarded_concat_slice_or_repeat_is_released.npy: 75373 live after 5000 passes at v452, 59 after. HEAP_DEBUG and i386 rows diff against CPython.