← board

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 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