A class-annotated local assigned from an uninferrable call holds a raw variant and segfaults
class Vec3:
def __init__(self, x: float, y: float, z: float):
self.x = x; self.y = y; self.z = z
def norm1(self):
return self.x + self.y + self.z
class State:
def __init__(self):
self.position = Vec3(1.0, 2.0, 3.0)
def mk(self):
return self.position # return type NOT inferrable
s = State()
v: Vec3 = s.mk()
print("field read:", v.x) # 6.8526596474162e-310 CPython: 1.0
print("method call:", v.norm1()) # SEGFAULT, core dumped CPython: 6.0
PXXDBG=n.locals reports v tk=6 rec=0 — the slot is declared a Vec3. The
value arriving is a variant and nothing unboxes it, so the class-typed slot holds
a raw variant payload. The denormal is the POINTER REINTERPRETED AS A DOUBLE; the
method call dereferences garbage.
Both failure modes are bad and the quiet one is worse. A field read returns a plausible-looking float with no diagnostic; only a method call crashes. A program that merely reads fields off such a local produces wrong numbers forever.
The condition is an AND, pinned by matrix
| callee's return | local | result |
|---|---|---|
return self.position |
v: Vec3 |
WRONG / segfault |
return self.position |
v (unannotated) |
OK |
return self.position, callee -> "Vec3" |
v: Vec3 |
OK (but see below) |
return Vec3(...) (inferrable) |
v: Vec3 |
OK |
| plain function, same shape | v: Vec3 |
WRONG |
d: float = s.mk().x |
annotated float | OK |
An annotated local breaks if and only if the call it is assigned from has a return type the compiler cannot infer.
THE "ANNOTATE THE CALLEE RESCUES IT" ROW IS REFUTED ON REAL CODE AND MUST NOT
BE QUOTED AS A WORKAROUND. It is true in six measured shapes — one hop, two
hops over an unannotated inner, two hops over an annotated inner, an unannotated
RECEIVER with an annotated callee, a forward-referenced -> 'Vec3', and a plain
function. It is FALSE on lekkerzeilen's sim.py: with 23 call-site annotations
plus 14 return annotations on State, Quat and Vec3, the build segfaults
byte-identically to the one with no return annotations at all (rc=139, same tick,
same marker, twice). The hop that defeats the rescue is unidentified — I
could not construct it, and the seat that has the failing tree could not isolate
it either.
Recorded this way deliberately: the matrix is a real measurement of the shapes it covers and a false rule about the population that matters.
TRIGGER CONFIRMED BY THE CONTROL THAT MATTERS. Same compiler, same tree, the SAME 14 return annotations as the build that segfaulted, with the 23 annotated locals removed: it runs clean for 180s (rc=124, the timeout, 332 frames). So the defect attaches to the ANNOTATED LOCAL and to nothing else, and a return annotation neither causes nor rescues it. That is a positive and a negative arm differing in exactly one thing, which is what the six-shape matrix could not supply.
AND THE FIX MUST NOT BE SCOPED TO THE MATRIX. Two consequences: it has to
cover the case where the callee IS annotated, because that one is broken today on
real code; and it should not be validated against these rows alone, since they
certify a rescue that does not hold. The store fix is independent of all of this
— it makes a variant landing in a class slot correct whatever the reason it is a
variant — which is the argument for fixing the STORE rather than chasing the
inference. Annotate both ends or neither; the
reading end alone is the broken combination. Not loop-specific (in-loop and
straight-line spellings fail identically), not method-specific, not class-
specific. The annotated-float row is the tell for the mechanism: a
variant-to-double COERCION exists, a variant-to-class-pointer one does not, so
the class case stores raw where the scalar case converts.
Why it is 90
The edit that triggers it — adding a type annotation — is exactly what a user
does to make code faster, and it is what our own performance guidance tells them
to do. There is no diagnostic. The two annotations most likely to be written
together (x: Vec3 at the call site, nothing at the definition) are the broken
pair, while the SAFE spellings are "no annotation at all" and "annotate the
definition" — so the naive optimisation attempt lands squarely on the defect.
Provenance and the shape it was NOT
Found 2026-09-15 from the lekkerzeilen seat's report that adding 23 local
annotations to sim.py turned a working 2.18 fps build into a deterministic
segfault on the first physics tick, between their contrib-in and contrib-out
markers. Their candidate was an annotated assignment inside a loop body —
the only structural feature distinguishing the five annotations they suspected.
It is not the loop: the same annotation outside any loop fails identically. Had
the loop hypothesis been tested by reverting those five, it would have appeared
to work, because those five are also the ones assigned from to_world — the
right rows for the wrong reason, which would have left the rule wrong in the
notes and the other eighteen a hazard.
Their to_world is return self.orientation.rotate(body_offset), a method
returning a method's result — measured separately as not inferring
(bug-n-annotating-a-local-that-is-returned-destroys-the-defs-inferred-return-type,
the r_selfcall row). That ticket and this one are siblings: there the missing
inference costs SPEED, here the same missing inference costs CORRECTNESS once an
annotation disagrees with it.
The fix, and the cheaper half of it
Proper fix: a variant-to-class-pointer store must unbox (or refuse). The scalar path already converts, so the asymmetry is the bug.
A diagnostic alone would retire the worst of this even before the store is
fixed: the compiler KNOWS the declared kind is tyClass and KNOWS the expression
is tyVariant, which is precisely the pair it currently stores raw. Refusing
that pair would turn a segfault-or-denormal into a compile error naming the two
types — and per CLAUDE.md, leaving the mistake visible beats guessing. Users
reach for the annotation to go faster; today it takes their program apart.
The fix, located
PyUnboxVariantToClassEx(node, ci, owned) already exists in pyparser.inc — it
wraps the value in pyvarobj and casts to the class — and is already applied to
call ARGUMENTS landing in a class-typed dataclass field. It is simply not applied
on the assignment path.
The store goes through PyCoerceAssignmentRHS(lhsTk, rhsNode), called from
exactly three places, all assignment spellings. Today it does ONE thing: rewrite
an integer literal to a double when the slot is a double. That is why the
annotated-float row works and the class row does not — the scalar case got a
coercion and the class case never did. It is a procedure that mutates in
place and so cannot REPLACE a node, which is likely why the class arm was never
added: the unbox returns a NEW node and the signature had nowhere to put it.
So: make it a function, pass the target's RecName beside its kind, and when the
slot is a user class and the RHS is tyVariant, return the unbox. Three callers,
one helper, no new path — fixing one caller instead would be the same one-arm
mistake this repo has hit four times in two days.
owned := True, and this is the part to get right. The dataclass site's own
comment records why: an unboxed pointer is a bare reference, while the variant
TEMP it came from is released at the end of the statement and takes the object
with it — so without the retain the slot points at a freed block, correct at the
assignment and garbage at the first read. That is a strictly nastier bug than the
one being fixed.
User-facing rule, and it survives the fix
From the lekkerzeilen seat, in their words: "annotate the definitions, never only the call sites."
Read the second half as "and NOT the call sites", not as "as well as". A build with the definitions annotated AND the call sites still annotated segfaults exactly as before — measured, twice — so annotating the definitions does not make a call-site annotation safe. The rule earns its place on the speed result and on the defect's shape (no annotated local means nothing for it to attach to), NOT on a measured rescue, because there is no measured rescue. It is the high-leverage route for speed because one annotation at a definition types every caller (measured: four return annotations moved thirteen hot locals where three constructor annotations moved none, and definitions-only took 41 -> 30 with no annotated local anywhere). Worth saying in the NilPy docs regardless of this bug, because the failure it prevents is silent.
FIXED — and what the fix does NOT check
Implemented as PyStoreRhsToClassSlot(symIdx, rhsNode) in pyparser.inc, a
separate helper rather than a fourth arm inside PyCoerceAssignmentRHS. The
plan above said to make that procedure a function and thread the target's
RecName through it; that was wrong in one respect and the ticket should say
so. It is shared by THREE sites and one of them is the AUGMENTED assignment,
where the node becomes an OPERAND of an AN_BINOP rather than the stored value
— coercing it to the target's class there would be wrong for v += 1. Widening
an int literal IS right at all three; the unbox is right at two. That asymmetry
is the reason for a separate helper, and folding it in would have shipped a
fourth-site bug in the same week this repo shipped a third-site one.
Verified: field read: 1.0, method call: 6.0, matching CPython exactly, where
the same program gave 6.85e-310 and a core dump. All five matrix rows pass.
Fixture test/test_nilpy_a_class_annotated_local_from_an_uninferrable_call_is_unboxed.npy,
wired into the Makefile, twenty rows including nine _ok rows that a fix
unboxing indiscriminately would redden. Its positive control is the pinned
compiler, which fails it with rc=139 and field_bad FAIL got 6.2639678662742e-310 — drawn from the population the question is about, and
the failure is the original defect rather than a manufactured one.
THE GAP, AND IT IS IN THE FIX RATHER THAN IN THE BUG: pyvarobj_owned IS
RETAIN-BUT-UNCHECKED.
function pyvarobj_owned(const v: Variant): Pointer;
begin
Result := Pointer(PPyVarRec(@v)^.Payload);
if PyVarSlotIsObj(PPyVarRec(@v)^.VType) then PXXObjRetain(Result);
end;
It hands back the payload bits whatever the tag says, and retains only when the
tag is an object. So a class-annotated local assigned from a call that at
RUNTIME yields a double, a str or a container now has those bits reinterpreted
as an instance pointer, with no retain and no diagnostic. pylib's own
pyvarobj_arg comment records that exact failure from the argument-binding
site: "a variant holding a STRING was reinterpreted as an instance pointer and
the callee dereferenced it — tuple(v), sorted(v), bytes(v), reversed(v)
and sum(v) all SEGFAULTED."
This is not a regression — before the fix that same program stored a raw
variant into an 8-byte slot and read garbage, which is not better. And the
checked entry point (pyvarobj_arg) cannot simply be substituted: it raises,
and raising here would turn a wrong ANNOTATION into a runtime abort where
CPython would have run the program. But the honest statement is that the fix
trusts the annotation, and an annotation is exactly the thing a user gets wrong.
The diagnostic proposed above — refuse the tyClass-slot / tyVariant-RHS pair
at COMPILE time where the RHS type is knowably not that class — is the thing
that would close it, and it is still unbuilt.
Log
- 2026-09-15 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit fc646c17a.
2026-09-15 evening — the fix LEAKED, and the paragraph that said it would not was the cause
The resolution above landed the unbox as OWNED (pyvarobj_owned), with a
paragraph explaining that the variant temp's release would otherwise free the
object under the slot. That paragraph was reasoned, not traced. Traced
(-dPXX_OBJTRACE, 2026-09-15): the store into a class-typed local RETAINS the
pointer itself -- A 1; R 2 (box); r 1; R 2 (owned); R 3 (store); ... r 1 --
so the owned retain was a second +1 nothing ever released. g: Vec3 = mk() in
a 1000-iteration loop: 921 allocs, 0 frees, value correct, fixture green.
With the bare unbox: live=4, value identical under -dPXX_HEAP_DEBUG.
"What the fix does NOT check" above listed no census row, and the assertion
class was the wrong one for exactly this failure -- a leak cannot fail a
value comparison (CLAUDE.md, "MATCH THE ASSERTION CLASS TO THE DEFECT CLASS").
Fixed in the same commit as
bug-n-an-augmented-store-into-a-class-field-of-a-variant-receiver-never-reaches-the-dunder,
whose field twin had copied the same owned unbox; guarded by
test/test_nilpy_a_class_annotated_local_from_a_call_is_released.npy
(assert_no_leak, bound 64). The demo writes this idiom in its per-step
integrator, so this is a candidate for the 16 MB ticket's residual.