Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 62 additions & 15 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2194,7 +2194,7 @@ impl Interpreter {
cancelled: Arc::clone(&self.cancelled),
hooks: Arc::clone(&self.hooks),
in_trap: false,
condition_sequence_depth: 0,
condition_sequence_depth: self.condition_sequence_depth,
deferred_proc_subs: Vec::new(),
proc_sub_paths: HashSet::new(),
random_state: AtomicU32::new(random_seed),
Expand Down Expand Up @@ -2305,6 +2305,14 @@ impl Interpreter {
self.flags.contains(BashFlags::ERREXIT)
}

/// `set -e` fires here: on, and not inside a context where bash ignores
/// it (an `if`/`while`/`until` condition, a non-final `&&`/`||` element,
/// a `!` pipeline). Those contexts reach into function bodies and
/// subshells run from them, so `if f; then` never stops inside `f`.
fn errexit_active(&self) -> bool {
self.is_errexit_enabled() && self.condition_sequence_depth == 0
}

/// Check if xtrace (set -x) is enabled.
fn is_xtrace_enabled(&self) -> bool {
self.flags.contains(BashFlags::XTRACE)
Expand Down Expand Up @@ -3279,7 +3287,7 @@ impl Interpreter {
// the status as suppressed (for example, a short-circuited AND-OR
// list) or the command is an explicitly negated pipeline.
// Lists are NOT suppressed here so set -e fires for failing lists.
if self.is_errexit_enabled() && exit_code != 0 {
if self.errexit_active() && exit_code != 0 {
let suppressed = matches!(command, Command::Pipeline(p) if p.negated)
|| result.errexit_suppressed;
if !suppressed {
Expand Down Expand Up @@ -3459,6 +3467,16 @@ impl Interpreter {
}
result
}
Command::Pipeline(pipeline) if pipeline.negated => {
// `! cmd` is an errexit-ignored context, inside too.
self.condition_sequence_depth += 1;
let result = self.execute_pipeline(pipeline).await;
self.condition_sequence_depth -= 1;
result.map(|mut r| {
r.errexit_suppressed = true;
r
})
}
Command::Pipeline(pipeline) => self.execute_pipeline(pipeline).await,
Command::List(list) => self.execute_list(list).await,
Command::Compound(compound, redirects) => {
Expand Down Expand Up @@ -3726,7 +3744,7 @@ impl Interpreter {
let emit_before = self.output_emit_count;
let result = self.execute_command_sequence(&for_cmd.body).await?;
self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
let should_errexit = self.is_errexit_enabled()
let should_errexit = self.errexit_active()
&& result.exit_code != 0
&& result.control_flow == ControlFlow::None
&& !result.errexit_suppressed;
Expand Down Expand Up @@ -4028,7 +4046,7 @@ impl Interpreter {
let emit_before = self.output_emit_count;
let result = self.execute_command_sequence(&arith_for.body).await?;
self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
let should_errexit = self.is_errexit_enabled()
let should_errexit = self.errexit_active()
&& result.exit_code != 0
&& result.control_flow == ControlFlow::None
&& !result.errexit_suppressed;
Expand Down Expand Up @@ -4613,7 +4631,7 @@ impl Interpreter {
let emit_before = self.output_emit_count;
let result = self.execute_command_sequence(body).await?;
self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
let should_errexit = self.is_errexit_enabled()
let should_errexit = self.errexit_active()
&& result.exit_code != 0
&& result.control_flow == ControlFlow::None
&& !result.errexit_suppressed;
Expand Down Expand Up @@ -5672,7 +5690,7 @@ impl Interpreter {
// Suppression is decided by the callee and surfaced through
// result.errexit_suppressed (e.g. AND-OR lists).
let suppress = result.errexit_suppressed;
if check_errexit && self.is_errexit_enabled() && exit_code != 0 && !suppress {
if check_errexit && self.errexit_active() && exit_code != 0 && !suppress {
return Ok(ExecResult {
stdout,
stderr,
Expand Down Expand Up @@ -6259,7 +6277,15 @@ impl Interpreter {
exit_code_from_conditional_context = false;
} else {
let emit_before = self.output_emit_count;
let result = self.execute_command(&list.first).await?;
// A non-final `&&`/`||` element runs with errexit ignored.
let conditional = list
.rest
.first()
.is_some_and(|(op, _)| matches!(op, ListOperator::And | ListOperator::Or));
self.condition_sequence_depth += usize::from(conditional);
let result = self.execute_command(&list.first).await;
self.condition_sequence_depth -= usize::from(conditional);
let result = result?;
self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
stdout.append(&result.stdout);
stderr.append(&result.stderr);
Expand Down Expand Up @@ -6313,7 +6339,7 @@ impl Interpreter {
// Check errexit before executing next semicolon-separated command:
// if previous command failed outside conditional context, exit now.
let should_check_errexit = matches!(op, ListOperator::Semicolon)
&& self.is_errexit_enabled()
&& self.errexit_active()
&& exit_code != 0
&& !exit_code_from_conditional_context;

Expand Down Expand Up @@ -6346,18 +6372,21 @@ impl Interpreter {
exit_code_from_conditional_context = false;
} else {
let emit_before = self.output_emit_count;
let result = self.execute_command(cmd).await?;
let followed_by_conditional_op =
list.rest.get(i + 1).is_some_and(|(op, cmd)| {
!Self::is_empty_sentinel(cmd)
&& matches!(op, ListOperator::And | ListOperator::Or)
});
self.condition_sequence_depth += usize::from(followed_by_conditional_op);
let result = self.execute_command(cmd).await;
self.condition_sequence_depth -= usize::from(followed_by_conditional_op);
let result = result?;
self.maybe_emit_output(&result.stdout, &result.stderr, emit_before);
stdout.append(&result.stdout);
stderr.append(&result.stderr);
exit_code = result.exit_code;
self.last_exit_code = exit_code;
control_flow = result.control_flow;
let followed_by_conditional_op =
list.rest.get(i + 1).is_some_and(|(op, cmd)| {
!Self::is_empty_sentinel(cmd)
&& matches!(op, ListOperator::And | ListOperator::Or)
});
// Bash suppresses errexit for AND-OR list elements except the
// command following the final &&/|| operator.
exit_code_from_conditional_context =
Expand Down Expand Up @@ -6388,7 +6417,7 @@ impl Interpreter {
// Final errexit check for the last command. A non-zero status only
// remains suppressed when it was carried from a short-circuited or
// non-final AND-OR list element; a failing final &&/|| command exits.
let should_final_errexit_check = self.is_errexit_enabled()
let should_final_errexit_check = self.errexit_active()
&& exit_code != 0
&& !exit_code_from_conditional_context
&& !self.is_in_condition_sequence();
Expand Down Expand Up @@ -10434,6 +10463,17 @@ impl Interpreter {
}
}
let commands: &[Command] = if file_read.is_some() { &[] } else { commands };
// bash clears `set -e` in a command substitution unless
// `shopt -s inherit_errexit`; the snapshot restores it.
let inherit_errexit = self.is_errexit_enabled()
&& self
.scoped
.variables
.get("SHOPT_inherit_errexit")
.is_some_and(|v| v == "1");
if self.is_errexit_enabled() && !inherit_errexit {
self.insert_variable_checked("SHOPT_e".to_string(), "0".to_string());
}
// Captured output must not reach the streaming callback: compound
// commands (`case`, `{ }`, `if`) emit through it as they run.
let saved_callback = self.output_callback.take();
Expand All @@ -10455,6 +10495,13 @@ impl Interpreter {
if matches!(cmd_result.control_flow, ControlFlow::Exit(_)) {
break;
}
if inherit_errexit
&& self.errexit_active()
&& cmd_result.exit_code != 0
&& !cmd_result.errexit_suppressed
{
break;
}
}
self.output_callback = saved_callback;
run?;
Expand Down
88 changes: 88 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/errexit-contexts.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
### errexit_negated_pipeline_does_not_exit
set -e
! true; echo "after !"
! false; echo "after ! false"
### expect
after !
after ! false
### end

### errexit_ignored_inside_function_in_condition
set -e
f() { false; echo "in f"; }
if f; then echo "f ok"; fi
f || echo unreachable
f && echo "and ok"
while f; do break; done
echo end
### expect
in f
f ok
in f
in f
and ok
in f
end
### end

### errexit_function_in_and_list_then_plain
### exit_code: 1
set -e
check() { echo "check $1"; [ "$1" = ok ]; echo "passed $1"; }
check ok
check bad && echo "and ran"
echo continue
check bad
echo never
### expect
check ok
passed ok
check bad
passed bad
and ran
continue
check bad
### end

### errexit_subshell_in_or_list
set -e
( false; echo "sub continues" ) || echo "sub failed $?"
echo end
### expect
sub continues
end
### end

### errexit_not_inherited_by_command_substitution
### exit_code: 3
set -e
x=$(false; echo "cmdsub continues")
echo "x=$x"
echo "flags=$(echo $- | tr -d hBc)"
shopt -s inherit_errexit
y=$(false; echo never) || echo "inherited rc=$?"
echo "y=[$y]"
v=$(exit 3)
echo never
### expect
x=cmdsub continues
flags=
y=[never]
### end

### errexit_inherited_cmdsub_exits_at_failure
### exit_code: 1
set -e
shopt -s inherit_errexit
f() { false; echo "in f"; }
x=$(f; echo more)
echo never
### expect
### end

### errexit_pipe_without_pipefail
set -e
false | true; echo "pipe ok"
### expect
pipe ok
### end
11 changes: 11 additions & 0 deletions knowledge/foundations/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,17 @@ compression paths use these builders because their expansion ratios and nested
buffers make post-hoc leasing unsafe. Atomic compare/exchange admission keeps
concurrent descendants from wrapping or temporarily exceeding the shared cap.

### `set -e` contexts

`errexit_active()` is `set -e` plus `condition_sequence_depth == 0`. The
depth counts the contexts where bash ignores errexit: an `if`/`while`/`until`
condition, a non-final `&&`/`||` element, and a `!` pipeline. It is a counter
on the interpreter, not a flag on results, so it also covers function bodies
and subshells run from those contexts (`if f; then`, `( false; x ) || y`).
Forked pipeline stages copy it. A command substitution clears `-e` unless
`shopt -s inherit_errexit` is set; with that option it stops at the first
failure, as bash does.

### Design Principles

1. **Async-first**: All filesystem and execution is async (tokio)
Expand Down
1 change: 1 addition & 0 deletions knowledge/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* **Feature**: `sudo` and `busybox`, the top missing commands in the gap replay (`sudo tee /etc/x <<EOF`, `busybox tar --help`). `sudo` skips its options and runs the command (leading `VAR=VALUE` through `env`, `-s`/`-i` through `bash -c`; `-v`/`-k` succeed, `-l` lists ALL); `busybox APPLET` runs the builtin of that name, `--list` lists them (L-SUDO-001). `nohup`/`nice`/`flock` no longer steal the inner command's `--help`.
* **Feature**: Plain `cat` streams through pipelines. As a stage it writes 4 KiB chunks into the stage pipe and, in a middle stage, reads its input pipe as it goes (new internal `Context::stdin_stream`), so `cat big | head -1` and `while :; do echo; done | cat | head -1` end with SIGPIPE (`141`) like bash instead of running to a limit. `cat` also reports a missing operand and continues with the rest, as GNU cat does. L-PIPE-001 narrowed.
* **Fix**: `${x#pat}`/`${x%pat}` follow the glob grammar. A lone `*` matches empty for the short forms (`${x#*}` and `${x%*}` keep `x`; they dropped a character), and patterns with `?` or several `*` (`${x%?}`, `${p#*/*/}`, `${p##*.*.}`) now match instead of being compared as literal text. Single-`*` patterns keep their linear fast path.
* **Fix**: `set -e` follows bash's ignored contexts. `! cmd` no longer exits. A function or subshell run as an `if`/`while` condition or a non-final `&&`/`||` element keeps going after a failing command inside (`if f; then`, `check bad && x`, `( false; echo ) || y`). `$( )` drops `-e` unless `shopt -s inherit_errexit`, and with it stops at the first failure. Debian-oracle cases `errexit-exceptions`, `errexit-subshell-and-cmdsub` and `errexit-in-function-called-from-and` now match bash. See foundations/architecture "`set -e` contexts".
* **Fix**: Quoted or escaped glob characters are literal everywhere. `echo a\*`, `a"*"` and `a'?'` no longer glob; `case`/`[[ == ]]` patterns match `"$x"`, `'a*'`, `a\*` and `\[s\]` literally; `"*"zz*` with no match prints `*zz*` (escapes no longer leak); `"$x"zz*` no longer reads `$xzz`; `'a'$x` expands and `'a'*` globs; `a{1,2}"*"` still brace-expands. The lexer escapes quoted glob characters in mixed words (the existing `QuotedGlobWord` convention, now with quote markers); `expand_word` returns unescaped text for non-glob uses; patterns use `expand_pattern_word`. Known gap: data from an unquoted expansion inside such a word loses `\` before glob characters when nothing matches.
* **Feature**: `fmt` builtin, on the uutils `fmt` engine (MIT, vendored: Knuth-Plass line breaking, paragraphs, `-c`/`-t`/`-s`/`-u`/`-p`, `-w`/`-g`, `-WIDTH`). Width rules and error messages follow GNU. Line breaks can differ from GNU `fmt` on long paragraphs (L-FMT-001); GNU's GPL code was not ported. Step 4 of the level-up roadmap (missing commands). Spec: `fmt.test.sh`. See foundations/builtins "fmt".
* **Perf**: The CPython snapshot ships as xz (4.99 MB, was ~6.7 MB as gzip; decoded by `lzma-rs` in build.rs). The room went to preloading `urllib.request` (http.client, email), `tomllib`, `configparser` and `xml.etree.ElementTree`: their imports went from 0.1-0.47 s per call to ~0; `print(1)` unchanged at ~4.1 ms, first call ~20 ms. `doctest` works again (a `pdb` stand-in; `breakpoint()` prints a notice), `mailbox` dropped. See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md#startup-is-the-product-constraint).
Expand Down
Loading