A dynamic attribute STORE on a scalar variant segfaults
Repro
xs = [5]
xs[0].foo = 1
print("survived")
| result | |
|---|---|
| CPython | AttributeError: 'int' object has no attribute 'foo' and no __dict__ for setting new attributes |
| pxx @ HEAD | SIGSEGV, rc 139 |
Cause
pydynattr_set_v (compiler/builtin/pylib.pas) tells apart exactly one tag:
if pyvartag(v) = 11 then { VT_CLASSREF }
begin
if PyClsAttrRefSet(v, name, val) then Exit;
raise AttributeError.Create(...);
end;
pydynattr_set(pyvarobj(v), name, val);
pyvarobj(v) on a VT_INT64 payload yields the integer reinterpreted as an
address. Every non-object, non-classref tag reaches it.
Why this is a CLASS and not one row — the part worth keeping
The read twin already does it right, and says so in its own comment:
"Unlike
pydynattr_getabove,pyvarobj(v)'s raw payload is only a real object pointer when the tag says so (VT_OBJECT); for any other tag (str/int/float/bool) it is scalar bits reinterpreted as an address, and ClassName on that would dereference garbage. Check the tag first."
pydynattr_get_v gates its object work on tg = 7. pydynattr_set_v gates on
nothing. So the condition that springs a bug of this shape is a concept with
two runtime entry points where only one carries the guard — and this file's
own comments keep calling that out (normalise-dont-special-case.md is cited
three times in it). The sibling here is not a different SHAPE, it is the
opposite DIRECTION of the same operation, which is why grepping for the
construct does not find it and grepping for the other spelling's handler does.
The fix, and the thing to check before writing it
Gate on the tag: VT_OBJECT (7) takes the store, VT_CLASSREF (11) keeps its
existing arm, anything else raises. CPython's wording is uniform across kinds —
measured, all eight of int/float/bool/str/list/tuple/dict/NoneType give
'<kind>' object has no attribute '<name>' and no __dict__ for setting new attributes — and PyVarTypeNameOf already exists in the same unit.
Check first whether any tag OTHER than 7 legitimately reaches the store
today. The getter's route census is not evidence about the setter, and a
None receiver in particular needs its answer chosen rather than inherited.
Acceptance
xs[0].foo = 1 raising with CPython's message rather than crashing, a
positive control showing the fixture reds on the current compiler, and the
existing class-reference row (c.num = 9,
bug-nilpy-class-attribute-through-a-class-reference-reads-garbage) unchanged.
Note on the neighbouring parse gap, so it is not conflated
mk().foo = 1 does not parse at all — a call result is not accepted as an
assignment target, where CPython accepts it and raises at run time. Loud, no
wrong value, and a different ticket if it is worth one.