# 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).
