← board

nonlocal x — the write never reaches the enclosing function

def outer():
    y = 1.0
    top = 800.0

    def headers():
        nonlocal y
        y = top
        y -= 9.0

    headers()
    return y

print(outer())      # CPython: 791.0    pxx: 1.0

Silent: it compiles, runs, and returns the value the enclosing function started with. nonlocal is ACCEPTED (no diagnostic) and then ignored.

Cause, as far as it is measured

A nested def's captures are passed as trailing parameters BY VALUE (feature-nilpy-nested-defs) — "Python reads a closed-over name at call time, so its value at the call site is the right one". That is true for READS and wrong for WRITES: a nonlocal assignment updates the callee's copy, which is discarded at return.

Why it matters here

songformatter's page layout is built this way — printHeaders() does nonlocal y; y = pagetop and then decrements y down the page, and every later line places text at that y. With the write lost, every header lands at the value y happened to hold before the call. It is not what crashes the render ([[bug-nilpy-songformatter-first-render-walls]]), but it would misplace everything once the crash is fixed.

Shape of a fix

The capture would have to be by REFERENCE for names the nested def declares nonlocal (the others can stay by value). That is a per-name decision the capture scan already has the information for: it reads the body, so it can see which names are under a nonlocal statement and pass those as var.

An honest interim, if the fix is deferred: REJECT nonlocal with a diagnostic rather than accepting it and dropping the write.

Gate

make test-nilpy plus a .npy covering read-only capture, a nonlocal write, and a nonlocal write in a def called twice, diffed against CPython.

Fixed 2026-07-30

A capture the nested body declares nonlocal is registered as a by-REFERENCE trailing parameter. The flag lives in ProcParamCapRef because the def's body is parsed later and that parse rebuilds Params[].IsRef from the header types alone (variant = by-ref, everything else by value), which wiped a flag set only on the Proc record.

Fixing it exposed a second, older bug: PyEmitParamSpills chose the spill width from the parameter's own type, so a by-ref INT param stored the incoming 64-bit address with mov [rbp+off], eax — truncated to 32 bits, and the first write through it faulted. By-ref params now always spill 8 bytes.

Gated by test/test_nilpy_selfassigned_comprehension.npy.

Log