← board

map(obj.method, xs) segfaults

Repro

class Box:
    def __init__(self, k):
        self.k = k

    def scale(self, x):
        return x * self.k


b = Box(3)
print(list(map(b.scale, [1, 2])))     # CPython: [3, 6]

SIGSEGV (and SIGILL for the inline map(Box(3).scale, ...) spelling).

It is PRE-EXISTING, measured

The control removes the variable rather than renaming it: the same file built with the pinned binary (v263, whose map is still the eager list) crashes identically. So this is the callable dispatch, not the cursor.

callable passed to map result
a plain def works
a lambda (an interpreted pyeval source closure) works
a builtin (str) works
a bound method (obj.method) SIGSEGV, both eager and lazy

Three of the four representations PyCallKey1 enumerates are fine; the bound-pair arm is the one that faults ([[project_nilpy_callable_has_three_representations]]).

Where to look

PyCallKey1 (pyeval) branches on PXXObjIsBoundPair(key) first and calls m1(recv, a0). sorted(key=obj.method) goes through the same entry, so whatever is wrong is likely visible there too — worth checking whether that spelling crashes as well, since it would say whether the fault is in the dispatch or in how map's arm BUILDS the key from obj.method (PyGetOrMakeCallableWrapper vs the bound-method value path).

The acceptance test test/test_nilpy_lazy_map_filter.npy covers the other three shapes and carries a comment pointing here for the fourth; add the row back when this is fixed.

2026-08-13 — FIXED. Three sites, one concept

The ticket's own suggestion — "check whether sorted(key=obj.method) crashes too, since that says whether the fault is the dispatch or how map BUILDS the key" — was the right first measurement. It does not crash: it raised a TypeError. That split the bug in two, and a third fell out of the test.

1. The refusal was obsolete. pyvar_callable_ptr (the coercion every variant argument to a Pointer parameter goes through) raised for a bound method: "carries a code address only, and a BOUND METHOD also needs its receiver". True of the bare code address, false of the value it actually has — the variant's payload is pybound_new's {code, recv} PAIR, and PyCallKey1's FIRST test is PXXObjIsBoundPair on exactly that pointer. Every consumer of a key: Pointer parameter here dispatches through PyCallKey1 (sorted/min/max directly, map/filter via PyIterCallHook), so handing over the pair is what they want. sorted(xs, key=obj.method) works with the refusal removed.

2. map/filter never coerced at all — that is the segfault. Their intercepts build the call by hand, so the generic Pointer-parameter coercion never ran and the raw VARIANT landed in key: Pointer: the TAG word (8) became the code address, and the call jumped to 8. PyMakeCallablePtrArg now wraps a variant callable argument at both intercepts.

3. A method read as a VALUE off a VARIANT receiver raised AttributeError. Found by the test's last row ([list(map(x.scale, ...)) for x in boxes], where the comprehension variable is a variant) and confirmed pre-existing on the pinned binary. CALLING it worked, because the frontend resolves the call; only the value form reached pydynattr_get_v, which knew about fields and dynamic attributes but not methods. It now binds one via pybound_new off the RTTI method table (PyFindMethByName, which PyFindDunder was a duplicate of and now calls). Safe by construction: a method whose name is read as a value is already normalised to the all-variant ABI by PyMethodUsedAsValue, which keys on this exact spelling.

Found while sweeping, filed, not fixed here

[[bug-nilpy-min-max-with-a-key-held-in-a-variable-picks-the-numeric-overload]] — min(xs, key=f) with the key in a VARIABLE picks the two-argument numeric overload and raises "expected a number, got object". Not bound-method specific (a plain def in a variable fails identically) and identical on the pinned binary, so it is overload SELECTION rather than this ticket's dispatch.

Gate

test/test_nilpy_map_over_a_bound_method.npy + .expected from CPython, wired into make test-nilpy: all four callable representations through map, the inline Box(4).scale spelling that used to SIGILL, a bound method through filter and sorted, a chained sum(map(..., filter(...))), the method held in a name, and two instances mapped from one call site (which is what proves the RECEIVER travels). The row that this ticket removed from test_nilpy_lazy_map_filter.npy is back, so the four representations are visible together there too. make test-nilpy green, gate.sh quick GREEN.

Log