← board

A conditional expression loses None identity

class Word: ...
class VM:
    def lookup_word(self, name: Any) -> Optional[Word]:
        ...
        return None                      # not found

vm = VM()
k = "MISSING"
w = vm.lookup_word(k) if isinstance(k, str) else None
print(w is None)                         # CPython True    pxx FALSE
if w is not None:
    w.is_native()                        # entered, then SIGSEGV

Silent wrong value first, crash secondis None answering False on a value that IS None is the bug; the segfault is only how you notice.

The SAME assignment without the conditional expression is correct:

w = vm.lookup_word(k)
print(w is None)                         # True — correct

One construct, two spellings, different answers.

Root cause — located, not guessed

PXXDBG=n.locals on the two spellings:

spelling recorded
direct w tk=6 rec=0 — tyClass
conditional expression w tk=22 rec=0 — tyVariant

So the ternary widens the result to a VARIANT, which means the class pointer gets BOXED. The boxing site is ir_codegen.inc ~5580:

  tyClass:  size := VT_OBJECT;
  ...
  EmitB($48); EmitB($C7); EmitB($07); EmitI32(size);  { mov qword [rdi], tag }
  EmitB($48); EmitB($89); EmitB($4F); EmitB($08);     { mov [rdi+8], rcx }

The tag is stored unconditionally. A nil class pointer therefore boxes as VT_OBJECT with payload 0 — a variant that is not VT_EMPTY, so every is None / truthiness test on it answers the opposite of Python.

Fix

For tyClass only, the tag must depend on the payload: nil → VT_EMPTY (Python's None), non-nil → VT_OBJECT. In the emitted blob, rdi holds the slot address and rcx the payload at that point, so it is a store, a test rcx, rcx, and a conditional overwrite of the tag with 0 — a manually patched forward jump, as several blobs in that file already do (see EmitDynArrayReleaseLocked for the idiom and why the jumps are patched by hand).

Mirror it in the portable twin if that path boxes classes too, and check the other five backends — this is emitted code, so a fix in the x86-64 blob alone would leave the cross targets disagreeing.

Why it matters

This is what segfaults uforth ([[bug-nilpy-uforth-compiles-but-segfaults-at-runtime]]). uforth.py:838-841 is exactly this shape:

word = self.lookup_word(key) if isinstance(key, str) else None
if word is not None:
    if word.is_native():        # <-- rdi = 0, `mov (%rdi),%rax`

Registers at the fault confirm it: rax=0 rdi=0.

More generally x = f() if cond else None is ordinary Python, and every None guard after one is currently unreliable when f() can return None.

Also noticed, not fixed

The pre-pass records the conditional-expression local as tk=tyVariant rec=0 — a variant carrying a class identity, which is contradictory (the field path normalises this with if tk <> tyClass then fldRec := REC_NONE). Clearing it in PyNoteLocalType was tried, changed the record to rec=-1, and did not fix this bug, so it was reverted rather than shipped unmeasured. Worth revisiting as a separate normalisation — a sticky PyInferLastCi leaking into a non-class type could well be behind other symptoms.

Gate

Per-fix loop, plus a .npy covering: a conditional expression whose taken arm returns None, whose taken arm returns a real object, the else None arm, the direct (non-conditional) assignment, and the same through a variable of Optional[X] type — oracle-diffed. And make test-uforth getting past uforth.py:840.

FIXED 2026-08-07

A nil class pointer now boxes as VT_EMPTY — which is what Python's None IS — instead of VT_OBJECT with a 0 payload. Both boxing sites in ir_codegen.inc take the same three instructions after the tag store:

test rcx, rcx
jne  +7
mov  qword [rdi], VT_EMPTY

The skipped store is exactly 7 bytes, so the jump is a fixed short one and needs no manual patching — unlike the blobs elsewhere in that file.

Both sites, not one: the same sequence appears in the variant-assignment path and the variant-operand-temp path, and fixing only the first would have left the other spelling wrong, which is the split this codebase keeps finding.

Measured

test/test_nilpy_conditional_expression_none.npy, 9 lines byte-identical to the CPython oracle: the conditional expression whose taken arm returns None, whose taken arm returns a real object, the else arm, the direct assignment that was always correct (kept as a guard against "fixing" the ternary by breaking it), truthiness of the boxed None, and a None-returning call through a plain variable. The whole Callable-field and Optional-method family from earlier the same day still passes.

uforth: the fault MOVED, which is the point

Before: uforth.py:840, mov (%rdi),%rax with rdi = 0, on token "CORE.UFO" — the null deref this bug caused.

After: uforth.py:841 (word.native(self)), on token INCLUDE, with PC = 0x0a — a jump through a garbage code pointer. A different failure, one line later, on a token that only gets reached now that the None guard behaves.

So uforth is not fixed, but this was genuinely on its path. The next failure is a Callable FIELD call through a variant receiver where the field was bound via define_word(name, native=_w) — a KEYWORD argument through the pyeval fallback, i.e. the shape [[bug-nilpy-pyeval-fallback-still-binds-host-kwargs-by-position]] describes. Recorded on [[bug-nilpy-uforth-compiles-but-segfaults-at-runtime]].

Not shipped

Clearing the contradictory tk=tyVariant rec=<class> the pre-pass records for such a local was tried, measured to change the record but NOT to fix this bug, and reverted. Still worth doing as a separate normalisation — a sticky PyInferLastCi leaking into a non-class type is the kind of thing that produces another symptom elsewhere.

Gate

make fpc-check byte-identical, self-host fixedpoint, tools/gate.sh quick GREEN.

Log