← board

A lambda returning a captured heap value yields None

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:

  1. 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 produces None.
  2. It is lambda-specific. A nested def with a byte-identical body works, so this is not closure machinery in general.
  3. It is heap-typed values only. str and int survive; 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:

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.