Files
sx/issues/0073-closure-literal-in-defer-segfault.md
agra 08f263c6e4 fix(ir): open a fresh defer window when lowering a lambda body (issue 0073)
A closure literal declared inside a `defer` body segfaulted the compiler.
Root cause: lowerLambda never opened its own `func_defer_base` window. Every
other function-lowering entry (lowerFunction / monomorphizeFunction /
monomorphizePackFn) saves func_defer_base, sets it to defer_stack.items.len, and
restores it — lowerLambda didn't. So a lambda's `return` drained the ENCLOSING
function's defers; when the defer body itself declared the lambda, draining
re-lowered the lambda, which returned, which drained again → infinite recursion
→ stack-overflow SIGSEGV (the failable variant surfaced one frame out, in
expandCallDefaults→lookupFn reading a clobbered scope).

Fix: lowerLambda now saves func_defer_base + the defer_stack length, sets the
base to the current length (a fresh window), and restores both on exit — so a
lambda's `return` drains only its own defers.

Regression: examples/0310-closures-closure-literal-in-defer.sx — a closure
declared and called inside a `defer`; verifies `body` then `defer closure: 42`
at scope exit (exit 0). Issue 0073 marked RESOLVED; repro promoted from
issues/0073-*.sx.

zig build, zig build test, tests/run_examples.sh (358/0) all green.
2026-06-02 23:29:49 +03:00

5.0 KiB

issue 0073 — closure literal inside a defer body segfaults the compiler

RESOLVED (2026-06-02). Root cause: lowerLambda never opened its own defer window. Every other function-lowering entry (lowerFunction, monomorphizeFunction, monomorphizePackFn) saves func_defer_base, sets it to defer_stack.items.len, and restores it — but lowerLambda didn't, so a lambda's return drained the enclosing function's defers. When the defer body itself declared the lambda, draining re-lowered the lambda, which returned, which drained again → infinite recursion → stack-overflow SIGSEGV. Fix: lowerLambda now opens a fresh defer window (save func_defer_base + defer_stack length, set base to the current length, restore both on exit) — src/ir/lower.zig. Regression test: examples/0310-closures-closure-literal-in-defer.sx (a closure declared + called inside a defer; verifies body then defer closure: 42 at scope exit). Suite 358/0.

Symptom

One-line: declaring a closure literal inside a defer body crashes the compiler with a segfault during lowering.

  • Observed: sx run / sx build SIGSEGVs in lowerLambda (src/ir/lower.zig) while lowering the enclosing function. With a failable closure (() -> !E { ... }) the crash surfaces one frame out, in lowerCallexpandCallDefaultsscope.lookupFnhash_map.get (a corrupted/garbage scope pointer), suggesting a stale/clobbered self.scope (or builder/current-function state) while the deferred body is lowered.
  • Expected: the program either lowers normally or produces a clean diagnostic. A compiler segfault is never acceptable, regardless of whether the shape is intended to be supported.

Isolation (all on arch-refactor, current HEAD):

Probe Shape Result
(a) failable closure declared + cb() catch e {}no defer OK (exit 0)
(b) failable closure literal inside a defer body SIGSEGV (lowerCall/expandCallDefaults)
(c) non-failable () { return; } inside a defer body SIGSEGV (lowerLambda)
(d) failable closure literal inside a plain { … } block (not defer) OK (exit 0)

So the trigger is a closure literal lowered inside a defer body — not failability, not whether the closure is called. (a)/(d) prove closures and failable closures lower fine outside a defer; (c) proves a bare non-failable closure in a defer is enough to crash.

Reproduction

issues/0073-closure-literal-in-defer-segfault.sx (minimal — non-failable, uncalled closure in a defer):

#import "modules/std.sx";

work :: () {
    defer { cb := () { return; }; }
}

main :: () -> s32 {
    work();
    return 0;
}

Run: ./zig-out/bin/sx run issues/0073-closure-literal-in-defer-segfault.sx → "Segmentation fault" with a stack through lowerLambda (src/ir/lower.zig).

Investigation prompt

A defer body is captured at its declaration site and lowered into the function's exit/cleanup path (drained by lowerReturn / block-exit), not inline. Lowering a lambda literal while emitting a deferred body appears to run with invalid lowering state — most likely self.scope (note probe (b)'s crash is in scope.lookupFn reading a hash map at a garbage address) and/or the builder's current-function / block context is stale or not the one lowerLambda expects when it allocates the closure's trampoline + env.

Suspected area: src/ir/lower.ziglowerLambda (~:8145) and the defer capture/replay path (defer_stmt handling + the cleanup-drain in lowerReturn / block exit; grep defer_stack / func_defer_base). Check whether deferred bodies are lowered:

  1. with a scope pointer that has since been popped/freed (use-after-free → garbage fn_names/map in lookupFn), or
  2. in a builder state where lowerLambda's assumptions (current function, insert block) don't hold, or
  3. re-entrantly / unbounded (the failable-variant trace looked like deep recursion through expandCallDefaultslowerCalllowerLambda).

Likely fix shape: lower deferred bodies under a valid, live scope (re-establish or retain the declaring scope when replaying the defer body), or defer the lambda's trampoline emission to a context that has the right function/block.

Verification step: run the repro above — expect it to lower cleanly (or emit a clean diagnostic) and NOT segfault. Then confirm a closure that is actually used inside the defer (defer { cb := () { ... }; cb(); }) also works, and re-run bash tests/run_examples.sh (357/0) to confirm no regression.

Provenance

Found while writing an A5.2 (architecture stream) test-first scaffolding example for the ERR E1.7 "cleanup-absorption stops at nested closures" behavior — the closure-boundary probe (a closure literal inside a defer) crashed the compiler instead of exercising the diagnostic. The crash is a pre-existing lowering bug, unrelated to the A5 error-analysis extraction; surfaced by the probe.