← board

A managed local that survives a yield is released at every yield (cross only)

The repro

def gen(k):
    acc = ""
    n = 0
    i = 0
    while i < k:
        acc = acc + "x"
        n = n + i * 1000000007
        yield acc + "|" + str(len(acc)) + "|" + str(n % 97)
        i = i + 1

out = []
for v in gen(6):
    out.append(v)
print(out)
target output
CPython ['x|1|0', 'xx|2|41', 'xxx|3|26', ...]
x86-64 identical to CPython
i386 SIGSEGV
arm32 SIGSEGV
aarch64 ['x|1|0', '\x00\x00|3|41', '\x00\x00|3|26', ...] — wrong value, no crash

Compiles fine on all four; the divergence is at runtime.

The cause, and why x86-64 is the one that is right

EmitManagedLocalCleanup (x86-64, symtab.inc) opens with

  if CurProcIsStackless then Exit;

and its comment explains it: a stackless generator step function RETURNS at every yield and is re-entered at the next one, so its locals are not going out of scope on that path — they are the generator's live state, checkpointed into the heap instance and restored on resume. Releasing them there frees the very objects the instance still points at.

EmitManagedLocalCleanupForTarget (ir_codegen.inc) has five arms — i386, arm32, aarch64, xtensa, riscv32 — and not one of them has that guard. Each opens with if CurProc >= 0 then and walks straight into the release chain, so every string, variant, promo-int, record and dynamic-array local of a step function is released at each yield. Pascal never sees it (its class locals are not refcounted and it has no NilPy generators), which is why the hole survived.

wasm32 releases in WasmEmitManagedLocals(release) instead and is unchecked by this ticket — it needs the same question asked of it.

The fix

Hoist the same early exit into EmitManagedLocalCleanupForTarget, beside the TARGET_WASM32 exit that is already there:

  if CurProcIsStackless then Exit;

The tyClass arm added by the sibling ticket carries (not CurProcIsStackless) inline as a stopgap, so it is already correct; that clause becomes redundant once the whole-function guard lands and should be deleted in the same commit, with the comment's stopgap paragraph.

The guard this needs

The repro above IS the positive control — it must go from SIGSEGV to CPython's output on i386 and arm32 and from corrupted strings to correct on aarch64. Wire it on all four targets; x86-64 is the oracle and is already green, so an x86-64-only row would pass before and after and prove nothing.

Log

Resolved — one line, at the top of the procedure, not five times

if CurProcIsStackless then Exit; now sits in EmitManagedLocalCleanupForTarget beside the existing TARGET_WASM32 exit, so it covers every kind on every cross arm rather than being repeated per target. The (not CurProcIsStackless) clause the sibling ticket's tyClass arm carried as a stopgap is deleted in the same commit, together with the paragraph that explained it — a stopgap left in place after the general fix reads as a second, narrower rule and invites someone to trust it.

The test is test/test_nilpy_generator_managed_local_survives_yield.npy, wired on x86-64 (oracle only), i386, aarch64 and arm32. acc and parts must SURVIVE the yield; a local rebuilt inside the loop body cannot see this defect at all, because its release lands after its last use.

target pin v403 HEAD
x86-64 correct correct — green both sides, so it guards nothing
i386 ['x|1|0', '1|1|0,1', '2|1|0,2,2', ...] correct
arm32 same as i386 correct
aarch64 same as i386 correct

acc came back as the loop counter's string and every earlier element of parts was overwritten — the freed blocks handed out again. Exit code 0 on all three: a silent wrong value, not a crash, which is why nothing had caught it. The closely related string-only shape SIGSEGVs on i386 and arm32 instead, so the class spans wrong-value to crash.

What this fix does NOT cover, measured rather than assumed. The first draft of the test carried a promo-int local and stayed red on i386 and arm32 after this change: a promotable-int local in a generator truncates to 32 bits on those two targets, identically on the pin, and reproduces with no managed local anywhere in the program. Removed from this test on purpose and filed as bug-a-a-promotable-int-local-in-a-generator-truncates-to-32-bits-on-i386-and-arm32. Leaving it in would have made this row fail for a defect it does not guard.

wasm32 is still UNCHECKED — it exits before this guard and releases in WasmEmitManagedLocals(release) instead. Nobody has asked that path the question; that is not a claim that it is fine.

Follow-up the same day — the blanket exit was itself half wrong

f891bbe8e hoisted if CurProcIsStackless then Exit; into EmitManagedLocalCleanupForTarget, matching x86-64's twin. That closed this ticket and was the right shape for one commit — but the twin it matched was also over-broad, which only showed up when the next ticket in the group needed the step function's cleanup to run for a different reason.

A stackless step function's frame holds two populations: its persistent slots, which are the generator's live state and must not be released at a step return, and its hidden temps, minted during IRLowerAST after AssignStacklessSlots has run, which die inside one statement and are ordinary locals in every sense. The blanket exit is correct about the first and wrong about the second, and the second leaked on both the return path and the unwind path.

Replaced by one per-symbol predicate, StacklessPersistentSlotSym(i) = CurProcIsStackless and SymGenSlot[i] >= 0, applied on the cleanup loops of both emitters and in ProcHasManagedLocalCleanup — see bug-a-a-generator-body-raising-past-a-managed-temp-is-not-covered-by-the-unwind-landing-pad for the measurement. This ticket's own test is the control that says the fix did not swing back too far: test_nilpy_generator_managed_local_survives_yield is exactly the program that breaks when a slotted local IS released, and it stays green on all four targets.