# 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 ```sx #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 / done` — `cleanup 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.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: `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).