← board

pyeval refuses a host method whose params mix variants and scalars

pyeval: host method define_word has an unsupported param shape

compiler/builtin/pyeval.pas (~line 690) recognises exactly two shapes when a PYTHON-bodied block calls back into a compiled host method:

Anything else prints the message above and fails the call. uforth's

def define_word(self, name: str,
                native: Optional[Callable[["VM"], None]] = None,
                forth_body=None, immediate: bool = False, wid=None) -> Word:

is neither: name is an AnsiString and immediate a Boolean (register family), while native is a variant — which is what a Callable[...] PARAMETER has lowered to since [[bug-nilpy-bound-method-cannot-pass-through-a-callable-parameter]], because a bare pointer has no room for a bound method's {code, receiver} pair.

The comment in pyeval still names this very method as the pointer-family's driver ("uforth's define_word(name: str, native: Callable, forth_body, immediate: bool) -> Word is the driver"). That comment is stale: it predates the Callable-parameter change, and the shape it describes no longer exists.

Attribution — pre-existing, only newly REACHED

Not caused by 2026-08-08's Callable-FIELD unification. That change made a Callable FIELD a variant; the PARAMETER arm of PyAnnTypeAt is behaviourally unchanged by it (a parameter answered tyVariant before and answers tyVariant after — the diff only removed the guard that made a FIELD differ). uforth simply never executed this far before: it crashed in pyboundfn_callvn, and then STD.UFO's colon definitions failed, ahead of the first PYTHON-bodied word that calls back into the host.

Shape of the fix

The two families are one special case too many. Decide per ARGUMENT rather than per SIGNATURE: walk the parameter kinds and place each one the way its own kind requires — a variant by address, a register kind in a register — which subsumes both existing families instead of adding a third. The omitted-trailing-parameter defaults the pointer family already fills (None -> nil, False -> 0) need the variant equivalent (pynone), the same substitution ir.inc's ProcParamDefaultIsNone branch makes.

While there: the diagnostic should print the offending kinds and the parameter index. "unsupported param shape" alone cost a bisect to turn into the sentence above.

Note: this is a BUILTIN

compiler/builtin/pyeval.pas is compiled INTO the compiler, so a change here needs make stabilize + make pin before the gate's self-host fixedpoint sees it — prove it landed via the sh-A/sh-B map diff, not by assuming.

Gate

A .npy with a PYTHON-bodied block calling a host method whose parameters mix a variant with scalars, in both argument orders and with trailing parameters omitted, oracle-diffed with tools/pydiff.py; plus make test-uforth getting past STD.UFO; plus the per-fix loop and the stabilize/pin above.

RESOLVED 2026-08-08 — one register family, not two, plus a latent result bug

The fix: a variant argument IS a register argument

compiler/builtin/pyeval.pas. The two families were never really two: what pxx passes for const a: Variant is the ADDRESS of the 16-byte slot — one pointer-sized value in an integer register, exactly like an int, a class reference or an AnsiString's data pointer. So TK_VARIANT joins the accepted kinds and a variant argument is marshalled as @slot; nothing else about the call changes. The third family the ticket warned against is not needed, and the "pointer family" comment naming uforth's define_word as its driver — stale since Callable parameters became variants — is now accurate again.

Two details that are not decoration:

And a latent bug found in the same block

The register family had ONE thunk, returning Int64. A method returning an AnsiString therefore had its string handle boxed by pyvar_of_int and came back to the caller as an INTEGER, silently. A Variant return, which travels through the hidden destination, could not have worked either. Both now have their own thunk set (TPSFn0..5, TPVFn0..5), chosen by RESULT kind — the arguments no longer influence the choice, since they are all one register wide by the time the call is made.

The refusal diagnostic now prints the arity and every parameter's kind. The bare "unsupported param shape" cost a bisect to turn into a sentence.

Verified

test/test_nilpy_pyeval_host_mixed_params.npy (new — no existing pyeval test covers host param shapes; the two that exist are the ** grammar and the missing-vm-key case). Covers a variant argument supplied, a variant argument OMITTED, a class argument omitted, an AnsiString result, a class result, and the callable that travelled through the bridge being stored and then CALLED.

It discriminates — stable_linux_amd64/default/pinned dies with Unhandled exception: TypeError: expected a number, got <unknown>.

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

NOT re-pinned, deliberately

compiler/builtin/pyeval.pas is frozen into stable_linux_amd64/default/builtin/ by make pin, so the PINNED binary keeps the old copy until someone pins. That matches how pyeval fixes have landed before (3 of the last 4 did not move the frozen copy) and the self-host fixedpoint is unaffected — the compiler does not use pyeval, only NilPy programs do, which is the same reason pylib changes do not trip the A != B gate step. Pinning is a deliberate brake; this fix will ride the next make stabilize + make pin.

uforth: past the refusal, now a WRITE AFTER FREE

make test-uforth still red, in a third place. The bridge no longer refuses anything; uforth now loads far enough to compile IO.UFO and dies dispatching an IMMEDIATE word, with -dPXX_HEAP_DEBUG reporting WRITE AFTER FREE and the recycled block coming back as a list's element storage.

Measured NOT to be this fix: with the bridge instrumented to print every variant it marshals, the write-after-free is reported on line 1 of the output, before the first mixed host call happens, and the freed address matches none of the objects that later pass through it. Filed as [[bug-nilpy-write-after-free-on-a-callable-held-in-a-dataclass-field]]; [[bug-nilpy-uforth-compiles-but-segfaults-at-runtime]] now blocks on that.

Log