← board

A bound method cannot be passed through a Callable[...] parameter

class C:
    def m(self, x: int) -> int:
        return x + 100

def apply(f: Callable[[int], int], v: int) -> int:
    return f(v)

apply(C().m, 5)
TypeError: parameter f is declared Callable[...], which carries a code address
only, and a BOUND METHOD also needs its receiver — pass a plain function, a
lambda, or declare the parameter without an annotation

CPython prints 105. The residual of [[bug-nilpy-callable-annotated-param-segfaults-on-a-heap-callable]], which fixed the plain def, the lambda/closure and the lifted bound-fn cases (all three segfaulted); this one is a representation limit rather than a coercion bug, so it was split off rather than bodged.

The workaround works and is what the message says: drop the annotation. def apply(f, v): return f(v) takes a bound method, a lambda and a def alike — an unannotated parameter is a VARIANT, and the dynamic-call path (pyvar_callv<n>) already tells all four callable shapes apart, receiver included.

Why

Callable[...] registers a $proctype signature and types the parameter tyPointer (PyAnnTypeAt, the callable branch). A bound method is a {code, receiver} PAIR — 16 bytes — and a pointer parameter has room for the code half only. Dropping the receiver would call the method with whatever happened to be in the Self register, which is the silent-wrong-value class this project refuses.

Shape of a fix

Type a Callable[...] PARAMETER as tyVariant and let the call go through PyMakeDynCall — the same dispatcher the unannotated form already uses, which handles all four shapes. The annotation then informs arity and diagnostics rather than the ABI, which is what it does for the RESULT already (the declared result is documented as a hint, not an ABI — see the callable branch's own comment).

The catch to check first: a Callable FIELD must stay tyPointer. uforth's native: Callable[[VM], None] is read as a code address by the field-call path (PyWrapClosureFieldCall), so the change has to distinguish the PARAMETER context from the FIELD context inside PyAnnTypeAt, or move the decision to the two callers.

Gate

make test-nilpy plus the existing test/test_nilpy_callable_param_heap_callable.npy extended with a bound method and a bound method stored in a list, diffed against CPython — and uforth still green, since it is the Callable-field user.

2026-08-04 — BOTH the title and the documented workaround are wrong

Picked this up to do the fix it describes (type a Callable[...] PARAMETER as tyVariant, keep a FIELD tyPointer). Implemented it — PyAnnParamScope set around the parameter-annotation read, the $proctype signature dropped when the parameter comes back variant so the call goes through the dynamic dispatcher — and then measured, which stopped it.

The Callable annotation is a red herring

The documented workaround does not work. def apply(f, v): return f(v), with no annotation anywhere, takes a bound method and segfaults — on stable_linux_amd64/default/pinned as well, so this is not new:

class C:
    def m(self, x): return x + 100
def ap(f, v): return f(v)
print(ap(C().m, 5))        # SIGSEGV. CPython: 105

So the ticket's "the workaround works and is what the message says" is false, and typing the annotated parameter as a variant only makes the annotated form behave exactly like the unannotated one — i.e. it removes an ABI limitation that was not the thing standing in the way. Reverted rather than landed: it changes the ABI of every Callable[...] parameter for no user-visible gain, which is the wrong trade.

The real boundary, measured

shape result
C().m(5) — direct call 105, correct
g = C().m then g(5) crashes
fs = [C().m] then fs[0](5) crashes
ap(C().m, 5) — unannotated parameter crashes
ap(lambda x: c.m(x), 5) — wrapped 105, correct

So it is not about parameters, annotations or the Callable ABI at all: a bound method cannot be used as a VALUE anywhere. Assigning it, putting it in a list and passing it all fail the same way, and the only thing that works is wrapping it in a lambda — which is the workaround the diagnostic should have named.

Where to look next

pyvar_callv<n> is documented as telling all four callable shapes apart, receiver included, so the gap is more likely in CREATING the {code, receiver} pair when C().m appears in a value position than in calling it — pyboundfn_new/PXXObjIsBoundPair (pyeval.pas) are the machinery that exists for exactly this. Check whether a bare obj.method in a value position builds one at all before touching the call side.

Retitled in spirit: the Callable parameter is one symptom of "a bound method is not a first-class value". Returned to backlog/ with that correction rather than left as a parameter-ABI ticket, since fixing it there would have produced a change that helps nobody.

2026-08-07 — the 2026-08-04 correction is ITSELF stale; the ORIGINAL title was right

Re-measured before doing anything, and the boundary has moved. The 2026-08-04 note concluded "a bound method cannot be used as a VALUE anywhere" and returned the ticket to backlog on that basis. That is no longer true — the callable-value work since then fixed it:

shape 2026-08-04 2026-08-07 (before this fix)
C().m(5) direct 105 105
g = c.m then g(5) crash 105
fs = [c.m] then fs[0](5) crash 105
ap(c.m, 5) unannotated param crash 105
ap(lambda x: c.m(x), 5) 105 105
apply(c.m, 5) annotated (the original bug) still raised

So the only surviving failure was the one the ticket was originally filed for, and the fix it originally proposed — type a Callable[...] PARAMETER as tyVariant, keep a FIELD tyPointer — became the right one, because the "workaround" it depends on (an unannotated parameter) now genuinely works. The 2026-08-04 session rejected that fix for removing "an ABI limitation that was not the thing standing in the way"; it is now the only thing standing in the way.

The change

PyAnnParamScope, mirroring the existing PyAnnRetScope exactly: set around a parameter's annotation read (the three sites that store a parameter type — the def header and both method paths, which must agree or the ABI silently mismatches). The callable branch of PyAnnTypeAt returns tyVariant under it, and drops the $proctype signature with it — keeping the signature would marshal the call against a procedural type again, which is the very thing with no room for a receiver. A FIELD sees the flag clear and keeps both.

All six rows above now match CPython.

The named risk, checked: the Callable FIELD path is unaffected

uforth's native: Optional[Callable[["VM"], None]] shape, exercised directly:

binary result
PINNED (pre-change) inc 2
this change inc 2
CPython inc 2

make test-uforth could NOT serve as the gate: it fails identically on the PINNED binary at uforth.py:411"no class declares a method or callable field .to_bytes()" — so the corpus was already red for an unrelated reason. Controlled rather than assumed, and not attributed to this change.

A pre-existing crash found and filed, not folded in

ap(C().m, 5) — a bound method of a TEMPORARY receiver — segfaults, on the pinned binary and through an UNANNOTATED parameter too. That is receiver lifetime, not the callable ABI, so it is [[bug-nilpy-bound-method-of-a-temporary-receiver-segfaults]] and the row is left commented out in the test naming that ticket, rather than blaming a pre-existing crash on this commit.

Test

test/test_nilpy_callable_param_heap_callable.npy extended as the gate asked — a bound method through the annotated parameter, one via a list element, one bound to a name, two instances keeping their own receivers, and the Callable FIELD path at the bottom. 11 lines byte-identical to the CPython oracle.

Gate

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

Log