← board

A Callable field typed only by a ctor parameter carries no signature

from typing import Callable

class VM:
    def __init__(self):
        self.n = 0

class Word:
    def __init__(self, native: Callable[[VM], None]):
        self.native = native            # no class-level annotation

def push1(vm):
    vm.n = vm.n + 1

vm = VM()
w = Word(push1)
w.native(vm)                            # SIGSEGV
print(vm.n)                             # CPython: 1

Pre-existing, and NOT the dataclass bug

Identical on stable_linux_amd64/default/pinned. Split out of [[bug-nilpy-call-through-a-dataclass-callable-field-segfaults]], which was a different cause (a -> None $proctype registered as a procedure, an ABI lie that crashed on return) and is fixed — the dataclass shapes all pass now while this one still fails.

Cause, already measured

A probe on the two field-registration sites showed:

PROBE selfassign field <name> tk=… fldSig=-1

The self.x = … scan (pyparser.inc ~19717) registers the field from the ASSIGNMENT, and the procedural signature it would need lives on the ctor PARAMETER's annotation, which that scan does not consult. So UFldProcSig is -1, PyWrapClosureFieldCall has no signature to marshal against, and the call jumps through the raw slot.

Give a class-level annotation and it works — but requiring one is not the fix; CPython needs no such thing.

Shape of a fix

When the self.x = <param> scan registers a field whose right-hand side is a PARAMETER carrying a $proctype (the parameter annotation recorded one), carry that signature onto the field, the way the dataclass site already carries PyAnnLastProcSig. The two sites are ~500 lines apart and each knows half the answer — worth checking whether they should share one helper rather than growing a third copy.

Gate

Per-fix loop, plus the repro above added to test/test_nilpy_callable_field_call_returns.npy (which currently covers only the dataclass shapes), oracle-diffed.

FIXED 2026-08-07 — and the cause in this ticket was WRONG: it was MY regression

The ticket says the field is registered with fldSig = -1 because the self.x = … scan does not consult the parameter's annotation. Measured, and that is false. A probe on that exact site:

PROBE hdrparam rhs=native tk=17 sig=1211

PyHeaderParamType already reads the parameter's annotation and the site already captures PyAnnLastProcSig — the field gets a real signature. The recorded cause was inferred from an earlier probe that happened to show a DIFFERENT field (self.n = 0, an int, legitimately -1).

What it actually was

A regression from [[bug-nilpy-bound-method-cannot-pass-through-a-callable-parameter]], landed the same day. That change gave a Callable[...] PARAMETER the VARIANT ABI so a bound method could travel through one. PyHeaderParamType — which types a FIELD from such a parameter — was not told, and kept answering tyPointer. So self.native = native stored a variant's tag word into a pointer slot, and the later call jumped through it.

It hid because it was masked. The -> None procedure-ABI bug ([[bug-nilpy-call-through-a-dataclass-callable-field-segfaults]]) crashed this shape too, on the pinned binary as well — so when it was controlled against pinned it looked pre-existing and was split out as a separate ticket. Only fixing the ABI bug unmasked it, and the A/B that proved it was disabling the parameter change alone and watching both repros go green.

Worth keeping: a control against PINNED does not prove a bug is not yours when two causes produce the same symptom. The pinned comparison was run correctly and still gave the wrong answer.

Fix

PyHeaderParamType reads the annotation under PyAnnParamScope, so a field typed from a parameter agrees with the parameter's real ABI. One line, plus the note explaining why it must stay in step.

Measured

Ten Callable-field shapes now pass, all oracle-diffed: dataclass (through the generated ctor, without Optional, without a default, assigned afterwards, value-returning), plain class with a class-level annotation, plain class typed ONLY by the ctor parameter, two callables on one instance, a variant receiver, and Optional[Word] method calls. The bound-method-through-a-Callable-parameter fix that started this is intact (mat repro, all six rows).

Test: test/test_nilpy_callable_field_call_returns.npy, 10 lines byte-identical to the CPython oracle.

uforth still segfaults

Same place, uforth.py:840. Every Callable-field shape derived from that line now passes, so it is something else there.

Gate

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

Log