Files
sx/issues/0108-defer-skipped-on-break-continue.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

RESOLVED — 0108: defer silently skipped on break / continue loop exits

Root cause: lowerBreak/lowerContinue emitted a bare br; the enclosing block's emitBlockDefers then saw the terminator and discarded the pending entries on the assumption they were already emitted (true only for return/raise).

Fix: Lowering.loop_defer_base records the defer-stack height at each loop's body start (lowerWhile / lowerFor / lowerRuntimeRangeFor, saved/restored like break_target); lowerBreak/lowerContinue drain non-onfail entries down to it in LIFO order via the new, non-truncating emitLoopExitDefers (src/ir/lower/stmt.zig) before branching — truncation stays with the lexical block exits, since the same entries still belong to the fall-through path. break/continue outside a loop now diagnose (`break` outside a loop) instead of silently no-op'ing.

Regression test: examples/0049-basic-defer-break-continue.sx (for break + continue, while break + continue, nested-block LIFO drain; the breaking iteration's cleanups were missing pre-fix).


0108 — defer silently skipped on break / continue loop exits

Symptom. A defer registered inside a loop body does not run when the iteration exits via break or continue. Observed: the cleanup for the breaking/continuing iteration never executes. Expected (specs.md §6 Defer: "defer expr; schedules expr to execute when the enclosing scope block exits"): break/continue exit the loop-body scope, so all pending defers of that iteration must fire before the jump. The normal fall-through end of an iteration DOES run them — only the break/continue paths skip.

Resource impact: for ... { f := open(...); defer close(f); if cond { break; } } leaks the handle on the break path. Same for continue (leaks once per continued iteration). Affects for (collection, range) and while equally — all share lowerBreak/lowerContinue.

Reproduction

#import "modules/std.sx";

main :: () -> i32 {
    for 0..3: (i) {
        defer print("cleanup {}\n", i);
        if i == 1 { break; }
        print("body {}\n", i);
    }
    print("after break loop\n");

    for 0..3: (i) {
        defer print("c2 {}\n", i);
        if i == 1 { continue; }
        print("b2 {}\n", i);
    }
    print("done\n");
    0
}
  • Observed (current master): body 0 / cleanup 0 / after break loop / b2 0 / c2 0 / b2 2 / c2 2 / donecleanup 1 and c2 1 are missing.
  • Expected: body 0 / cleanup 0 / cleanup 1 / after break loop / b2 0 / c2 0 / c2 1 / b2 2 / c2 2 / done

Repro co-located: issues/0108-defer-skipped-on-break-continue.sx (unpinned — pin as the regression once fixed, with the expected output above).

Root cause (suspected area)

src/ir/lower/control_flow.ziglowerBreak / lowerContinue (~864-876) emit a bare self.builder.br(target) without draining the defer stack. Contrast lowerReturn (src/ir/lower/stmt.zig ~501), which calls self.emitBlockDefers(self.func_defer_base) before ret. After the bare br, the enclosing lowerBlock's scope-exit emitBlockDefers sees currentBlockHasTerminator() and discards the entries under the assumption "cleanups were already emitted" (stmt.zig ~1016) — true for return/raise, false for break/continue. So the cleanups are dropped, not deferred-elsewhere.

Investigation prompt (paste into a fresh session)

Fix issue 0108: defer is skipped on break/continue exits.

  1. Record the loop's defer base: in lowerFor / lowerRuntimeRangeFor / lowerWhile (src/ir/lower/control_flow.zig), alongside the existing save/restore of break_target/continue_target, save self.defer_stack.items.len into a new Lowering field (e.g. loop_defer_base: usize), restoring the old value after the body.
  2. In lowerBreak/lowerContinue, before the br, emit pending non-onfail cleanups from defer_stack.items.len down to loop_defer_base in LIFO order without truncating the stack (mirror emitErrorCleanup's non-truncating walk in src/ir/lower/stmt.zig, success-exit filtering like emitBlockDefers). Truncation must stay with the lexical lowerBlock scope exits — the same defer entries still belong to the fall-through lowering path after the if { break; } arm.
  3. inline for (lowerInlineRangeFor) bodies lower through lowerBlock per unrolled iteration; check a break inside one targets the enclosing runtime loop with the same drain (and that break with no enclosing loop gets a diagnostic rather than the current silent no-op Ref.none).

Verify: run the repro in issues/0108-defer-skipped-on-break-continue.sx, expect cleanup 1 after body 0/cleanup 0, and c2 1 between c2 0 and b2 2. Add a while-loop break/continue + defer case. Then promote to examples/00xx-basic-defer-break-continue.sx per the resolution flow, and run zig build && zig build test && bash tests/run_examples.sh (all ok).