A lambda returning a captured heap value yields None
- Type: bug — Track N (Nil-Python frontend).
- Filed: 2026-08-30 by frankB, found when it corrupted a shim probe during
the
mimic_differential sweep. - Measured against pin v395 (
aa78a7faf63a). CPython runs every line below and prints the value; we printNone.
Repro — three lines
lv = [1]
f = lambda: lv
print(repr(f())) # pxx: None CPython: [1]
The boundary, measured
| body | pxx v395 | CPython |
|---|---|---|
lambda: [1] — list literal |
[1] |
[1] |
lambda: lv — list variable |
None |
[1] |
lambda: {'k':1} literal |
{'k': 1} |
same |
lambda: dv dict variable |
None |
{'k': 1} |
lambda: (1,) literal |
(1,) |
same |
lambda: tv tuple variable |
None |
(1,) |
lambda: b'a' literal |
b'a' |
same |
lambda: bv bytes variable |
None |
b'a' |
lambda: sv str variable |
'a' |
'a' |
lambda: iv int variable |
1 |
1 |
lambda x: x — parameter passthrough, list arg |
[1] |
[1] |
lambda: bv + b'x' — expression over captured bytes |
None |
b'ax' |
lambda: len(lv) — captured value consumed |
2 |
2 |
nested def returning the same captured lv |
[1, 2] |
[1, 2] |
Three facts pin it:
- It is the RETURN that is lost, not the capture.
len(lv)inside the lambda is correct, so the captured object is reachable and intact; only handing it back as the lambda's value producesNone. - It is lambda-specific. A nested
defwith a byte-identical body works, so this is not closure machinery in general. - It is heap-typed values only.
strandintsurvive; list, dict, tuple and bytes do not. A literal body survives, which suggests the literal is materialised in the lambda's own frame while a captured reference is not retained across the return.
Local and global captures both fail, so it is not about which scope the name comes from.
Why prio 60 despite Track N being deprioritised
Not a missing feature and not a diagnostic — a wrong value, silently, for a
shape that is ordinary Python: key=lambda p: (p.a, p.b), lambda: [] as a
default factory, lambda: cached_dict. Nothing raises and nothing warns; the
None propagates to whatever consumes it.
It also makes measuring instruments lie, which is how it was found. A
table-driven probe of mimic_codecs wrapped each call in a lambda, and the
bytes-valued rows came back None — a plausible, self-consistent, entirely
wrong story ("codecs.encode returns None for every encoding"). Re-probing with
def showed the truth: encode is fine for utf-8 and segfaults for ascii
and latin-1 ([[bug-b-codecs-encode-segfaults-for-every-encoding-except-utf-8]]).
A broken instrument that returns something believable is the exact failure the
debugging playbook is organised against, so the blast radius here includes every
future lambda-based probe.
Until this closes: use def, not lambda, in NilPy probes.
Note for whoever takes it
sorted(..., key=lambda p: (p[0], p[1])) currently gives the RIGHT answer,
because the tuple is consumed by the sort rather than returned to Python — case
13 above. So a naive "does sorting still work?" check will pass and is not
evidence the bug is absent. Test by returning the value.
2026-09-25 (frankH): a second body shape, a CONSTRUCTOR call
class Q: def m(self, a): return a then (lambda: Q())().m(3) raises
AttributeError: 'NoneType' object has no attribute 'm'; CPython prints 3.
Measured with two builds of 5573bd550d plus uncommitted work (with and without
the trailing-comma fix), which agree; the pinned compiler (v435, sha of stable_linux_amd64/default/pinned
on 2026-09-25) fails the same way. v435 already carries a457570295, so this
does not date the bug; nothing older was run. Same symptom as above with nothing captured,
which suggests the cause is the object-typed lambda RESULT and not the capture.
Closed (2026-10-02, frankuser)
Mechanism: PyCompileLambdaBody returned a class-typed value only when
PyLambdaResultIsOwnedTemp called it fresh. Fresh meant one of: the
lambda's own hoist temp, a named pylib container constructor, or a user def.
Anything else was evaluated and dropped, and $pyresult stayed None. A
captured name is a global or an enclosing local, so it never passed. The
gate dates from lambda s: log.append(s) taking a captured log to refcount
0 (4 retains, 5 releases).
That over-release no longer reproduces. With the gate removed, the following
run 2000 passes under -dPXX_HEAP_DEBUG with output identical to CPython:
log.append through a lambda, a user method returning self, and captured,
field, subscript, dict-value, a + b, a * 2 and slice bodies. Census is
flat in each case. So the gate, its three helpers (PyLambdaResultIsOwnedTemp,
PyProcIsPyDefined, PyProcIsFreshContainerCtor) and its carve-outs are gone,
and a lambda body is now an AN_EXIT like a def's return.
The pin got all of these wrong: (lambda: lv)(), (lambda: a + b)(),
(lambda: p.kids)() and (lambda: xs[1]).v were None or raised.
Fixtures, all with census + keep control + HEAP_DEBUG diff:
- test_nilpy_a_lambda_returning_a_captured_heap_value_returns_it.npy (also an i386 leg)
- test_nilpy_a_lambda_returns_a_borrowed_or_computed_object.npy
- test_nilpy_a_lambda_that_constructs_a_class_instance_returns_it.npy
Not fixed here: a DISCARDED a + b or bv + b"x" (a bare expression
statement) still leaks two objects per execution. pylist_concat and
pybytes_concat are classified borrowed because r.put(...) on their result
local counts as an escape (LocalUseEscapes treats any method call on the
local as one). Assigned forms are flat. Filed as
bug-n-a-discarded-list-or-bytes-concat-leaks-because-its-builder-reads-as-borrowed.