← board

Reduction

test/test_nilpy_dynamic_dispatch_on_a_classref_receiver_does_not_walk_rtti.npy, wired into test-nilpy. Two classes declare SPAN and reach; the classref rows go last, because the instance arrangement passes either way.

Without the guard the fixture dies with rc=139 on the FIRST classref row, having printed the two instance rows correctly. With it, all four rows match CPython byte for byte.

How it presented

lekkerzeilen's world path. Under MALLOC_PERTURB_ it looked like one thing and under valgrind like another; valgrind is what resolved it:

Invalid read of size 8 -- address 0x40000008, not stack'd, malloc'd or freed
  __pxxInheritsFrom + 0x114     <-- mov (%rax),%rax, rax = cur + 8
  App._bucket       + 0x9b2
  App._prepare
  App._loader
  PyBoundPairCallKwBody -> pybound_pair_call_kw -> pybound_pair_call
  PyBoundCallV -> pybound_callv0
  ThreadLauncher -> PalThreadCreate

App._bucket is a @classmethod whose first statement is size = cls.FOLIAGE_CHUNK. Its source contains no is, no isinstance and no __class__ -- the RTTI walk is entirely compiler-generated, which is why reading the Python did not point at it.

Disassembled, the site is unambiguous:

call PyBoxClassRef        ; cls -> a variant, payload = the class BLOB
...                       ; 16-byte copy into a hidden temp
call pyvarobj             ; -> the RAW payload.  NO pyvartag before it.
mov  (%rax),%rax          ; [blob+0]
sub  $0x8,%rax ; mov (%rax),%rax   ; [[blob+0]-8] -- "the RTTI blob"
call __pxxInheritsFrom    ; walks ->parent at +8.  0x40000000 + 8. SIGSEGV.

ir.inc documents that lowering: blob = [[inst+0]-8], reflexive, the same machinery x.InheritsFrom(C) uses. It is correct for an INSTANCE and is nonsense for anything else.

The fix

PyMakeVariantIsTest(baseNode, ci) in pyparser.inc -- one spelling of (pyvartag(v) = VT_OBJECT) and (pyvarobj(v) is C). Five call sites folded onto it (two attribute arms, one field-call arm, the dual-candidate method arm, and the dynamic-defaults fallback arm added at accb99f3c). Boolean and lowers with JUMP_IF_FALSE over the right operand, so a non-object tag never reaches the walk.

The guard was NOT new information. pyparser.inc already said, in its own words, "The tag guard on the object arm is not optional: pyvarobj hands back the raw payload" -- and the two sites that carried it were the two that had been written next to that sentence. Seven copies of ten lines, two of them right. That is the case for a function rather than a ninth copy, and it is the normalise-dont-special-case.md failure in its usual shape: the second path is the one that stays broken.

Measured: accb99f3c did NOT cause this

That control was open in bug-n-a-callable-value-called-with-four-arguments-dereferences-a-variant-at-address-1 and is now closed, two ways:

The suspicion was reasonable -- accb99f3c grew the RTTI param block and its new arm emits AN_IS_TEST -- and it was wrong. Recorded because the cheap reading was "the new commit touched RTTI, the crash is in RTTI".

What this does NOT fix

The rax=1 fault in pybound_pair_call_kw at arity 4, reached through App._draw_scene, stays in its own ticket. Different thread, different arity, different frame; it is a bad ADDRESS rather than a bad CLASS pointer.

cls.some_static_method(...) is separately broken and unrelated to the walk: it answers AttributeError: 'type' object has no attribute '<name>' for a staticmethod the class plainly declares. Filed separately.

Gate

make compiler/pascal26 (converged, b46c98d433f1), the fixture above with its positive control, make test-nilpy.

Log