← board

A name assigned from a CALL, then reused as a for-loop target, SEGFAULTS

D = {"a": "1"}

def merged(s):
    m = dict(D)
    m.update(s)
    return m

cfg = {"w": {"h": "1"}}

for name in sorted(cfg.keys()):
    sec = merged(cfg[name])       # `sec` bound from a CALL
    print(sec["h"])

allkeys = set()
for sec in cfg.values():          # ...and reused as a LOOP TARGET
    allkeys |= set(sec.keys())
print(sorted(allkeys))            # SIGSEGV

Narrowed — three one-line variations settle it

change result
as written SIGSEGV
sec = cfg[name] instead of sec = merged(...) works
rename the first binding to other works

So it needs BOTH: the name bound from a function CALL, and the same name later used as a for-loop target. Either alone is fine.

Why it is worth 50

Reusing a short name like sec, row or item for the same KIND of thing in two loops is ordinary style, and the failure is a crash with no diagnostic. It also survived two other fixes in the same program, so it is independent of them.

Where to look

The first binding types sec from merged's return (a TPyDict class); the for-loop target then rebinds it to a variant element. That is the same "one name, two representations" family as [[bug-nilpy-local-reassigned-across-classes-keeps-one-static-class]] (fixed 2026-08-08, unrelated CLASSES widen to a variant) — but here the two are not unrelated classes, so that widening does not fire and the loop lowering and the call result disagree about the slot.

Start with PXXDBG=n.locals on the repro to see what sec is typed as, and compare against the working sec = cfg[name] variant, which is the control that removes the call from the picture.

Gate

.npy diffed against CPython: the repro; the same shape with the loop first and the call-binding second; a def-local version as well as module scope; and the two one-line variations above as controls, so a fix cannot pass by making only the reported ordering work.

2026-08-09 — measured, not started (claude-AN)

It is PRE-EXISTING, and the control says so

Reproduced identically on stable_linux_amd64/default/pinned, so it is not a regression from any of tonight's NilPy work. Worth stating because it was found in the same program as two bugs that WERE fixed tonight, and "the last change broke it" is the cheap wrong answer.

It is MEMORY CORRUPTION, not a wrong branch

The manifestation is layout-dependent, which is the tell:

run result
plain SIGSEGV
under gdb no crash, prints []
with -dPXX_OBJTRACE no crash, prints []
at HEAD after the ` =` fix landed

So the fix for bug-nilpy-set-augmented-union-does-nothing did not change this bug at all — it changed which SYMPTOM you see, because |= used to do nothing and now reads a corrupted sec.

-dPXX_OBJTRACE names the shape: a premature free

The tail of the trace is

objtrace A 0x...e78 1     <- allocated, refcount 1
objtrace r 0x...e78 0     <- released to ZERO
objtrace F 0x...e78 0     <- freed
keys []

An object is allocated and immediately released to 0 and freed, and the empty result follows from reading it afterwards. So this is an ARC/lifetime fault — someone releases a reference they only borrowed — and NOT a typing or dispatch bug, which is what the "one name, two representations" guess in the section above assumed.

That also means the fix must be verified with -dPXX_OBJTRACE balance rather than by a passing test: a lifetime bug that stops crashing has not necessarily stopped being wrong.

Where that leaves the ticket

The narrowing above still holds — it needs the name bound from a CALL and reused as a loop target — but the mechanism is a release of a borrowed reference somewhere in that pair, not a slot-type disagreement. Start from the objtrace output on the six-line repro and identify which allocation 0x...e78 is (the sec binding, the keys() list, or the set() temp) by swapping the capture, per [[project_variant_object_tag_list_lives_in_four_places]]'s note that OBJTRACE can be blind to the object you assume it is showing.

Not started: an ARC lifetime fix at the tail of a long session is how a double-free ships, which is the same call made for [[bug-nilpy-lambda-returning-a-call-result-container-yields-none]] earlier tonight.

RESOLVED (2026-08-09) — same root as a broader bug, fixed there

This ticket's exact repro now matches CPython (1 / ['h'], exit 0).

It was not what this ticket says. Reducing further showed the CALL was incidental: the same failure appears with a plain dict LITERAL as the earlier binding, and what actually matters is a module-level name rebound INSIDE a block by a shape the pre-pass does not recognise. Filed and fixed as [[bug-nilpy-module-name-reassigned-from-a-subscript-in-a-block-reads-garbage]], which covers the subscript RHS, the .values()/.keys() loop target and the list()/sorted()/reversed() wrappers.

The earlier diagnosis on this ticket — "an ARC lifetime fault, a borrowed reference released" — was wrong, and worth recording as a caution. The -dPXX_OBJTRACE trace really did show alloc → release-to-0 → free, which reads exactly like a premature free. It was the CONSEQUENCE: a variant written into a class-shaped slot, then released as if it were the class it was declared to be. An ARC instrument shows you the release; it does not tell you whether the type that led there was right. The reduction (vary the shape until the call drops out) is what found it, not the tracer.

Closed as a duplicate of the broader ticket rather than resolved on its own, so the narrowing above stays findable.

Log