Files
sx/issues/0108-defer-skipped-on-break-continue.md
agra 3cc34d54c1 fix(0108): break/continue run the loop body's pending defers
lowerBreak/lowerContinue emitted a bare br, and the enclosing block's
emitBlockDefers — seeing the terminator — discarded the pending entries
on the assumption a return had already drained them. The breaking
iteration's defers were silently skipped, leaking whatever the cleanup
released.

Lowering.loop_defer_base records the defer-stack height at each loop's
body start (while / for / range-for, saved and restored alongside
break_target); break/continue drain non-onfail entries down to it in
LIFO order via the non-truncating emitLoopExitDefers before branching.
Truncation stays with the lexical block exits — the same entries still
belong to the fall-through path after the branch containing the break.
break/continue outside a loop now diagnose instead of no-op'ing.

Regression: examples/0049-basic-defer-break-continue.sx (for and while,
break and continue, nested-block LIFO drain).
2026-06-10 17:43:58 +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 :: () -> s32 {
    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).