Skip to content

fix(interpreter): give a loop left via break/continue status 0 - #417

Open
trieloff wants to merge 2 commits into
vercel-labs:mainfrom
trieloff:bb/fix-control-flow-break-continue-lose-the-loop-ex-thr_wkb54cx35f
Open

trieloff wants to merge 2 commits into
vercel-labs:mainfrom
trieloff:bb/fix-control-flow-break-continue-lose-the-loop-ex-thr_wkb54cx35f

Conversation

@trieloff

@trieloff trieloff commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

$? after break is the failed command, not break

break and continue are builtins that return 0, and they are the last command a loop body runs — so in bash a loop left through them reports 0. just-bash reported the status of whatever ran before the break. No set -e needed to see it:

while :; do false; break; done; echo $?            # 1   GNU bash: 0
for   i in 1; do false; break; done; echo $?       # 1   GNU bash: 0
for   i in 1 2; do false; continue; done; echo $?  # 1   GNU bash: 0
while :; do break; done; echo $?                   # 0   (already correct)

All four loop forms were affected — for, C-style for, while, until — plus a break/continue in a while condition, and a multi-level break 2 unwinding through an enclosing loop.

Why it surfaced as a set -e bug

The stale status is invisible until something reads $?. Under set -e the phantom failing loop ends the script with no output and no diagnostic:

set -euo pipefail
while : ; do
  [ 5 -eq 0 ] && break     # correctly exempt from errexit as the left operand of &&
  break
done
echo "ok"                  # never printed; exit 1

Inserting any successful command between the failed && list and the break hides it, which is what made this look like an errexit or &&-exemption problem downstream. It is neither — the && exemption works fine; the loop's status is simply wrong. Same shape as #400 (bare assignment leaking the previous $?).

The fix

Each loop body kept exitCode = stmtResult.exitCode and never reset it when handleLoopError turned a BreakError/ContinueError into a break/continue action. handleLoopError now reports the builtin's own status (0) alongside the action, and each of the four loops adopts it — one line per site rather than four divergent copies. The hand-rolled break/continue path in a while condition resets the status too.

continue sets the status at the point it runs but does not pin it — a later iteration still overwrites it, so for i in 1 2; do if [ $i = 1 ]; then true; continue; fi; false; done remains 1. A loop that ends normally is unchanged, and return from a function still reports the last command rather than 0.

Tests

src/comparison-tests/loop-break-status.comparison.test.ts — 23 cases recorded against real bash: bare $? after break/continue in all four loop forms; break nested in if/case; break 2, break 3, break 5 past the nesting depth, continue 2; break and continue in a while condition; $? observed from the next iteration after a continue; the set -e repro above; and negative controls (a normally-ending loop, return).

13 of the 23 fail on main and all 23 pass here. A separate 35-case matrix diffed against /bin/bash also matches on every case.

pnpm typecheck, pnpm lint, pnpm knip clean. pnpm test:run is green except 6 pre-existing Python/WASM sandbox failures that reproduce identically on a clean main checkout in this environment.

Reported downstream as ai-ecoverse/slicc#2978 (diagnosed there as an errexit/&& issue — the root cause is the loop status).

🤖 Generated with Claude Code

@vercel

vercel Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

@claude is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

@auto-maintain

auto-maintain Bot commented Sep 9, 2026

Copy link
Copy Markdown

🤖 auto-maintain review

Automated, advisory triage for @trieloff's PR. Facts below are read from the GitHub API.

Check Result
Author's merged PRs (this repo) 18
Account established ✅ (age 6481d · 135 followers · 203 public repos)
Commits signed/verified ✅ 2/2
Changeset included ✅ (.changeset/loop-break-continue-status.md)

Review panel: 🟡 medium highest severity

just-bash maintainer code review: 🟡 medium

The body-path fix is incomplete because equivalent control flow in `until` conditions remains incorrect.

  • packages/just-bash/src/interpreter/control-flow.ts:576 — `until` conditions still bypass break/continue handling: `executeCondition` rethrows these errors, so `until false || { false; break; }; do :; done; echo $?` leaks status 1, while `continue` incorrectly exits the loop. Mirror the `while` condition handling and adopt status 0.

General code review: 🟡 medium

The status fix is incomplete for break/continue executed in an `until` condition.

  • packages/just-bash/src/interpreter/control-flow.ts:576 — `until` conditions still let `BreakError`/`ContinueError` escape through `executeCondition`, unlike the new `while` handling. Commands such as `until false || { false; break; }; do :; done; echo $?` therefore retain the stale failure status instead of reporting 0; handle condition control flow symmetrically and add comparison coverage.

Adversarial security: 🟢 low

No actionable security issues found in the complete diff.

Adversarial security (second opinion): 🟢 low

Narrow, bash-conformant fix: handleLoopError now reports status 0 for break/continue and the four loop sites adopt it via adoptLoopStatus, which sets lastExitCode/$? using the same pattern already used elsewhere in the interpreter. No network, dependency, install-script, CI, filesystem, or argument-handling surface is touched; the invalid-`break n` path still throws ExitError(128) unchanged, multi-level rethrow paths deliberately carry no status, and all four handleLoopError callers are patched consistently. Fixtures are unlocked and internally consistent with real bash quirks (e.g. `until` body observing $?=1), so nothing looks fabricated to force a pass. No security or correctness findings.

Standard Bash and host portability: 🟡 medium

The fix remains incomplete for standard Bash control flow in `until` conditions.

  • packages/just-bash/src/interpreter/control-flow.ts:576 — `until` evaluates its condition outside the loop-control catch, so `break`/`continue` in that condition bypass the new status handling; `continue` exits the loop instead of restarting it, and `break` can leave a stale nonzero `$?`.

Posted by auto-maintain. This automated code review is advisory; a human maintainer makes the call.

@trieloff
trieloff force-pushed the bb/fix-control-flow-break-continue-lose-the-loop-ex-thr_wkb54cx35f branch 2 times, most recently from 629f044 to 3cc9008 Compare September 9, 2026 09:10
@trieloff

trieloff commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — the $? finding is correct, and it also caught a hole in my own test.

for i in 1 2; do echo "$i:$?"; false; continue; done gave 1:0 2:1 where bash gives 1:0 2:0. Fixed in 48e7a1c.

The reason it slipped through: the test I wrote for exactly this read $? after a [ command ([ $i = 2 ] && echo "q=$?"), so it only ever observed that ['s status and could never see a stale value. Reading $? as the body's first command is the only position that can. The test now does that, and it fails against the previous commit.

Fix: a new adoptLoopStatus helper sets the loop-local exit code and ctx.state.lastExitCode together, and every site that consumes a break/continue routes through it — the four loop bodies, the while-condition path, and the multi-level continue n that unwinds into an enclosing loop.

On scope: only for and C-style for were observably wrong. while/until masked it because their condition runs between iterations and resets $? — the status was equally stale, it just got overwritten before anything could read it. They're synchronised and covered regardless, so the behaviour doesn't depend on that coincidence.

Worth noting until legitimately shows 1 here: n=0; until [ $n -ge 2 ]; do echo "$n:$?"; ...; continue; done gives 0:1 1:1, because the condition returning false is the last command before the body. The fixture records that as-is rather than normalising it to 0.

Coverage is now 28 cases (up from 23), adding $?-as-first-command for all four loop forms, continue 2 into the outer loop's next iteration, and $? after an inner loop left via break. Verified 3 of the new tests fail against 3cc9008.

typecheck, lint, knip clean; suite green apart from 6 pre-existing Python/WASM sandbox failures that reproduce identically on a clean main in this environment.

Both commits are now GPG-signed and verified — the earlier 0/1 was the harness dropping the signature and the committer identity.

@trieloff
trieloff marked this pull request as ready for review September 9, 2026 09:57
Copilot AI lite review requested due to automatic review settings September 9, 2026 09:57
@trieloff
trieloff requested a review from cramforce as a code owner September 9, 2026 09:57

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

trieloff and others added 2 commits September 23, 2026 13:16
`break` and `continue` are builtins that return 0, and they are the last
command a loop body runs. All four loop forms kept `exitCode` at the
status of whatever ran before them, so the loop reported that instead:

    while :; do false; break; done; echo $?     # was 1, bash says 0

`handleLoopError` now reports the builtin's own status alongside the
break/continue action, and each loop adopts it. The hand-rolled
break/continue path in a `while` condition resets the status too.

`continue` sets the status without pinning it - a later iteration still
overwrites it - and a loop that ends normally is unchanged.

Reported downstream as ai-ecoverse/slicc#2978.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Lars Trieloff <lars@trieloff.net>
The previous commit corrected the value a loop returns, but `$?` still
read the command before the `continue`. A `for` loop runs nothing between
the builtin and the next iteration's first command, so the failure stayed
visible there:

    for i in 1 2; do echo "$i:$?"; false; continue; done
    # was 1:0 2:1, bash says 1:0 2:0

New `adoptLoopStatus` sets the loop's exit code and `ctx.state.lastExitCode`
together, and every site that consumes a break/continue goes through it -
the four loop bodies, the while-condition path, and the multi-level
`continue n` that unwinds into an enclosing loop.

`while`/`until` masked the bug because their condition runs between
iterations and resets `$?`; they are covered now too.

The test asserting this previously read `$?` after a `[` command, so it
only ever saw that command's status. It now reads `$?` as the body's first
command, which is the only position that can observe a stale value.

Reported downstream as ai-ecoverse/slicc#2978.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Lars Trieloff <lars@trieloff.net>
@trieloff
trieloff force-pushed the bb/fix-control-flow-break-continue-lose-the-loop-ex-thr_wkb54cx35f branch from 48e7a1c to 726ebb2 Compare September 23, 2026 11:16

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants