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).
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 / done—cleanup 1andc2 1are 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.zig — lowerBreak / 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:
deferis skipped onbreak/continueexits.
- Record the loop's defer base: in
lowerFor/lowerRuntimeRangeFor/lowerWhile(src/ir/lower/control_flow.zig), alongside the existing save/restore ofbreak_target/continue_target, saveself.defer_stack.items.leninto a newLoweringfield (e.g.loop_defer_base: usize), restoring the old value after the body.- In
lowerBreak/lowerContinue, before thebr, emit pending non-onfail cleanups fromdefer_stack.items.lendown toloop_defer_basein LIFO order without truncating the stack (mirroremitErrorCleanup's non-truncating walk insrc/ir/lower/stmt.zig, success-exit filtering likeemitBlockDefers). Truncation must stay with the lexicallowerBlockscope exits — the same defer entries still belong to the fall-through lowering path after theif { break; }arm.inline for(lowerInlineRangeFor) bodies lower throughlowerBlockper unrolled iteration; check abreakinside one targets the enclosing runtime loop with the same drain (and thatbreakwith no enclosing loop gets a diagnostic rather than the current silent no-opRef.none).Verify: run the repro in
issues/0108-defer-skipped-on-break-continue.sx, expectcleanup 1afterbody 0/cleanup 0, andc2 1betweenc2 0andb2 2. Add awhile-loop break/continue + defer case. Then promote toexamples/00xx-basic-defer-break-continue.sxper the resolution flow, and runzig build && zig build test && bash tests/run_examples.sh(all ok).