← board

Cross backends: a missing retain and a missing release that cancel

The two halves

No retain. ir_codegen_arm32.inc's IR_VAR_STORE reaches the object case through VariantTagForTk and fills the slot with no PXXObjRetain. riscv32 and xtensa are the same shape; none of the three has a VT_OBJECT arm at all. x86-64, aarch64 and i386 all retain (correctly, and only where the source does not already own its +1IRNodeOwnsManagedObj, added ca8153b6c).

No release. EmitManagedLocalCleanupForTarget (ir_codegen.inc) has five cross arms — i386, arm32, aarch64, xtensa, riscv32 — and not one has a tyClass case. Only EmitManagedLocalCleanup (x86-64, symtab.inc:11209) drops a NilPy class binding's reference at scope exit.

Why the observable is small, and why that is the trap

The LOOP case is already covered: the rebind ARC that releases a tyClass local's old value is emitted at IR level (ir.inc:10105), which is target-independent. So what the missing epilogue arm loses is the LAST value a scope held — one object per scope, not one per iteration.

Measured on arm32, the one cross target NilPy builds for today (bug-a-nilpy-on-cross-targets-four-remaining-walls):

probe 20k 400k
x = Node(i) in a loop 8584 kB 8584 kB
o.w = Node(i) into a variant field 8548 kB 8548 kB
test_nilpy_object_in_variant_slot_survives_churn.npy prints CPython's own answer under qemu-arm

Flat, and correct. That is the two errors cancelling, not two things working. A borrow stored into a variant slot is under-retained by exactly the amount the never-released local over-holds.

The failure this ticket exists to prevent

Add the PXXObjRetain to arm32's variant boxing and stop there: the slot now holds +1 it will release, the local still holds one nobody drops — a bounded leak, no worse. Add the scope-exit tyClass release and stop there: the local's reference is dropped while a variant slot holds the object without ever having retained it, and the next read is a use-after-free. The second is the natural first move, because "the epilogue is missing an arm" is the easier gap to see.

So: both, in one commit, with a borrow-into-a-variant-then-drop probe run on arm32 under qemu. The x86-64 shape of that probe is in the scratch record of feature-nilpy-object-reclamation; it is ~15 lines.

Log

Resolved — both halves, one commit, measured on four targets

The probe. test/test_nilpy_variant_borrow_two_slots.npy: TWO variant slots borrowing ONE local instance, then one of the two borrows dropped and the other read. The second slot is the whole point — with ONE slot the two errors cancel exactly, so a one-slot probe is green before and after and guards nothing. This ticket's own "measured on arm32, flat and correct" table is that: it is the cancellation, correctly described, and a one-slot instrument cannot see past it.

Positive control, drawn from the population, run before the fix (pin v403, ce63beeeb):

target before after
x86-64 correct, live=9 unchanged (this commit does not touch it)
arm32 (neither half) SIGSEGV correct, live=9
i386 (retain, no release) correct, live=39955 correct, live=9
aarch64 (retain, no release) correct, live=39955 correct, live=9

allocs=208613 in every column, so the census is comparing the same work. The two rows measure DIFFERENT halves and neither could have found the other: the value assertion sees the missing retain (a dangling read), and only the census sees the missing release (a leak prints nothing). Both rows are wired on all four targets.

riscv32 and xtensa got the retain and cannot be probed from NilPyclass refuses to compile for either ("a heap arena needs mmap"), so the code landed with its siblings rather than leaving the three 32-bit backends to drift again. Said here rather than left for a reader to assume it was tested.

What the change is. Retain: a tyClass arm asking IRNodeOwnsManagedObj in EmitVariantPayload{RISCV32,Xtensa} and in arm32's IR_VAR_STORE / IR_VAR_BOX, with an EmitObjRetain{Arm32,RISCV32,Xtensa} beside each file's existing string-incref twin. Release: a tyClass arm in all five cross arms of EmitManagedLocalCleanupForTarget, predicate copied from x86-64's (NilPyUserCode and PyClassSymArcEligible(i)), loading the instance POINTER-WIDTH from the slot.

One thing the release arm needed that was not there. x86-64's twin opens if CurProcIsStackless then Exit; — a generator step function's locals are the generator's live state, not locals going out of scope. No cross arm has that guard, for any kind. The new arm carries (not CurProcIsStackless) inline as a stopgap; the general hole is bug-a-a-managed-local-that-survives-a-yield-is-released-at-every-yield-on-every-cross-target, filed with a 12-line repro that SIGSEGVs on i386 and arm32 today.