← board

A lifted closure reads its slots at the wrong width on 32-bit targets (from 2 slots on i386/arm32, from 1 on xtensa)

Repro (HEAD 6eaabf48c, compiler sha 4e32f1dde0ec)

add2 = lambda a, b: a + b
print(add2(2, 3))

What the arity boundary looks like:

Mechanism

The fix is an ABI decision, not a bridge tweak. Repacking the words in the runtime would have to follow each target's 64-bit argument alignment: arm32 AAPCS and xtensa put an Int64 in an even register pair. It would also need per-slot widths that the object does not record (BKindMask holds kinds, not widths).

The normalising fix is for the lifter to declare every lifted slot as one 64-bit word on every target, and re-type it inside the body. That makes the bridge's "one Int64 per slot" contract true everywhere, instead of only where a pointer is 8 bytes. Check the other producers of bound-fn bodies before choosing: the nested-def lifter (pyparser.inc ~11405, ~11876, ~12046, ~12193) and the callback bridges.

Acceptance

Found 2026-09-24 (frankH) by the first NilPy-corpus census on a real ESP32-S3 board.

Resolution (2026-09-24, frankH, Track N)

Two causes, both 32-bit-only, both invisible on x86-64/aarch64.

  1. Slot width. PyBoundFnEntry(realPi) returns realPi on a 64-bit target. Otherwise it registers $pywordthunk_<pi> with one Int64 param per slot and queues it with PyPendLamTok = -3. The bStart = -3 arm in PyCompileLambdaBody builds return REAL(conv(w0), ...), where a by-ref or Variant slot becomes deref(PVariant(wk)) through a typed pointer local, and anything else is the word narrowed to the slot's type. All three AN_PROCADDR sites use it: the lambda lifter, PyMakeBoundFnValue and PyNestedDefClosureValue. The thunk also sets the real lambda's Variant params IsRef/const itself, because the LIFO queue compiles the thunk BEFORE the lambda body writes those flags. Without that, the pointer was boxed into a fresh Variant and the lambda saw an empty value. That was the thunk's first draft. The xtensa register-pair explanation above was never confirmed by disassembly and did not need to be: the thunk removes the mismatch whatever the ABI does with it.
  2. Untyped param in a nonlocal cell (the shared e row of nonlocal_escaping_closure). It was NOT the bridge: c was empty BEFORE any call, and even with no call (g = [bump]). The IR was identical to x86-64's, so the defect was in lowering. EmitLoadVariantAddr386 answered lea [ebp+slot] for a load_sym of the POINTER-typed cell symbol, and the Variant copy read 16 bytes of stack. x86-64's var_store asks IREmitNode and never reaches its own twin. The boundary, measured: an int param worked, a local Variant cell worked, and only an untyped PARAM failed, because only that path copies $byref.c into a heap cell and then reads the cell back through the pointer symbol. Fixed by the same rule in EmitLoadVariantAddr{386,Arm32,RISCV32,Xtensa}: a pointer-typed symbol's VALUE is the slot address.

Verified:

Log