← board

An augmented store into a class field of a variant receiver never reaches the dunder

Found by the lekkerzeilen seat (2026-09-15) while trying to reproduce a leak attribution: acc.force += v through an unannotated parameter raised TypeError: expected a number, got object on 1a74a2318642. Varying the shape here: with acc annotated it is correct; unannotated it reads back 0.0 (the dunder never called) or, with v: Vec3 or a read of the field in the same body, SEGFAULTS. CPython prints 2.0 for all of them.

Mechanism, read. PyMakeVariantFieldSet dispatches a write through a variant receiver per candidate class (if pyvarobj(v) is C then C(pyvarobj(v)).name := ...). Its per-arm builder PyVariantFieldStore built the augmented form as a bare AN_BINOP typed with the field's kind -- for a class-typed field, a class-typed add of two instance pointers. The bare-name target (PyAugClassDunder) and the statically typed field target (PyAugClassDunderNode) both dispatch __i<op>__/__<op>__; this was the THIRD arm of one family and the one left behind (normalise-dont-special-case).

Three sibling rows in the same store, fixed together:

And a fourth thing the census found, which the value rows could not: both unboxes were first written OWNED, on the strength of the comment in PyStoreRhsToClassSlot (landed the same day): "without the retain the slot points at a freed block". Traced with -dPXX_OBJTRACE, the store into a class-typed slot RETAINS by itself, so the owned unbox was a second +1 nobody released: g: Vec3 = mk() leaked one object per call, 921 allocs / 0 frees, with the value correct and its fixture green. Both sites are bare unboxes now; the comment is corrected in place. test/test_nilpy_a_class_annotated_local_from_a_call_is_released.npy is the census row that guards it (live=4 over 3000 stores; HEAD before the fix: leak or segfault).

Fixture: test/test_nilpy_an_augmented_store_into_a_class_field_of_a_variant_receiver_reaches_the_dunder.npy, eleven rows, every expected value chosen to differ from the raw right-hand side and from 0, byte-identical to CPython, identical under -dPXX_HEAP_DEBUG. A demo-shaped probe (state = self.state; state.velocity += ... on a typed self, and body.state.velocity += ... through a variant) matches CPython.

Why 85 and not filed lower: the demo's contributor loop is this shape. The leak half is NOT offered as the 16 MB ticket's residual: the lekkerzeilen seat's static census of the demo's source as of 2026-09-15 finds exactly two annotated-local assignments, both : int, which never reach the unbox at all. That is a census of the source on that date -- the idiom is absent, not prohibited -- so the exculpation is worth exactly as long as nobody writes v: Vec3 = body.state.velocity.copy() in the integrator, and the census row is what guards it after that.

Log