← board

A Pascal function handed to a procedural parameter from NilPy is not a code address

unit ptrfld2;
interface
type
  TMakeFn = function(laden, room: Integer): Integer;
  TKindRec = class
    build: TMakeFn;
  end;
function TheMaker(laden, room: Integer): Integer;
function MkKindWired: TKindRec;
function MkKind(f: TMakeFn): TKindRec;
function CallItFromPascal(k: TKindRec): Integer;
implementation
function TheMaker(laden, room: Integer): Integer;
begin Result := laden * 100 + room; end;
function MkKindWired: TKindRec;
begin Result := TKindRec.Create; Result.build := @TheMaker; end;
function MkKind(f: TMakeFn): TKindRec;
begin Result := TKindRec.Create; Result.build := f; end;
function CallItFromPascal(k: TKindRec): Integer;
begin Result := k.build(5, 2); end;
end.
import 'ptrfld2.pas' as p
k = MkKind(TheMaker)                       # the value crosses NilPy -> Pascal HERE
print(CallItFromPascal(k))                 # SIGSEGV

The measurement that says where it is

how the field is wired called from Pascal called from NilPy
inside Pascal, Result.build := @TheMaker 502 502
from NilPy, MkKind(TheMaker) SIGSEGV SIGSEGV

Both call sites are identical between the rows — only the assignment moved. So the call lowering is fine and what lands in the slot is not a code address. The NilPy-side call is not the discriminator either: with the field wired in Pascal, NilPy calls through it correctly.

Receiver shape is NOT the discriminator here (unlike its neighbour bug-n-a-keyword-argument-through-a-procedural-field-needs-a-plain-receiver): k.build(5, 2) on a plain-name receiver and ks[0].build(5, 2) on a list element segfault identically.

Not new

Reproduced under stable_linux_amd64/default/stable_pinned (2026-09-06) with the same SIGSEGV, so it is in the pin and predates the 2026-09-11 keyword work at this door. Found while building a POSITIVE CONTROL for the new refusal in PyVariantFieldCallArm — the control was "positional through a typed procedural field still works", and it did not.

Likely shape, NOT verified

NilPy takes a function name as a VALUE through its own callable carriers (a tag-8 pair, a lifted bound-fn object), which is what makes g = f; g(1) work and what pyvar_callv_kw reads parameter names out of. A Pascal TMakeFn parameter wants a bare address. Somewhere on that hand-off the carrier is stored whole, or its payload is read from the wrong word. PXXDBG=a.ast: on the call, and the stored bytes, will say which — do not take this paragraph as the diagnosis.

Why it matters beyond the repro

This is the ordinary way a Python program configures a Pascal library: hand it a callback. It fails with no diagnostic at all, at a call that may be far from the assignment, and it is currently invisible to the suite — the callable-field fixtures all wire NilPy functions into NilPy-declared fields, which is the case that works.

2026-09-20 (frankS) — what is actually in the slot, measured. NOT fixed; I was moved to another group.

Banked rather than left in a transcript. Reproduced at 74ff679403b8, x86-64, exactly the repro above. The probes are three routines added to the unit -- SlotBits (q := @k.build; q^), Peek(addr, i) (word i at addr) and AddrOf(which) (@ of each unit routine) -- so every number below is read through Pascal, not inferred.

maker         5558117        <- @TheMaker
wired slot    5558117        <- Result.build := @TheMaker, and it CALLS fine
handed slot   0x7ab99f600050 <- MkKind(TheMaker) from NilPy

So the slot does not hold a mangled address: it holds a heap pointer. The carrier is stored whole, which is the "likely shape" paragraph's first branch.

And the second branch is ruled out -- the fix is NOT "read word 0 of the pair". Dereferencing the carrier:

word 0   @TheMaker + 2149     <- a code address, and NOT TheMaker's
word 1   0
word 2   -4294967295          = 0xFFFFFFFF00000001, a refcount/tag pair shape
word 3   another .text address
word 4   40
word 5   0

The four unit routines sit at @TheMaker + 0 / 102 / 200 / 325, so word 0 is none of them. Whatever code address the carrier holds, it is a wrapper or a different routine entirely, and a fix that loads word 0 into the slot would store a plausible, wrong, still-crashing address.

Where I would go next, in order: PXXDBG=a.ast: on the MkKind(TheMaker) call to see what the argument node IS, then find which routine lives at +2149 (a map file, or an AddrOf arm per candidate) -- naming that routine is what turns this from "the carrier is stored whole" into a diagnosis. Note the pointer VALUES move per run (ASLR); the deltas do not.

Untouched by me: PyCoerceCallableArgsIn (compiler/pyparser.inc ~26959-27275) and pycallback_* in pylib.pas. Ticket is unassigned and free.

And frankh-c0, who holds the frontend's inference pre-pass, says the object is the frontend doing what it thinks is right (2026-09-20, in reply to a topic check): "a NilPy def's value is a class instance, not a bare pointer, wherever the frontend has typed it ... The code address lives inside it." So the two halves are separable and this ticket is the second one: what lands in the slot once the CALLEE's signature says procedure, not what class the frontend inferred for the value. Do not take a heap pointer here as evidence of a lowering bug in the inference half.

Resolution (frankb-8e, 2026-09-20) -- commit 8826e6aec

MECHANISM. NilPy has exactly one place that coerces a callable VALUE into a raw slot, PyCoerceCallableArgsIn in pyparser.inc, and it knew about Pointer, cbuf and cptrarray shapes only. A procedural PARAMETER is none of those: ProcParamProcSig[procIdx * MAX_PROC_PARAMS + i] >= 0 marks it and nothing read that mark, so the carrier record built by PyMakeFuncValueFor was stored as-is. That record is a Python callable object, not a code address, so the slot held a heap pointer and every call through it -- from either language -- jumped into a record header.

FIX. A new first arm in PyCoerceCallableArgsIn, guarded on that mark, asks PyCarrierNamedProc which routine the carrier was built FOR and replaces the argument with a plain AN_PROCADDR to it. PyCarrierNamedProc reads the carrier's AN_PYSIGREF argument, whose ASTIVal is the original routine; it falls back to the first AN_PROCADDR in the carrier, which is safe because that fallback can only be the all-Variant $pycallwrap_* wrapper and a wrapper's signature cannot pass ProcSigCompatible.

The signature test is the existing ProcSigCompatible, so a routine of the wrong shape is not silently coerced: it gets a named warning instead (suppressed during PyTypingPass, which runs the same site twice).

WHAT IS NOT FIXED, and it is the thing the ESP demo actually wants: this delivers a PASCAL routine's address into a Pascal procedural slot. A NilPy def handed to a C or Pascal callback slot still does not work, because the code address that would have to be materialised is the carrier's, and a NilPy def has no native-ABI entry point of that signature. Filed separately; frankH owns the half that changes how the carrier is BUILT.

FIXTURE. test/test_nilpy_function_into_a_procedural_parameter.npy with test/nilpy_units/procslot.pas, wired into test-nilpy, six rows all 502: wired-with-@ vs handed-as-a-name, crossed with called-from-Pascal and called-from-NilPy, plus a list-element receiver so the coercion is not only exercised through a direct local. Positive control on stable_linux_amd64/default/pinned: segfault. The wired rows are the arm that must NOT change and they are green under both binaries.

GATE. make compiler/pascal26 converged; gate.sh quick GREEN; full test-nilpy GREEN at compiler f99f37bcebe2.

Log