Measured 2026-09-13, at 533c194fd, no dynamic dispatch involved
calls = []
def track(v):
calls.append(v)
return v
def f(a, b):
return a + b
c = False
r = f(*[track(10), track(20)]) if c else 99
print(r, calls)
| result | |
|---|---|
| CPython | 99 [] |
| pxx | 99 [10, 20] |
The VALUE is right in both. Only the side effects differ, which is why no
expect_same row over r could ever have caught it — the instrument has to be
a log of what RAN, not of what was returned. Same structural blindness as
CLAUDE.md's leak and store-order rows.
Why it matters beyond a probe
Two callers pay for it today and both are ordinary Python:
- A star-unpacked call in a conditional expression — the row above.
- Any construct whose desugar hoists.
enumerate(xs, start=bump())binds its start to a hidden local before the loop deliberately (once, not per-iteration), and that hoist is correct because aforheader always runs. A hoist inside a ternary arm is the same mechanism where the arm may not.
A THIRD INSTANCE, AND IT IS A CRASH ON CORRECT PYTHON -- 2026-09-15
This ticket's own summary predicted it: "every construct in this frontend that hoists has the same escape." It does, and the third one found is not a stray side effect -- it raises and kills the program on code CPython runs fine. That is why this is now prio 80 and no longer 55. The two rows already here produce a right answer with a wrong side effect; this one produces no answer at all.
Found on the lekkerzeilen demo by lekkerzeilen-c8 (menu open, app.py:3130), reproduced independently here:
class P:
def pending(self):
return {"x": 1}
d = {}
staged = d.get("miss")
r = staged.pending() if staged is not None else {}
print("len=%d" % len(r))
CPython prints len=0. pxx raises
AttributeError: 'NoneType' object has no attribute 'pending', rc=217.
The trigger is NAME RESOLUTION, which is what makes it look arbitrary:
| fixture | result |
|---|---|
no class in the program defines pending |
correct |
| a class defines some OTHER method | correct |
a class defines pending, same module |
RAISES |
a class defines pending, imported module |
RAISES |
the same code as an if/else STATEMENT |
correct |
A sweep of short-circuit forms whose method name nothing defines comes back entirely clean -- c8 nearly filed "pxx does not have a short-circuit bug" on exactly that. A fixture that does not define the method certifies this bug as absent, which is CLAUDE.md's unrepresentative-population trap wearing a new dress.
THE MECHANISM, FROM PXXDBG=a.ast, AND IT CONFIRMS THE DIAGNOSIS ABOVE
For r = staged.pending() if staged is not None else {} the frontend emits, at
STATEMENT level, in this order:
1 AN_ASSIGN __t549 := staged hoist the receiver
2 AN_IF cond NOT(<receiver-is-non-nil>(__t549))
then AN_CALL(__t549, "pending") <- the RAISE (nil test)
3 AN_ASSIGN __t550 := getmem(48) <- the else arm's {} literal
4 AN_ASSIGN r := AN_TERNARY(cond, <the call>, __t550)
Steps 2 and 3 sit outside the AN_TERNARY at step 4. The attribute-missing
guard is hoisted to statement level and runs unconditionally, whichever arm the
ternary would select -- which is this ticket's mechanism exactly, with a raise
instead of a side effect. It also explains the name-resolution trigger: the
guard is only emitted when the attribute name resolves to a known method
somewhere in the compilation, so with no such name there is nothing to hoist and
the row is clean for the wrong reason.
WHAT THE GUARD EMITS ON AND WHAT IT TESTS ARE TWO DIFFERENT THINGS, and
conflating them mis-states the acceptance test. It is EMITTED when the
attribute name resolves to a known method somewhere in the compilation (the
table above), and what it TESTS is the receiver for NIL -- the message is just
worded as an attribute failure. Both instruments agree: the fixture where
nothing defines pending is clean because no guard is emitted, and on the demo
panel.more() if isinstance(panel, ui.Menu) else "" runs for every plain Panel
every frame without raising, because more resolves (on Menu) but the
receiver is never nil. So the family crashes iff the receiver CAN be None, not
iff the attribute is missing -- credit lekkerzeilen-c8, from the demo, against
an earlier reading here that said attribute-presence.
THE WORST CALL SITE IS NOT THE ONE THIS WAS FOUND ON. ui.py:1020
Stack.press does panel.press(...) on panel = self.at(px, py), and
Stack.at returns None for a click that lands on no panel -- reached unguarded
from app.py:3298 for any left click that is not on the menu or the icon. If
the hoist fires there, a left click on open water kills the demo, which is a
far commoner action than opening the menu. 19 conditional expressions in that
package call a method on an arm whose name resolves; 10 of them guard the very
receiver being called, i.e. the author wrote the guard because it can be None.
Step 3 answers a question that was open on the demo side: the untaken arm's
{} IS allocated, unconditionally, on every evaluation. That is one wasted
dict per evaluation in a per-frame path. Allocated is NOT the same as leaked --
__t550 is ARC-eligible and a rebind should release the previous one -- and
nobody has measured whether it leaks. Do not fold it into a leak figure without
that measurement.
THIS IS THE SAME BUG AS bug-n-a-hoisted-argument-temp-escapes-a-conditional-that-lives-inside-an-expression
Same root cause, same fix, different symptom; that ticket carries the better
boundary table (the three CORRECT rows -- or short-circuit, dead for body,
untaken if arm -- which are what say the hoist is statement-local and
working). Fix once, close both, and check the third row above as the
acceptance test -- it is the only one of the three that fails loudly, so it is
the cheapest positive control the family has.
FIXED FOR THE CONDITIONAL EXPRESSION -- 2026-09-15
PyParseBoolExpr now snapshots the hoist queue before the then-arm and folds
each arm's hoisted setup INTO that arm with PyFoldHoistSince. The CONDITION's
hoists deliberately stay at statement level: the condition always runs, and they
must run before the ternary.
This is the mechanism and/or operands already use in the same file, and
PyFoldHoistSince's own header states the principle -- "Attaching each
operand's setup to THAT operand is what satisfies both: the value is fresh, and
it is only computed on the path that actually reaches it." The ternary arms had
simply never been given the same treatment. Of the two options this ticket
listed, it is neither: the setup moves into the ARM rather than into a generated
if, so the condition is not duplicated and AN_TERNARY's lowering is
untouched.
| row | before | after |
|---|---|---|
staged.pending() if staged is not None else {}, dict.get miss |
AttributeError rc=217 | len=0, matches CPython |
same, literal None receiver |
AttributeError rc=217 | len=0 |
f(*[track(10), track(20)]) if c else 99 |
99 [10, 20] |
99 [] |
| no class defines the name | correct | correct |
| a class defines some OTHER method | correct | correct |
the if/else STATEMENT form |
correct | correct |
AND THE COMPREHENSION FILTER, THE THIRD HOIST SITE -- FIXED IN THE SAME PASS
[g(b=side(4), a=1) for i in [1] if False] printed evaluated 4. A separate
site with the same shape: the comprehension spliced the element's hoisted setup
into the loop BODY, ahead of the filter's AN_IF, so it ran on every iteration
whether the filter passed or not.
The element's setup is now taken off the queue BEFORE the filter is parsed and
placed inside the filter's TAKEN arm. The queue is emptied before the element
parse (savedHoist), so whatever is on it at that moment is the element's and
only the element's. The FILTER's own hoists deliberately stay on the queue --
they must run before the filter is tested, and the existing splice puts them in
the body ahead of the AN_IF, which is already correct.
A NESTED comprehension as the element hoists its whole build loop, and that
moves inside the arm too: [[side(x) for x in [k, k]] for k in [1, 2, 20] if k > 10] no longer builds the inner list for a rejected k.
THE PRE-FIX FAILURE SHAPE IS THE TICKET'S OWN ARGUMENT, MEASURED. On the compiler carrying the ternary half but not this one, the three comprehension VALUE rows all pass and only the side-effect COUNTS fail:
comp_untaken OK
comp_untaken_calls WRONG got 2 want 0
comp_taken OK
comp_taken_calls WRONG got 4 want 2
comp_nested OK
comp_nested_calls WRONG got 6 want 2
Every value is right and every effect count is doubled. No expect_same row
over the results could ever have caught this; the instrument has to be a log of
what RAN.
ALL THREE SHAPES ARE NOW FIXED -- CLOSING THIS AND ITS SIBLING
One root cause, three symptoms, one fix, one fixture
(test/test_nilpy_a_conditional_expression_does_not_evaluate_the_untaken_arm.npy,
13 rows, wired into the tier). The rows live in ONE file deliberately: a reader
who breaks the hoist fold should see all three go red together.
PyMakeDynMethCall's two paths -- direct pydyn_meth<n> rungs for four
arguments or fewer, a hoisted TPyList past that -- split ONLY because of this
bug, and its own comment says to merge them when it is fixed. That merge is
now unblocked and is NOT done here; it is a separate change with its own
risk, and doing it in the same commit as the correctness fix would make a
regression in either one unattributable.
PROVENANCE: THIS IS NOT A RECENT REGRESSION
Measured by lekkerzeilen-c8 against archived compiler 44a006699586f064 (sha
verified before and after the run, CWD at the repo root so the builtin lookup
resolves correctly): the crash reproduces there. So the defect predates the
NilPy object-lifetime work entirely. Scoped honestly -- that says the COMPILER
predates it; lib/ and builtin/ are today's, and an old compiler against a
current tree is not a time machine.
CORRECTED 2026-09-15 -- the parenthetical that used to close this paragraph
was WRONG, and it was wrong in the direction that retired a leg we could have
run. It read: "the demo leg on that compiler does not build at all: today's
heapq does not bind heapify for it". lib/rtl/mimic_heapq.py:121 defines
heapify perfectly well. The build was not hitting a version mismatch at all --
an archived binary invoked by absolute path does not resolve lib/ , so nothing
that imports a shim binds. PXX_HOME=/home/neo/frank-user in front of the
archived compiler fixes it outright, rc=0 on BOTH archived compilers, and c8 has
since run the pre-fix demo leg that this sentence had written off.
The root cause was already measured and banked -- LOGBOOK.md 2026-09-13, on
a compiler built into a scratch dir answering import: no unit named heapq and no shim mimic_heapq on a demo that had just compiled: "a binary outside the
repo root finds neither compiler/builtin nor the lib/rtl shims beside
itself". Same mechanism, found twice, a fortnight apart, and the second finder
had to rediscover it because the first wrote it in the logbook and not here.
So Condition One as stated in this ticket is necessary but NOT sufficient.
CWD at the repo root fixes the BUILTIN lookup; it does not fix the SHIM lookup
for a binary invoked by absolute path. The archive pattern wants PXX_HOME in
it. CLAUDE.md documents the builtin half of this and not the shim half, which
is why both of us had the incomplete version.
How the wrong diagnosis survived, in c8's own words, because it generalises: two failures shared an error string; the first time they reasoned from the message to a plausible cause and stopped. "One data point let me pick the explanation that was interesting; two forced the one that was true." The tell was available without the second failure -- both failures were archive builds and both successes were in-tree builds, so the compiler version was never the variable.
CONFIRMED IN THE REAL APP, NOT ONLY IN FIXTURES
c8 built a probe into the demo that exercises every conditional-expression site whose receiver can be nil, in the constructed app with real panels, with two controls in OPPOSITE directions (a name nothing defines, which must PASS; and a deliberately missing attribute, which must RAISE) and a runner that refuses to print a verdict if either misbehaves. Pre-registered predictions, hit row for row:
RAISED ui.py:1020 Stack.press <- a left click on OPEN WATER kills the demo
RAISED ui.py:1034 Stack.wheel <- so does a scroll there
RAISED app.py:3130 _panel_note(menu)
RAISED ui.py:776 Menu.text <- a Menu with no tabs
RAISED app.py:2913 panel.value (shape)
RAISED sim.py:373 env.depth (shape)
PASS app.py:3136 _panel_note(panel) receiver is a Panel, never nil
PASS control (no such method name)
RAISED positive control (expected)
The shape of a fix, not yet chosen
The hoist target is the enclosing statement. A ternary arm is not a statement,
so either the hoisted setup moves into a generated if that mirrors the
ternary, or the ternary itself lowers to one. The second is the smaller change
and is what AN_TERNARY's lowering would have to grow; the first duplicates the
condition.
Not attempted here: this was found as a CONSTRAINT on another change, measured, and banked rather than microfixed.
What is blocked on it
PyMakeDynMethCall runs two paths — direct pydyn_meth<n> rungs for four
arguments or fewer, a hoisted TPyList past that — and the split exists ONLY
because of this bug. Merge them when it is fixed; the comment there says so.
Log
- 2026-09-15 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit 87d0fae10.