Skip to content

Commit 76009ba

Browse files
committed
fix(interpreter): propagate errexit_suppressed through compound commands
When a loop body's last command is an AND-OR list (e.g. `[[ cond ]] && cmd`) that exits non-zero, the loop should not trigger `set -e` at the caller level. Add `errexit_suppressed` flag to ExecResult. Set it when execute_list returns a non-zero exit from an AND-OR chain. Propagate it through for, while/until, and C-style for loops back to execute_command_sequence_impl, which now skips errexit when the flag is set. Closes #867
1 parent c246ad3 commit 76009ba

File tree

3 files changed

+124
-1
lines changed

3 files changed

+124
-1
lines changed

crates/bashkit/src/interpreter/mod.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1457,6 +1457,7 @@ impl Interpreter {
14571457
let mut stdout = String::new();
14581458
let mut stderr = String::new();
14591459
let mut exit_code = 0;
1460+
let mut last_errexit_suppressed = false;
14601461

14611462
// Get iteration values: expand fields, then apply brace/glob expansion
14621463
let values: Vec<String> = if let Some(words) = &for_cmd.words {
@@ -1512,6 +1513,7 @@ impl Interpreter {
15121513
stdout.push_str(&result.stdout);
15131514
stderr.push_str(&result.stderr);
15141515
exit_code = result.exit_code;
1516+
last_errexit_suppressed = result.errexit_suppressed;
15151517

15161518
// Check for break/continue
15171519
match result.control_flow {
@@ -1564,6 +1566,7 @@ impl Interpreter {
15641566
stderr,
15651567
exit_code,
15661568
control_flow: ControlFlow::None,
1569+
errexit_suppressed: last_errexit_suppressed,
15671570
..Default::default()
15681571
})
15691572
}
@@ -1748,6 +1751,7 @@ impl Interpreter {
17481751
let mut stdout = String::new();
17491752
let mut stderr = String::new();
17501753
let mut exit_code = 0;
1754+
let mut last_errexit_suppressed = false;
17511755

17521756
// Execute initialization
17531757
if !arith_for.init.is_empty() {
@@ -1776,6 +1780,7 @@ impl Interpreter {
17761780
stdout.push_str(&result.stdout);
17771781
stderr.push_str(&result.stderr);
17781782
exit_code = result.exit_code;
1783+
last_errexit_suppressed = result.errexit_suppressed;
17791784

17801785
// Check for break/continue
17811786
match result.control_flow {
@@ -1829,6 +1834,7 @@ impl Interpreter {
18291834
stderr,
18301835
exit_code,
18311836
control_flow: ControlFlow::None,
1837+
errexit_suppressed: last_errexit_suppressed,
18321838
..Default::default()
18331839
})
18341840
}
@@ -2180,6 +2186,7 @@ impl Interpreter {
21802186
let mut stdout = String::new();
21812187
let mut stderr = String::new();
21822188
let mut exit_code = 0;
2189+
let mut last_errexit_suppressed = false;
21832190

21842191
// Reset loop counter for this loop
21852192
self.counters.reset_loop();
@@ -2215,6 +2222,7 @@ impl Interpreter {
22152222
stdout.push_str(&result.stdout);
22162223
stderr.push_str(&result.stderr);
22172224
exit_code = result.exit_code;
2225+
last_errexit_suppressed = result.errexit_suppressed;
22182226

22192227
// Check for break/continue
22202228
match result.control_flow {
@@ -2264,6 +2272,7 @@ impl Interpreter {
22642272
stderr,
22652273
exit_code,
22662274
control_flow: ControlFlow::None,
2275+
errexit_suppressed: last_errexit_suppressed,
22672276
..Default::default()
22682277
})
22692278
}
@@ -2782,6 +2791,7 @@ impl Interpreter {
27822791
let mut stdout = String::new();
27832792
let mut stderr = String::new();
27842793
let mut exit_code = 0;
2794+
let mut last_errexit_suppressed = false;
27852795

27862796
for command in commands {
27872797
let emit_before = self.output_emit_count;
@@ -2807,11 +2817,15 @@ impl Interpreter {
28072817
// Skip errexit for commands that are AND-OR lists — per POSIX, set -e
28082818
// does not exit on failures that are part of && or || chains.
28092819
// The list executor already handles errexit internally.
2820+
// Also skip when the result has errexit_suppressed set — this means
2821+
// a compound command (loop, etc.) ended with an AND-OR list exit code
2822+
// that should not propagate errexit to the caller.
28102823
let is_and_or_list = matches!(
28112824
command,
28122825
Command::List(list) if list.rest.iter().any(|(op, _)| matches!(op, ListOperator::And | ListOperator::Or))
28132826
);
2814-
if check_errexit && self.is_errexit_enabled() && exit_code != 0 && !is_and_or_list {
2827+
let suppress = is_and_or_list || result.errexit_suppressed;
2828+
if check_errexit && self.is_errexit_enabled() && exit_code != 0 && !suppress {
28152829
return Ok(ExecResult {
28162830
stdout,
28172831
stderr,
@@ -2820,13 +2834,15 @@ impl Interpreter {
28202834
..Default::default()
28212835
});
28222836
}
2837+
last_errexit_suppressed = suppress && exit_code != 0;
28232838
}
28242839

28252840
Ok(ExecResult {
28262841
stdout,
28272842
stderr,
28282843
exit_code,
28292844
control_flow: ControlFlow::None,
2845+
errexit_suppressed: last_errexit_suppressed,
28302846
..Default::default()
28312847
})
28322848
}
@@ -3099,6 +3115,7 @@ impl Interpreter {
30993115
stderr,
31003116
exit_code,
31013117
control_flow: ControlFlow::None,
3118+
errexit_suppressed: has_conditional_operators && exit_code != 0,
31023119
..Default::default()
31033120
})
31043121
}

crates/bashkit/src/interpreter/state.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ pub struct ExecResult {
6666
/// Structured side effects from builtin execution.
6767
/// The interpreter processes these after the builtin returns.
6868
pub side_effects: Vec<BuiltinSideEffect>,
69+
/// When true, the non-zero exit code came from an AND-OR list (e.g. `false && true`)
70+
/// and should NOT trigger `set -e` / errexit at the caller level.
71+
/// Propagated through compound commands so nested loops don't re-trigger errexit.
72+
pub errexit_suppressed: bool,
6973
}
7074

7175
impl ExecResult {

crates/bashkit/tests/set_e_and_or_tests.rs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,108 @@ echo "reached"
8989
assert!(result.stdout.contains("reached"));
9090
}
9191

92+
/// set -e: [[ false ]] && cmd inside nested C-style for loops (issue #867)
93+
#[tokio::test]
94+
async fn set_e_and_list_in_nested_cfor() {
95+
let mut bash = Bash::new();
96+
let result = bash
97+
.exec(
98+
r#"
99+
set -e
100+
f() {
101+
for ((i=0; i<2; i++)); do
102+
for ((j=0; j<2; j++)); do
103+
[[ $i -ne $j ]] && echo "$i != $j"
104+
done
105+
done
106+
echo "done"
107+
}
108+
f
109+
echo "after f"
110+
"#,
111+
)
112+
.await
113+
.unwrap();
114+
assert!(result.stdout.contains("0 != 1"), "should print 0 != 1");
115+
assert!(result.stdout.contains("1 != 0"), "should print 1 != 0");
116+
assert!(result.stdout.contains("done"), "should reach done");
117+
assert!(result.stdout.contains("after f"), "should reach after f");
118+
}
119+
120+
/// set -e: nested for-in loops with AND-OR list
121+
#[tokio::test]
122+
async fn set_e_and_list_in_nested_for_in() {
123+
let mut bash = Bash::new();
124+
let result = bash
125+
.exec(
126+
r#"
127+
set -e
128+
f() {
129+
for i in a b; do
130+
for j in a b; do
131+
[[ "$i" != "$j" ]] && echo "$i != $j"
132+
done
133+
done
134+
echo "done"
135+
}
136+
f
137+
"#,
138+
)
139+
.await
140+
.unwrap();
141+
assert!(result.stdout.contains("a != b"));
142+
assert!(result.stdout.contains("b != a"));
143+
assert!(result.stdout.contains("done"));
144+
}
145+
146+
/// set -e: nested while loop with AND-OR list
147+
#[tokio::test]
148+
async fn set_e_and_list_in_nested_while() {
149+
let mut bash = Bash::new();
150+
let result = bash
151+
.exec(
152+
r#"
153+
set -e
154+
i=0
155+
while [[ $i -lt 2 ]]; do
156+
j=0
157+
while [[ $j -lt 2 ]]; do
158+
[[ $i -ne $j ]] && echo "$i != $j"
159+
((j++)) || true
160+
done
161+
((i++)) || true
162+
done
163+
echo "done"
164+
"#,
165+
)
166+
.await
167+
.unwrap();
168+
assert!(result.stdout.contains("0 != 1"));
169+
assert!(result.stdout.contains("1 != 0"));
170+
assert!(result.stdout.contains("done"));
171+
}
172+
173+
/// set -e should still exit on plain failure inside nested loops
174+
#[tokio::test]
175+
async fn set_e_exits_on_plain_failure_in_nested_loop() {
176+
let mut bash = Bash::new();
177+
let result = bash
178+
.exec(
179+
r#"
180+
set -e
181+
for ((i=0; i<2; i++)); do
182+
for ((j=0; j<2; j++)); do
183+
false
184+
done
185+
done
186+
echo "SHOULD NOT APPEAR"
187+
"#,
188+
)
189+
.await
190+
.unwrap();
191+
assert!(!result.stdout.contains("SHOULD NOT APPEAR"));
192+
}
193+
92194
/// set -e should still exit on non-AND-OR failures
93195
#[tokio::test]
94196
async fn set_e_exits_on_plain_failure() {

0 commit comments

Comments
 (0)