Files
sx/issues/0073-closure-literal-in-defer-segfault.md
agra d8076b9333 lang: rename signed integer types sN -> iN
Surface rename of the signed integer family: s1..s64 become i1..i64
(u1..u64, usize, isize unchanged). 'string' keeps the s-prefix arm in
name classification; width parsing moves to the i-prefix arm next to
isize.

Internal TypeId tags follow the surface (.s8/.s16/.s32/.s64 ->
.i8/.i16/.i32/.i64), as do mono-key mangle fragments (ptr_i64,
tu_i64_bool) and all display/diagnostic formatting (i{d}).

Migrated in the same sweep: stdlib + examples + issue repros + FFI C
companions (shared symbol names like ffi_id_i64), expected
stdout/stderr/ir snapshots, specs.md, readme.md, CLAUDE.md/AGENTS.md,
implementation_plan.md, docs/, issue writeups. Vendored stb_image and
historical flow state left untouched.

zig build test: 426/426; examples suite: 595/595.
2026-06-12 09:31:53 +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 :: () -> i32 {
    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.