A bare read of a method as a value yields a garbage code address
The shape
test/nilpy_bareread/ plus
test/test_nilpy_a_bare_read_of_a_method_crashes_when_a_call_site_takes_the_dynamic_path.npy,
banked with this ticket. Not wired into test-nilpy — it fails at HEAD and
wiring it would redden the tier for every seat.
THE FIXING COMMIT MUST DO TWO THINGS: wire the fixture into test-nilpy,
and delete its line from test/UNWIRED.txt. Both, in that one commit. The
exemption is a countdown, not a steady state, and tools/check_test_wiring.py
re-reads that file every run — which is the only reason the condition is there
rather than only in the fixture's own header. A header instruction of exactly
this kind was written on 2026-09-14 (f09f6bcde), the fixing commit landed the
same day, and the three files it named sat unwired for six days because nothing
scans a source header.
class Craft:
def gusty(self, env):
return env.gust(4.0) # CALL site -- warns, dispatches at run time, WORKS
class Reader:
def bare_gust(self, env):
g = env.gust # READ site -- same name as a VALUE. Silent. Crashes.
env is unannotated in both, so both are open-world. CPython prints 12.0
twice; pxx prints the call row and then SIGSEGVs on the read row.
What is measured, and what is not
| deterministic | 4/4, and under setarch -R, so not ASLR |
| RIP at the fault | 0x4004ac — inside the ELF headers. Not null, not code. |
| the call site | emits warning: no class declares a method or callable field .gust() and is correct |
| the read site | emits no diagnostic of any kind |
| compiler | d9e9b124ee790727, pxx 381ea70ff |
The single variable is the call site. Deleting the two lines of Craft.gusty
and changing nothing else makes the identical read work, and putting them back
restores the crash. Both directions measured on one tree.
0x4004ac is the part worth keeping. An unfilled slot gives 0x0. A value
inside the ELF headers means something real was loaded and it was not a code
address — so this is a carrier built wrong, not a carrier never built. That
distinction is what separates it from its sibling below.
What this is NOT
It is not lekkerzeilen blocker 05, and I am not filing it as a reduction of
05. It was found by applying 05's operator to a smaller population, and the
signatures differ: 05's recorded RIP is 0x0, this is 0x4004ac. A reduction
that reproduces something is not evidence it is the same phenomenon — 05's own
document already made that mistake once, by reducing against type() when the
operator was the bare read. Same family, two rows, each marked with what it
measured. If a fix here also clears 05 on the demo, that is the evidence that
joins them; until then they are separate.
It is not blocker 03 and not import-order sensitive. Swapping the two import lines changes nothing, which is the axis 03 turned on.
Where to look
The call path has an open-world dynamic dispatcher and warns when it uses it.
The read path evidently has no equivalent: it resolves the name against
something the call site left behind and builds a carrier from it. The carrier
family is pyvar_of_callable / pyboundfn_bind / pybound_new* in
compiler/pyparser.inc; PyCarrierNamedProc is the function that names a
carrier's target and it was extended on 2026-09-20 for the capturing-def case
(085c43903), so it is the natural first place to print from.
Do not assume the fix is in the carrier. The read may be resolving to the wrong thing before any carrier is built, in which case naming the carrier better would only move the garbage. Print what the read resolved to first.
The general point, for whoever picks this up
One name, two uses, two mechanisms — and the silent one is the wrong one. The call form is the one that looks risky (it warns, it defers to run time) and it is correct; the read form looks trivial and produces an address that is not code. Wherever a frontend serves a construct through two paths, the path that emits no diagnostic is not the safe path, it is the unexamined one.
Resolution (2026-09-20, frankb-8e)
Fixed. PyMethodUsedAsValue in compiler/pyparser.inc now walks every
Python source range via PyPyRangeAt instead of 1..MainProgramTokCount.
The measurement that found it, and the two hypotheses it killed
Neither of my first two candidates survived being printed, which is the whole argument for printing rather than reasoning:
- "
PyMethodUsedAsValueis the discriminator." It answers FALSE in both the crashing and the working configuration. A no-change result, which is data about the model rather than about the code. - "The read lowers differently." Identical —
variantRecv=TRUE, three reads, in both.
The discriminator was the RTTI entry at run time:
CRASHING RetKind=19 (Double) <- native signature, bound into an all-variant bridge
WORKING RetKind=22 (Variant) <- the normalised ABI pybound_new_star assumes
and then the scope:
read in the MAIN file : MainProgramTokCount=73 match at tok 39 -> TRUE
read in a MODULE : MainProgramTokCount=236151 no match -> FALSE
The ticket's own mechanism sentence was wrong, and the fixture pays for it
This ticket said the bug springs when a call site elsewhere took the dynamic
path. That is the trigger, not the cause. The call site is what lets the
return type be inferred as Double; with no call site nothing pins the
parameter, the method defaults to Variant, and the bug cannot appear.
So a fixture written without that call site passes on the unfixed compiler. That is this repo's certifies-the-defect shape, and the wired row carries a comment saying the call site is load-bearing so nobody "simplifies" it away.
Why PyPyRangeAt and not TokCount
PyPyRangeAt yields the main file plus every appended .py/.npy module and
skips Pascal units. An unbounded scan once met parts.at(k) in pylib.pas
— Pascal, one argument — and wrongly vetoed Grid.at's z (2026-09-15, token
68229). The guard against the naive widening was already written down. The same
shape is applied to the class scans, whose PyTokBodyEnd takes a limit
because "the class may sit in a module the parser has not reached".
Verification
| banked fixture | rc=139 -> rc=0, matches the CPython oracle |
| the 4/4 crasher package | 3/3 clean |
| build | converged after 1 round(s) |
| fixture | wired into test-nilpy, UNWIRED.txt exemption deleted in the same commit |
| wiring census | rc=0 |
This did NOT clear lekkerzeilen blocker 05, and that is the point
05 has the same operator — a bare method read — and this fix does nothing for
it: PROBE A prints, PROBE B does not, rc=139. That is the evidence that
would have joined them, and it does not exist. The separation was made on one
digit, RIP 0x4004ac against 0x0 — a carrier built wrong against one never
built — and it now rests on a measurement rather than a judgement call.
The residual, named with an owner
pybound_new_star still binds mi^.Code on an unverifiable precondition.
It is handed mi^.RetKind <> 0 as a mere has-result boolean and never checks
the kind is Variant. This fix removes the way we knew to violate that
precondition; it does not make the violation detectable. A one-line guard
there — refuse, or diagnose, when RetKind <> 22 — converts any future
instance from a jump into the ELF headers to a message. Not done here because
it wants its own fixture and a decision about refuse-versus-coerce, and
smuggling it into this commit would leave both unmeasured. Filed as the next
piece of work on this mechanism.
Where this fix is reachable from (added 2026-09-20, same seat)
Pin v413 (d79e66f07) does not carry it, nor the 03 fix (fb0c0af11) nor
the 04 fix (12806ea63). So anything building with $(PXX_STABLE) — Track B/E
by rule — gets the pre-fix compiler until the owner pins. That is a sentence
here, not a reason to wait: never wait for a pin, and a pin is his call.
But the lekkerzeilen demo does NOT build against the pin, so it is not
pin-gated. runbin.sh defaults to latest, i.e.
$PXX_ROOT/compiler/pascal26. The gap is different and sharper:
PXX_ROOT defaults to /home/neo/frank-user, the owner's own checkout,
and that checkout is at d79e66f07 with a compiler/pascal26 whose sha is
f94c2a7e2396d2be — byte-identical to the pinned binary and carrying none of
tonight's three fixes.
Measured rather than inferred, the command he would type, same flags as the pair above, one variable changed:
PXX_ROOT |
result |
|---|---|
/home/neo/frank-user (his, the default) |
TypeError: object is not callable, rc=217 |
/home/neo/frankB (has the fixes) |
sails, rc=0, 0.9 kn |
That TypeError is blocker 03's signature error, and it is the same string the
pinned binary produces on the 03 fixture — so the two instruments agree about
which compiler he is running.
So the reach of every fix tonight is a git pull and a rebuild in HIS
checkout, not a pin. Nobody else can do it for him: that tree is his, and
pulling or rebuilding in another seat's checkout is not a thing to do
unasked.