← board

A callable in a Callable field is freed while the field still holds it

uforth, after the pyeval mixed-param fix, segfaults compiling IO.UFO:

pxx-heap: WRITE AFTER FREE in 0x00007fffd8089908      (-dPXX_HEAP_DEBUG)
SIGSEGV, PC = 0x00007fffd7f069c0                       (a HEAP address)

Call chain, symbolised through the .map (readelf is blind on pxx binaries):

VM.interpret_file -> VM._interpret_current_source_line -> VM.exec_token
  -> VM.compile_token -> pyvar_callv1 -> <jump into the heap>

compile_token's word.native(self) on an IMMEDIATE word (uforth.py:906).

The tell

pyvar_callv1 reached its LAST arm — f1 := TPyCallFn1(payload); f1(a0) — which runs only when PyCallableObj returns nil, i.e. the payload's magic matches neither a closure nor a bound-fn. Dumping the payload shows why:

0x7fffd7f069c0: 0x00007fffd80e12a8  0x0000000000000007
0x7fffd7f069d0: 0x00007fffd80e0e10  0x0000000000000007

Alternating {pointer, 7} — an array of 16-byte VT_OBJECT variants, i.e. some TPyList's element storage. The callable's block was FREED and the allocator handed it to a list; the magic is gone, so the tag test falls through and the call jumps into the variant array.

BEGIN, WHILE, IF and THEN dispatch correctly first; REPEAT is where it dies. Nothing distinguishes them in uforth — all four are vm.define_word("X", native=w_x, immediate=True) — which is itself evidence for recycled memory rather than a bad lowering of one word.

NOT the pyeval mixed-param fix

Measured, not assumed. With the bridge instrumented to print every variant argument it marshals, the WRITE AFTER FREE is reported on line 1 of the program's output, before the first mixed host call happens at all, and the freed address matches none of the seven objects that later travel through it (nor is any within a page of it). The marshalling buffer there is also deliberately non-owning now (a raw TPyRec, not a managed Variant), so that path adds no release.

The isolated repros of the same shape — a closure through the mixed host bridge, stored in a Callable dataclass field and called afterwards — all pass against the CPython oracle (test/test_nilpy_pyeval_host_mixed_params.npy). uforth had simply never executed this far before.

Where to start

The suspects are the four-places variant OBJECT-tag list ([[project_variant_object_tag_list_lives_in_four_places]]): a retain missing on ONE of the paths that stores a callable variant into a field leaves the field holding a borrowed reference, and the owner's release then frees it. native arrives as VT_PYCLOSURE (tag 9) in uforth's case — measured — so check the closure arm specifically, and check it in the CONSTRUCTOR store as well as the plain field store, which are different lowerings ([[project_nilpy_class_attribute_lowering_matrix]]).

-dPXX_OBJTRACE then grep <addr> names the retain/release pairs, but note it is BLIND to plain GetMem, so confirm the closure object is actually traced before trusting an empty result.

Gate

make test-uforth past IO.UFO, plus a .npy that keeps a closure alive only through a Callable field across enough allocation to force block reuse, oracle-diffed, plus -dPXX_HEAP_DEBUG clean on it, plus the per-fix loop.

RESOLVED 2026-08-08 — a promo LOCAL was cleared without ever being initialised

Not a refcount bug, and not the callable's fault at all. The object was alive the whole time — objtrace shows A(1) R(2) R(3) R(4) r(3) r(2) r(1) and no free. Its BLOCK was freed by someone who never owned it.

The chain, measured end to end

-dPXX_HEAP_DEBUG reported WRITE AFTER FREE; a gdb hardware watchpoint on the object's first word (its Magic) caught the writers in order:

  1. pyboundfn_new writes Magic — legitimate.
  2. PXXAlloc + 0x167 — the FREE-BIN zeroing loop. The allocator handed the block out again, so it had been freed.
  3. PXXStrConcat then builds a string in it, byte by byte.

Walking the stack past the release blob's 13 pushed registers named the freer: PXXPromoClear, called from build_base_vm.w_repeat, on a stack slot holding {tag = 1, payload = <the live bound-fn object>}.

PXXPromoClear releases the payload as a managed AnsiString whenever the tag word reads PROMO_TAG_HEAP (1). Its own header says it must not be run on uninitialised memory — "it tests the old tag and would release a garbage payload" — and that is exactly what happened: w_repeat returns early, its promo local at that depth was never written, and the frame garbage under it read as a heap-tier slot pointing at a live object.

Root cause: cleanup without init

EmitManagedLocalCleanup clears EVERY promo local at scope exit. Its counterpart EmitManagedLocalsZeroInit — the one owner of the zero-init contract, listing dynamic arrays, AnsiStrings, variants, managed records, COM interfaces and NilPy class locals — had no promo arm. Promo TEMPS were safe only because IRPromoTempSlot flags them SymIsHiddenArgTemp, which buys a prologue zero; named promo LOCALS got nothing.

One line, in the list where the other half of the contract already lives: TypeIsPromoInt -> zero TypeSize bytes, so the tag starts PROMO_TAG_INLINE.

Why it presented as a callable bug

Undefined behaviour again. The freed block is only fatal once the allocator REISSUES it, so BEGIN, WHILE, IF and THEN dispatched fine and REPEAT did not, with nothing distinguishing them in uforth's source. The visible failure was pyvar_callv1 finding neither closure nor bound-fn magic and jumping into string data — three layers and thousands of lines from a promo local in an unrelated function.

Verified

test/test_nilpy_promo_local_zero_init.npy (new). It DISCRIMINATES, and was A/B'd against MY OWN change rather than against pinned: rebuilding HEAD with and False on the new branch segfaults, with it the output matches CPython.

make compiler/pascal26 byte-identical · tools/gate.sh quick GREEN.

Also fixed here: select.select was a stub

Once the crash was gone, uforth ran but printed a UF> prompt on every piped line where CPython prints none. pyselect_select returned three EMPTY lists unconditionally — not a degraded answer but the wrong one, since uforth suppresses its prompt with if sys.stdin in select.select([sys.stdin], [], [], 0)[0].

Implemented for real: new PyPalPoll in pypal.pas (ppoll(2) — aarch64 and riscv32 have no plain poll, ppoll exists on all five targets, one number each), and pyselect_select now polls each entry and returns the ready subset. A NilPy sys.stdin IS its file descriptor (the integer 0), so an entry polls as itself and comes back as the same value — which is what makes the in test agree with CPython. None timeout blocks; a float timeout is seconds, as CPython spells it.

test/test_nilpy_select_stdin_ready.npy covers it.

make test-uforth: PASS

uforth compiles, boots, loads STD.UFO, and both smoke words evaluate (1 2 + . = 3, 10 3 / . = 3). [[bug-nilpy-uforth-compiles-but-segfaults-at-runtime]] is GREEN and closed.

Deeper uforth corpora are NOT green yet — tests/*.for hit a separate <lambda>() takes 0 positional arguments but 1 were given in pyeval on value / TO / create / allot. Filed separately; it is beyond this ticket's gate.

Log