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).
This commit is contained in:
agra
2026-06-10 17:43:58 +03:00
parent bf47146085
commit 3cc34d54c1
9 changed files with 211 additions and 4 deletions

View File

@@ -0,0 +1,107 @@
# 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 :: () -> 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 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).