A lambda in a range comprehension captures the counter past the end
g = [lambda: k for k in range(3)]
print([f() for f in g]) # CPython [2, 2, 2] pxx [3, 3, 3]
Silent. Found 2026-08-15 by a CPython differential sweep of closure and scoping
constructs. A LIST source (for k in [10, 20, 30]) and a STR source were
already right, which is what pins it to the range lowering rather than to
closures.
Cause — a reasoned exemption that a capture invalidates
The statement form was fixed for this exact defect
(bug-nilpy-for-range-counter-survives-with-the-wrong-value): the counter is
incremented before the guard is re-tested, so on normal exit it holds the value
that FAILED the test — 3 for range(3), and with a step the gap widens (9 for
range(0, 10, 3), where CPython leaves 9 too only because 9 was the last value
yielded; range(0, 12, 3) would leave 12). The fix copies the counter into the
user's variable at the top of the body, so the variable only ever holds a value
the loop actually yielded.
That fix's own comment exempts comprehensions: "A COMPREHENSION already loops on a hidden name, so its variable cannot leak, and that name IS the counter with nothing outside able to observe its terminal value." True of a later READ, which is what leaking means; false of a CLOSURE built inside the body, which captures the counter itself and reads it after the loop.
The comprehension now takes the same body copy — one extra Int64 local per range
comprehension, and one fewer special case
(devdocs/dev/normalise-dont-special-case.md). Nothing about leaking changes:
the visible name is still the deterministic hidden one, so it neither escapes
nor clobbers an enclosing binding of the same spelling.
Gate
test/test_nilpy_lambda_in_range_comprehension.npy (+.expected, in the
Makefile), byte-identical to CPython: the capture over a range, a list and a
str; the k=k default-argument idiom still pinning per iteration; a genexpr and
a dict comprehension (same lowering); a range with a start and a step; an empty
range; every ordinary comprehension form's values unchanged; and the two
non-leak rows the earlier fix installed. gate.sh quick GREEN. No pin — the
change is frontend-only.