Skip to content

Commit 9e7f605

Browse files
authored
fix(interpreter): exit builtin terminates execution in compound commands (#904)
## Summary - The `exit` builtin returned a plain exit code without `ControlFlow` signaling, so commands after `exit` in if/while/for/case blocks kept running - Added `ControlFlow::Exit(i32)` variant and wired it through the interpreter: propagates past function boundaries, consumed at subshell and command substitution boundaries (matching bash semantics) ## Test plan - [x] 7 new spec tests: exit in if, while, for, case, function, subshell isolation - [x] `cargo test --all-features` passes - [x] `cargo clippy` and `cargo fmt` clean - [ ] CI green
1 parent 17abfba commit 9e7f605

File tree

5 files changed

+109
-11
lines changed

5 files changed

+109
-11
lines changed

crates/bashkit/src/builtins/flow.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,11 @@ impl Builtin for Exit {
5656
.unwrap_or(0)
5757
& 0xFF;
5858

59-
// For now, we just return the exit code
60-
// In a full implementation, this would terminate the shell
61-
Ok(ExecResult::err(String::new(), exit_code))
59+
Ok(ExecResult {
60+
exit_code,
61+
control_flow: ControlFlow::Exit(exit_code),
62+
..Default::default()
63+
})
6264
}
6365
}
6466

crates/bashkit/src/interpreter/mod.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1408,6 +1408,16 @@ impl Interpreter {
14081408
self.last_exit_code = saved_exit;
14091409
self.aliases = saved_aliases;
14101410
self.coproc_buffers = saved_coproc;
1411+
1412+
// Consume Exit control flow at subshell boundary — exit only
1413+
// terminates the subshell, not the parent shell.
1414+
if let Ok(ref mut res) = result
1415+
&& let ControlFlow::Exit(code) = res.control_flow
1416+
{
1417+
res.exit_code = code;
1418+
res.control_flow = ControlFlow::None;
1419+
}
1420+
14111421
result
14121422
}
14131423
CompoundCommand::BraceGroup(commands) => self.execute_command_sequence(commands).await,
@@ -1680,6 +1690,15 @@ impl Interpreter {
16801690
..Default::default()
16811691
});
16821692
}
1693+
ControlFlow::Exit(code) => {
1694+
return Ok(ExecResult {
1695+
stdout,
1696+
stderr,
1697+
exit_code: code,
1698+
control_flow: ControlFlow::Exit(code),
1699+
..Default::default()
1700+
});
1701+
}
16831702
ControlFlow::None => {}
16841703
}
16851704
}
@@ -5880,6 +5899,9 @@ impl Interpreter {
58805899
let cmd_result = self.execute_command(cmd).await?;
58815900
stdout.push_str(&cmd_result.stdout);
58825901
self.last_exit_code = cmd_result.exit_code;
5902+
if matches!(cmd_result.control_flow, ControlFlow::Exit(_)) {
5903+
break;
5904+
}
58835905
}
58845906
// Fire EXIT trap set inside the command substitution
58855907
if let Some(trap_cmd) = self.traps.get("EXIT").cloned()

crates/bashkit/src/interpreter/state.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ pub enum ControlFlow {
1111
Continue(u32),
1212
/// Return from a function (with exit code)
1313
Return(i32),
14+
/// Exit the shell (with exit code)
15+
Exit(i32),
1416
}
1517

1618
/// Structured side-effect channel for builtins that need to communicate
@@ -174,6 +176,7 @@ impl LoopAccumulator {
174176
ControlFlow::Return(code) => {
175177
LoopAction::Exit(self.build_exit(ControlFlow::Return(code)))
176178
}
179+
ControlFlow::Exit(code) => LoopAction::Exit(self.build_exit(ControlFlow::Exit(code))),
177180
ControlFlow::None => LoopAction::None,
178181
}
179182
}
@@ -193,7 +196,7 @@ impl LoopAccumulator {
193196
/// Build an exit result, draining accumulated stdout/stderr.
194197
fn build_exit(&mut self, control_flow: ControlFlow) -> ExecResult {
195198
let exit_code = match control_flow {
196-
ControlFlow::Return(code) => code,
199+
ControlFlow::Return(code) | ControlFlow::Exit(code) => code,
197200
_ => self.exit_code,
198201
};
199202
ExecResult {

crates/bashkit/tests/spec_cases/bash/exit-status.test.sh

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,3 +168,74 @@ false || false; echo $?
168168
1
169169
0
170170
### end
171+
172+
### exit_in_if_block
173+
# exit inside if block terminates script
174+
if true; then echo before; exit 0; fi
175+
echo SHOULD_NOT_REACH
176+
### expect
177+
before
178+
### end
179+
180+
### exit_in_if_block_with_code
181+
# exit with non-zero code inside if block
182+
if true; then exit 42; fi
183+
echo SHOULD_NOT_REACH
184+
### exit_code: 42
185+
### expect
186+
### end
187+
188+
### exit_in_while_loop
189+
# exit inside while loop terminates script
190+
i=0
191+
while true; do
192+
echo "iter $i"
193+
if [ "$i" -eq 1 ]; then exit 0; fi
194+
i=$((i + 1))
195+
done
196+
echo SHOULD_NOT_REACH
197+
### expect
198+
iter 0
199+
iter 1
200+
### end
201+
202+
### exit_in_for_loop
203+
# exit inside for loop terminates script
204+
for x in a b c; do
205+
echo "$x"
206+
if [ "$x" = "b" ]; then exit 5; fi
207+
done
208+
echo SHOULD_NOT_REACH
209+
### exit_code: 5
210+
### expect
211+
a
212+
b
213+
### end
214+
215+
### exit_in_case_block
216+
# exit inside case block terminates script
217+
case "yes" in
218+
yes) echo matched; exit 0 ;;
219+
esac
220+
echo SHOULD_NOT_REACH
221+
### expect
222+
matched
223+
### end
224+
225+
### exit_in_subshell_does_not_propagate
226+
# exit inside subshell only terminates the subshell
227+
(exit 7)
228+
echo "after subshell: $?"
229+
### expect
230+
after subshell: 7
231+
### end
232+
233+
### exit_in_function
234+
# exit inside function terminates the whole script
235+
f() { echo in_func; exit 3; }
236+
f
237+
echo SHOULD_NOT_REACH
238+
### exit_code: 3
239+
### expect
240+
in_func
241+
### end

supply-chain/config.toml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -663,7 +663,7 @@ version = "0.1.34"
663663
criteria = "safe-to-deploy"
664664

665665
[[exemptions.js-sys]]
666-
version = "0.3.92"
666+
version = "0.3.93"
667667
criteria = "safe-to-deploy"
668668

669669
[[exemptions.leb128fmt]]
@@ -1467,23 +1467,23 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
14671467
criteria = "safe-to-run"
14681468

14691469
[[exemptions.wasm-bindgen]]
1470-
version = "0.2.115"
1470+
version = "0.2.116"
14711471
criteria = "safe-to-deploy"
14721472

14731473
[[exemptions.wasm-bindgen-futures]]
1474-
version = "0.4.65"
1474+
version = "0.4.66"
14751475
criteria = "safe-to-deploy"
14761476

14771477
[[exemptions.wasm-bindgen-macro]]
1478-
version = "0.2.115"
1478+
version = "0.2.116"
14791479
criteria = "safe-to-deploy"
14801480

14811481
[[exemptions.wasm-bindgen-macro-support]]
1482-
version = "0.2.115"
1482+
version = "0.2.116"
14831483
criteria = "safe-to-deploy"
14841484

14851485
[[exemptions.wasm-bindgen-shared]]
1486-
version = "0.2.115"
1486+
version = "0.2.116"
14871487
criteria = "safe-to-deploy"
14881488

14891489
[[exemptions.wasm-encoder]]
@@ -1503,7 +1503,7 @@ version = "0.244.0"
15031503
criteria = "safe-to-deploy"
15041504

15051505
[[exemptions.web-sys]]
1506-
version = "0.3.92"
1506+
version = "0.3.93"
15071507
criteria = "safe-to-deploy"
15081508

15091509
[[exemptions.web-time]]

0 commit comments

Comments
 (0)