A nonlocal capture in an ESCAPING closure fails to parse at the call site
def counter():
n = 0
def bump():
nonlocal n
n += 1
return n
return bump
bp = counter()
print(bp()) # pascal26:9: error: unexpected token (near: bp)
# CPython: 1
That was true when this was filed. It is NOT true any more — see the 2026-08-06 re-measurement below: it now COMPILES and SEGFAULTS. The paragraph that follows is kept as written, because the reasoning it records is still correct and is what predicted the crash.
A compile error, not a wrong value — so this is the good failure mode, and it is filed separately from [[bug-nilpy-escaping-closure-captures-unbound-unless-arity-is-one]] (now fixed), which was the silent one.
Boundary
The same closure WITHOUT nonlocal parses and now runs correctly at every
arity. Called while the parent frame is still alive, the nonlocal form also
works — return bump() inside counter yields 1. It is specifically
nonlocal + escaping that fails, and it fails at the CALL site (bp()), not at
the def.
Likely cause
nonlocal x makes that capture a BY-REF trailing parameter, so the write lands
in the enclosing frame (pyparser.inc, PyParseDefHeader:
if PyBodyDeclaresNonlocal(...) then Procs[procIdx].Params[nOwn + j].IsRef := True).
The value-closure path has no way to bind a by-ref parameter — pyboundfn_bind
stores a word, pyboundfn_bind_var a heap Variant slot, and the new
pyboundfn_bind_obj a retained object, none of which is "the address of the
enclosing local". Something in that mismatch leaves the resulting value typed
such that bp() no longer parses as a call.
Worth confirming with PXXDBG=n.caps and by dumping the type the name bp
ends up with, rather than assuming — the parse error is a symptom several
layers from whatever the real cause is.
Note on semantics
Even once it parses, nonlocal through an escaped closure needs the captured
cell to OUTLIVE the enclosing frame — CPython gives every closure over the same
frame a shared cell, so two calls to bump() return 1 then 2, and two separate
counter() results have independent cells. Binding the enclosing local's
ADDRESS would dangle exactly the way the class capture did before
pyboundfn_bind_obj. The fix probably wants a heap cell per captured
nonlocal, bound like pyboundfn_bind_var already does, with the body's
by-ref parameter pointed at it.
Gate
make test-nilpy + self-host byte-identical, plus the counter above and a
two-instance case (c1 = counter(); c2 = counter()) diffed against CPython.
The non-nonlocal shapes are already covered by
test/test_nilpy_escaping_closure.npy and must stay green.
2026-08-06 — RE-MEASURED: it no longer fails to parse, it SEGFAULTS
Found again while bughunting (a textbook counter closure), and the failure mode
has changed since this was filed. Measured on a self-hosted binary at
54fbd2754 and identically on pinned, so this is not a regression of
today's work — something between the filing and now made it parse, and what is
left is precisely the dangling write the "Note on semantics" above predicted.
def mk():
c = 0
def bump():
nonlocal c
c = 5 # <- SIGSEGV happens HERE, on the write
return bump
f = mk()
print("before") # prints
f() # Segmentation fault (exit 139)
print("after") # never reached
The crash is on the write, not on the call or the bind: print("before")
runs, and a body that only assigns (no read, no return) is enough to kill it.
Boundary, re-measured
| shape | verdict |
|---|---|
escaping closure READING a local (return c) |
works |
| escaping closure reading a PARAMETER | works |
escaping closure mutating a captured LIST (c[0] += 1) — the usual Python workaround |
works |
nonlocal called while the parent frame is alive (return bump()) |
works |
nonlocal write through an ESCAPED closure |
SIGSEGV |
So the by-value capture path is fine; only the by-ref nonlocal cell is wrong,
exactly as the note above reasoned. The read-only cases working is what makes
this narrow enough to fix confidently.
Why the priority went 45 -> 65
The ticket was rated as a compile error — "the good failure mode". It is now a hard crash on ordinary Python, with no diagnostic, at a place several frames from the code that caused it. That is a materially worse thing to leave in the backlog, and the rating should reflect what it does today rather than what it did when filed.
The recommended fix is unchanged and is the one this ticket already reasoned
its way to: a heap cell per captured nonlocal, bound the way
pyboundfn_bind_var already binds a heap Variant slot, with the body's by-ref
parameter pointed at the cell instead of at the enclosing frame's local. That
also gives CPython's sharing semantics for free (two bump() calls on one
closure see one cell; two counter() results get independent cells), which the
Gate section below already asks for.
Title
The slug still says "fails to parse", which is now wrong. Left alone on purpose:
renaming it would break the [[...]] links pointing here and rewrite the record
of what was actually observed when it was filed. The summary line and this
section carry the correct behaviour.
Log
- 2026-08-06 — resolved, commit 1602523d0.