← board

uforth compiles, boots, and stalls loading STD.UFO

New frontier, not a regression: uforth.py had never compiled before 2026-08-07. Two blockers were cleared that day — [[bug-nilpy-to-bytes-on-a-variant-receiver-does-not-compile]] (line 411) and [[bug-nilpy-input-builtin-is-shadowed-by-pascals-standard-input-file]] (line 3352) — and the compile now succeeds:

ok: /tmp/uf.bin  [code=4048168B  data=57304B  bss=9580B  procs=1500]

make test-uforth then fails with Segmentation fault (exit 139) at the smoke step. There is no earlier state to compare against, so nothing here is attributable to those fixes beyond having made the code reachable.

Why it matters beyond the corpus

make test-uforth is named as the corpus check in [[bug-nilpy-pyeval-fallback-still-binds-host-kwargs-by-position]] — "uforth still green" — and that gate has been unreadable for as long as uforth did not compile. It is still unreadable until this is fixed, which is worth knowing before anyone plans work that depends on it.

Where to start

~4300 lines with a layered .UFO stdlib, so bisect the RUN, not the source: -dPXX_HEAP_DEBUG first (this session fixed the --threadsafe + -dPXX_HEAP_DEBUG deadlock, so both are available together now), then -dPXX_OBJTRACE if it looks like reclamation. uforth exercises the shapes this session found bugs in — bound-method values, Callable fields, escaping closures, variant receivers — so re-read the day's done/ tickets before assuming a new cause.

Watch the measurement traps: /proc/<pid>/comm before believing a sample (a backgrounded cd X && ./p & gives the SUBSHELL's pid), and disassemble from the live process rather than computing file offsets.

Gate

make test-uforth green (uforth.py compiles, STD.UFO loads, the smoke script runs), plus the ordinary per-fix loop.

2026-08-08 — the crash MOVED (still red, new location)

[[bug-nilpy-closure-stored-in-a-callable-field-jumps-through-the-variant-tag]] cleared the tag-jump. make test-uforth is still red, at a different place.

It now dies during STARTUP, before the banner — so it is a native word run while loading STD.UFO, not line 841's token. (stdout is empty on the crash; that is buffering, not evidence about where it got to.)

The faulting instruction is the one right after the bridge's indirect call:

  lea -0x1f0(%rbp),%rax ; mov %rax,%r10 ; pop %r11 ; call *%r11 ; push %rax
=> mov (%rax),%rcx          <-- rax = 0

pyboundfn_callvn calls the body through TBF<n>, a Variant-returning function type (rv := f1(p[0])). uforth's native words are explicitly def w_plus(vm: VM) -> None: — real PROCEDURES, which never set rax, so the result is read from a null pointer. TBoundFnObj carries no IsFunc field, where the pybound_new path carries exactly that flag for exactly this reason (bug-nilpy-void-def-assigned-and-called-crashes).

That hypothesis is NOT confirmed. The obvious minimal repros of it both PASS: a -> None nested def stored in a dataclass Callable field, and the same reached through a keyword define_word(name, native=w_plus) that mirrors uforth's own shape. Whatever uforth hits needs an ingredient those do not have — find it before writing a fix, and diff against the CPython oracle rather than reasoning from the disassembly alone.

2026-08-08 (later) — SEGFAULT FIXED. uforth boots and evaluates; now blocked on a different bug

pyboundfn_callvn chased to root and fixed. uforth no longer crashes: it prints its banner, loads part of STD.UFO, and 1 2 + . correctly answers 3. make test-uforth is STILL RED, on [[bug-nilpy-property-setter-is-skipped-on-a-dynamically-typed-receiver]] — blocked-by: that, nothing left here.

Root cause

PyParseDefHeader normalises a def that is used as a VALUE onto the function-object ABI (variant result, variant parameters) — but it exempted a -> None PROCEDURE: "A procedure stays a procedure — there is no result to disagree about." There is. Every bridge calls a function value through a VARIANT-RETURNING pointer type unconditionally — pyeval's TBF0..TBF13 in pyboundfn_callvn, pybound_callv*, and every Callable[...] signature, whose own comment in PyAnnTypeAt states it outright: "a Callable signature is ALWAYS a variant-returning function." A real procedure never sets the result register, so the bridge read the result from whatever was left there and dereferenced it.

The METHOD arm of the same rule (PyMethodUsedAsValue in PyRegisterClassMembers) has always normalised unconditionally, and its comment records the identical history — "a value-returning bound method crashed, a -> None one worked, which is why this looked like a receiver bug rather than an ABI one." Two arms of one rule that disagreed. The exemption is gone; the def arm now forces PyHdrIsProc := False and a variant result, like the method arm.

Why it read as unfixed, and the measurement trap

Reading an unwritten register is UNDEFINED, not reliably fatal, so a crash/no-crash test of this is BLIND — and both the earlier minimal repros and the existing regression test test_nilpy_void_def_value_call.npy were exactly that, and passed on the broken build.

The gdb evidence that settled it: only THREE calls reach the bridge before the crash — w_include, w_backslash, w_colon (r11 at the indirect call, symbolised through the .map). All three are -> None. The first two returned with a mapped address left in the register, so the junk read was harmless; the third returned 0 and it faulted. Disassembling all three ends confirmed the compiler emitted leave; ret with no result written — i.e. procedures — which is the fact a pass/fail run cannot give you.

test_nilpy_void_def_value_call.npy now PRINTS the result instead of merely calling, and covers the Callable-field route too: pinned prints 0 where CPython prints None, so it is deterministic in both directions.

uforth's remaining failure

: X 1 ; 5 X .     CPython: 1        pxx: 5      (X's body is empty)
.S                CPython: <empty>  pxx: 13 junk entries (CORE.UFO's PYTHON
                                    word bodies, pushed as string literals)
STD.UFO           dies at CORE.UFO:80, "expected a number, got str"

One cause, all three: w_colon's vm.compiling = True is silently DROPPED because compiling is a @property with a setter and vm is a dynamically typed parameter. Every colon definition therefore stays in interpret mode and its body executes instead of compiling. Filed, with a 25-line repro, as [[bug-nilpy-property-setter-is-skipped-on-a-dynamically-typed-receiver]] — PRE-EXISTING (reproduced under pinned), just unreachable until now.

2026-08-08 (third pass) — past STD.UFO's colon definitions, new blocker

[[bug-nilpy-property-setter-is-skipped-on-a-dynamically-typed-receiver]] fixed (three separate silent property-write losses, see that ticket). vm.compiling = True in w_colon now takes effect, so colon definitions COMPILE instead of executing and the junk that was landing on the data stack is gone.

make test-uforth now fails in a different component:

pyeval: host method define_word has an unsupported param shape

pyeval's host-call bridge accepts only an ALL-variant or an ALL-pointer-sized parameter list, and define_word(name: str, native: Callable, immediate: bool) mixes the two. Filed as [[bug-nilpy-pyeval-host-call-refuses-a-mixed-variant-and-scalar-param-shape]]; blocked-by: moved there. Nothing left in this ticket again.

2026-08-08 (fourth pass) — loads STD.UFO's includes, dies in IO.UFO

[[bug-nilpy-pyeval-host-call-refuses-a-mixed-variant-and-scalar-param-shape]] fixed, so PYTHON-bodied words can call back into define_word again. uforth now gets as far as compiling IO.UFO and segfaults dispatching an IMMEDIATE word (compile_token's word.native(self), uforth.py:906) — BEGIN, WHILE, IF and THEN dispatch fine, REPEAT does not, and nothing distinguishes them in the source.

-dPXX_HEAP_DEBUG names it: WRITE AFTER FREE. The callable's block was freed and reissued as a TPyList's element storage, so pyvar_callv1 finds neither closure nor bound-fn magic, falls to its plain-code-address arm and jumps into a variant array. Filed as [[bug-nilpy-write-after-free-on-a-callable-held-in-a-dataclass-field]]; blocked-by: moved there.

Four blockers cleared in a row on this ticket now (tag-jump ABI, -> None value ABI, property setter, pyeval param shape), each one uncovering the next. The pattern is worth naming: uforth is not hitting ONE bug, it is walking a queue of them, and each fix buys a few more lines of STD.UFO.

2026-08-08 — GREEN. make test-uforth: PASS

test-uforth: PASS — compiles, STD.UFO loads, native + PYTHON-bodied words
evaluate (1 2 + . = 3, 10 3 / . = 3)

Five blockers, each hidden behind the last, all found by measuring rather than reasoning:

  1. [[bug-nilpy-closure-stored-in-a-callable-field-jumps-through-the-variant-tag]] — a Callable FIELD was a pointer while a Callable PARAMETER was a variant; the tag word was jumped through as code (PC = 0x0a).
  2. [[bug-nilpy-uforth-compiles-but-segfaults-at-runtime]] (this ticket's own ABI half) — a -> None def used as a VALUE stayed a procedure while every bridge called it through a variant-returning pointer type.
  3. [[bug-nilpy-property-setter-is-skipped-on-a-dynamically-typed-receiver]] — three silent property-write losses; the STATE flag never took effect, so every colon definition executed instead of compiling.
  4. [[bug-nilpy-pyeval-host-call-refuses-a-mixed-variant-and-scalar-param-shape]] — the host bridge refused define_word's mixed signature outright.
  5. [[bug-nilpy-write-after-free-on-a-callable-held-in-a-dataclass-field]] — a promo LOCAL was cleared at scope exit without ever being zero-initialised, freeing a live block; plus select.select being a stub, which is what kept the UF> prompt in the piped output.

The pattern worth keeping: four of the five were UNDEFINED behaviour, not deterministic failure — a register never written, a block not yet reissued, a tag that happened to read 1. Every one of them had a minimal repro that PASSED on the broken build. Pass/fail testing could not have found any of them; the .map, -dPXX_HEAP_DEBUG, objtrace and a gdb watchpoint did.

Still open beyond this gate

uforth's deeper corpora (tests/*.for, testje.for) are NOT green: value, TO, create and allot fail with <lambda>() takes 0 positional arguments but 1 were given from pyeval. Filed as [[bug-nilpy-pyeval-lambda-host-word-arity-mismatch]].

Log