← board

What it looked like

lekkerzeilen died on SIGSEGV at rip=rsp=rbp=0 after printing its whole startup banner. Line-stepping under -g -O2 put the last executed line at app.py:834, sys.setswitchinterval(0.0005) -- a sys attribute we did not have, so the correct behaviour there was an AttributeError. The refusal is what crashed, not the call: the raise read a stale chain head.

The two-line version of the same call refuses cleanly (rc=217, the right message). So did a package-shaped one, and one with threading imported, and one inside a method inside an if. The route mattered and the shape did not -- the discriminator was that lekkerzeilen had already returned out of an except handler somewhere earlier in its startup.

The measurement

At the breakpoint, gs: slot 8 (TLS_SLOT_EXC_TOP) held 0x7fffffff8760 -- 0xb30 bytes BELOW rsp, i.e. inside stack that had already been popped. The frame there was all zeroes, so the unwinder's restore loaded 0 into rip, rsp and rbp. rdi at the fault was 0x7fffffff8768, the frame address plus 8, which is what named the restore as the faulting step rather than a call 0 (a call through a null pointer leaves rsp valid).

Reduced to five lines, both frontends:

def h():
    try:
        raise ValueError("x")
    except ValueError:
        return 1            # <-- the leak
print("A", h())
raise KeyError("boom")      # <-- rc=139, nothing printed
function H: Integer;
begin
  try raise Exception.Create('x');
  except on E: Exception do begin H := 1; Exit; end; end;
end;
begin WriteLn('A ', H); raise Exception.Create('boom'); end.

break out of the handler does it too. except: pass falling out the bottom does NOT -- the fall-through arm always emitted its EXC_LEAVE.

Why nothing caught it for so long

An enclosing try hides it completely. Entering any try overwrites the chain head, so try: h(); raise ...; except: ... around the identical calls passes on the broken compiler. The stale head is only ever READ by a raise with nothing above it -- and by then the failure is a silent segfault, so the observable is the ABSENCE of a diagnostic, which no expect_same row over stdout can see. Every value the reproducers print was correct throughout.

The second defect is a leak on the same path, and it is the same blindness in the other direction: pin v409 gives allocs=5411 frees=408 live=5003 where HEAD gives frees=5408 live=3 on byte-identical output.

The fix

compiler/ir.inc, AN_TRY_EXCEPT: the handler body's IR_EXC_ENTER now also sets ExceptionFinallyBody[depth] := -1 and Inc(ExceptionCodegenDepth), with Dec before the matching IR_EXC_LEAVE. IRLowerCleanupToDepth gained the free and the clear, driven by two new arrays in defs.inc (ExceptionCaughtObjTmp / ExceptionCaughtClsTmp) that are -1 for every region that is not a handler body. So an early exit now emits EXC_LEAVE, then IREmitCaughtExcFree, then IR_EXC_CLEAR -- exactly the fall-through's sequence, in the fall-through's order.

Rows

All three fail on pin v409 and pass at HEAD.