← board

def f(): s = "ab"; for ch in s: print(ch) SEGFAULTS

def f():
    s = "ab"
    for ch in s:
        print(ch)
f()
# CPython: a / b     pxx: SIGSEGV

The trigger is the BODY SHAPE, not the loop variable

Refined after the first pass: the loop variable is irrelevant. print of a CONSTANT crashes just as well, and any ASSIGNMENT anywhere in the body cures it.

loop body (inside a def, iterating a str local) pxx
print(ch) SIGSEGV
print("x") — the loop variable is not even used SIGSEGV
print("x") then print("y") SIGSEGV
print(ord(ch)) SIGSEGV
z = 1 then print("x") correct ✓
print("x") then z = 1 correct ✓
c2 = ch then print(c2) correct ✓
out = out + ch (no print at all) correct ✓

So: a loop body consisting ONLY of print statements. One assignment, before or after, in any position, makes it work.

What the crash actually is — an UNINITIALISED managed local being released

CORRECTION to an earlier reading in this ticket: rip = 0x400181 looked like a jump into the ELF header, i.e. a bad call target. Disassembling there shows it is nothing of the sort — it is the managed-string RELEASE helper, which simply lives at a low address in this ELF layout:

=> 0x400181:  decq   -0x10(%rax)     ; refcount at [handle-16]
   0x400185:  jne    0x4001ab
   0x400187:  sub    $0x10,%rax      ; ...else fall through to free
   0x40018b:  push   %rsi

So the fault is a DATA dereference with a garbage %rax: a managed string is being released through an uninitialised handle. PXXDBG=n.locals shows both s and ch as tk=23 managed locals, and the loop desugar adds a managed hidden local (__py_c_N) as well.

That puts it squarely in the recorded zero-init landmine family (project_nilpy_method_result_not_zeroed_landmine, project_nilpy_object_reclamation_arc's "mid-body-local zero-init landmine"): a managed slot that the prologue does not zero, so the first store's release of the "previous" value dereferences whatever the frame happened to contain.

It is frame-layout dependent, which is why every neighbouring shape works — adding ANY other local shifts the slot onto bytes that happen to be zero:

change pxx
the repro SIGSEGV
add t2 = "cd" (a second managed local) correct ✓
add z = 1 (an int local) anywhere in the body correct ✓
iterate a LIST local instead correct ✓
iterate a str PARAMETER instead correct ✓
while i < len(s) instead of for ch in s correct ✓

PXXDBG=a.ir:f confirms the crashing and working variants are structurally IDENTICAL apart from the extra store_sym, so the IR is right and the defect is in what the prologue zero-initialises for this frame shape.

Next step: -dPXX_HEAP_DEBUG makes freed bytes $DD rather than a recycled neighbour's, which should show whether the handle is stale-freed or never written; then compare the two prologues under -g in gdb.

The shape is very narrow — and every neighbour works

variant pxx
the program above SIGSEGV
the same loop with print removed (count only) correct ✓
for ch in "ab": — literal inline, no local correct ✓
the same three lines at TOP LEVEL (not in a def) correct ✓
def f(s): — the string as a PARAMETER, then iterate and print correct ✓
def f(s): param, ord(ch) in the body correct ✓
def f(s): param, building a string from ch correct ✓

So it needs all three of: inside a function, iterating a str LOCAL (a named variable, not a literal), and using the loop variable in print.

Found via a caesar-cipher program, which is why it matters: for ch in s over a local is how you write almost any string-processing helper.

Provenance — bisected, NOT from this session's work

Bisected by rebuilding the compiler at successive commits and running the repro:

built at result
the 2026-07-27 stable BINARY prints a b
acab84ed8 — this session's starting commit, before any change of mine SIGSEGV
7e90f99e3 (first change of the session) SIGSEGV
943f590c7 (mixed-type operands) SIGSEGV
current HEAD SIGSEGV

The session's starting commit already crashes, so none of this session's fixes introduced it. The 07-27 stable BINARY differs because it compiles against its own frozen stable_linux_amd64/default/builtin/, not the working tree's compiler/builtin/ — so the regression window is between 2026-07-27 and acab84ed8, in the builtin/pylib or frontend work landed in that period.

Worth bisecting that window properly with tools/bisect-style rebuilds rather than guessing; the window is small and the repro is a five-line file.

Note

make test-nilpy is green across all 230 .npy tests, so this shape is not covered anywhere in the suite — worth adding regardless of who fixes it.

Gate

make test-nilpy + self-host byte-identical, plus a .npy with the table above against CPython's own output. Keep the neighbour rows: they are what makes the shape identifiable if it regresses again.

Log