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:
107
issues/0108-defer-skipped-on-break-continue.md
Normal file
107
issues/0108-defer-skipped-on-break-continue.md
Normal 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).
|
||||
Reference in New Issue
Block a user