How it was found
Attempting the lekkerzeilen umbrella. With the previous wall cleared
(bug-n-a-callee-declared-below-its-caller-gets-the-argument-by-the-wrong-abi)
the compiled demo reached gauges.py:366 and stopped at
Unhandled exception: TypeError: expected a number, got function
on floor = pound.level - drop. The probe that named the defect is the one
that compares an INSIDE read against an OUTSIDE one:
PROBE pound= Pound(1, 6.54, 'Neder-Rijn') <- __repr__, correct
PROBE pound.id= 1 <- correct
PROBE pound.observed= None <- correct
PROBE pound.name= <a memory dump> <- wrong
PROBE poundlevel= <function at 0x3032325f36395f74> <- wrong
Pound.__repr__ reads self.id, self.now and self.name from INSIDE the
class and prints all three correctly, so the object is intact and only the
DYNAMIC read is wrong. 0x3032325f36395f74 decodes little-endian as the ASCII
string t_96_220 -- a tile id belonging to no Pound -- so the read was landing
outside the object entirely.
Five standalone reductions failed before the mechanism was found, because each of them happened to put the interesting class where it passes.
The reduction
Eighteen lines, and it segfaults:
class Ghost:
def __init__(self):
self.keep = 1
class Pound:
def __init__(self, name):
self.name = name or ""
def stamp(o):
o.name = "ghostly"
g = Ghost()
stamp(g)
print("done")
Pound is never instantiated. o is an unannotated parameter, so a variant;
exactly one class declares name; the frontend stores into Pound's layout at
Pound's offset. PXXDBG=a.ir:stamp shows the whole defect in one line --
field a=5 ival=8 tk=22 then var_store, a direct store at offset 8 with no
test before it.
self.name = name or "" versus self.name = name is the difference between a
SEGFAULT and silent corruption, and it is not the bug: the or widens the
field to Variant, and a variant store RELEASES the old value first. With the
field typed AnsiString the same store just writes over Ghost.keep and the
program carries on. The crashing spelling is the lucky one.
The cap, which is the same defect with a different door
PyVariantFieldCands collects into TPyFieldCands, sized
PY_MAX_FIELD_CANDIDATES = 16, and drops the rest silently -- n does not
count them. So the seventeenth class declaring a name was not merely
mis-ordered, it was invisible, and the unchecked last-candidate arm answered
for it. Measured with generated files, one field name, each class putting it at
a different offset:
16 classes -> all 16 correct
17 classes -> the seventeenth segfaults, the other sixteen correct
20 classes -> classes 17..20 all die, the first sixteen correct
lekkerzeilen reaches this easily: name is declared by Route, Furniture,
Pound, Region, Reading, the traffic and rig classes and more.
With the dynamic fallback in place the cap is no longer a correctness limit at all -- a class past the sixteenth simply resolves at run time instead of through an inline arm -- so it is left at 16 rather than raised.
The fix
compiler/pyparser.inc, PyMakeVariantField (read) and
PyMakeVariantFieldSet (write): the else arm is now always
PyMakeDynAttrGet / PyMakeDynAttrSet, and every candidate keeps its own
is pyvarobj(v) test. The sameShape fast path is gone and the write no
longer declines names with fewer than two candidates. PyVariantFieldHasPropCarrier
is removed -- it existed to pick the dynamic fallback for property carriers,
and the fallback is unconditional now.
The read and the write must take the SAME rule about which names dispatch, or
the caller rewinds into the read and the IR refuses the ternary as an
assignment target (IR_UNSUPPORTED: frontend could not lower AST node (kind 67)). That is recorded in the code, because it is not visible from either
function alone.
What it costs
lekkerzeilen's code segment goes 11300520 -> 12521128 bytes, +10.8%, for the
extra is tests. Not measured for speed; the arms themselves are unchanged and
the dynamic call runs only when no candidate matches, which for correct code is
never.
What is still open
The write's dynamic fallback uses pydynattr_set, which is store-only, so a
@property SETTER on a dynamically-typed receiver is still skipped --
bug-nilpy-property-setter-is-skipped-on-a-dynamically-typed-receiver, already
filed. That is strictly better than the arm it replaced, which wrote into
another class's layout.
Verification
test/test_nilpy_an_attribute_on_a_dynamic_receiver.npy, 3 sections, CPython the oracle. The control rows are the arrangement everyone writes.- Seven reproducers from the hunt, all matching CPython after the fix and all measured identical on pin v408 before it.
tools/gate.sh quickGREEN, fullmake test-nilpygreen.- The demo clears
gauges.pyentirely and stops two walls further in.
Log
- 2026-09-13 | fixed in
compiler/pyparser.inc, commit e5cd18e4b. Closed by the same commit, which also carries themax(genexp, default=)wall behind it andstruct.Struct.