Skip to content

Commit 163bf72

Browse files
committed
add pipefail handling
1 parent a72d30b commit 163bf72

File tree

5 files changed

+167
-12
lines changed

5 files changed

+167
-12
lines changed

src/shell/commands/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ mod head;
1212
mod mkdir;
1313
mod pwd;
1414
mod rm;
15+
mod set;
1516
mod sleep;
1617
mod unset;
1718
mod xargs;
@@ -76,6 +77,10 @@ pub fn builtin_commands() -> HashMap<String, Rc<dyn ShellCommand>> {
7677
"rm".to_string(),
7778
Rc::new(rm::RmCommand) as Rc<dyn ShellCommand>,
7879
),
80+
(
81+
"set".to_string(),
82+
Rc::new(set::SetCommand) as Rc<dyn ShellCommand>,
83+
),
7984
(
8085
"sleep".to_string(),
8186
Rc::new(sleep::SleepCommand) as Rc<dyn ShellCommand>,

src/shell/commands/set.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright 2018-2025 the Deno authors. MIT license.
2+
3+
use futures::future::LocalBoxFuture;
4+
5+
use crate::shell::types::EnvChange;
6+
use crate::shell::types::ExecuteResult;
7+
8+
use super::ShellCommand;
9+
use super::ShellCommandContext;
10+
11+
pub struct SetCommand;
12+
13+
impl ShellCommand for SetCommand {
14+
fn execute(
15+
&self,
16+
mut context: ShellCommandContext,
17+
) -> LocalBoxFuture<'static, ExecuteResult> {
18+
let result = execute_set(&mut context);
19+
Box::pin(futures::future::ready(result))
20+
}
21+
}
22+
23+
fn execute_set(context: &mut ShellCommandContext) -> ExecuteResult {
24+
let mut changes = Vec::new();
25+
let args: Vec<String> = context
26+
.args
27+
.iter()
28+
.filter_map(|a| a.to_str().map(|s| s.to_string()))
29+
.collect();
30+
31+
let mut i = 0;
32+
while i < args.len() {
33+
let arg = &args[i];
34+
if arg == "-o" || arg == "+o" {
35+
let enable = arg == "-o";
36+
if i + 1 < args.len() {
37+
let option_name = &args[i + 1];
38+
match option_name.as_str() {
39+
"pipefail" => {
40+
changes.push(EnvChange::SetOption("pipefail".to_string(), enable));
41+
}
42+
_ => {
43+
let _ = context
44+
.stderr
45+
.write_line(&format!("set: unknown option: {}", option_name));
46+
return ExecuteResult::from_exit_code(1);
47+
}
48+
}
49+
i += 2;
50+
} else {
51+
// No option name provided - in bash this would list options
52+
// For now, just return success
53+
i += 1;
54+
}
55+
} else {
56+
// Unknown argument
57+
i += 1;
58+
}
59+
}
60+
61+
ExecuteResult::Continue(0, changes, Vec::new())
62+
}

src/shell/execute.rs

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -575,20 +575,33 @@ async fn execute_pipe_sequence(
575575
let output_handle = tokio::task::spawn_blocking(|| {
576576
last_output.unwrap().pipe_to_sender(stdout).unwrap();
577577
});
578-
let mut results = futures::future::join_all(wait_tasks).await;
578+
let results = futures::future::join_all(wait_tasks).await;
579579
output_handle.await.unwrap();
580-
let last_result = results.pop().unwrap();
581-
let all_handles = results.into_iter().flat_map(|r| r.into_handles());
582-
match last_result {
583-
ExecuteResult::Exit(code, mut handles) => {
584-
handles.extend(all_handles);
585-
ExecuteResult::Continue(code, Vec::new(), handles)
586-
}
587-
ExecuteResult::Continue(code, _, mut handles) => {
588-
handles.extend(all_handles);
589-
ExecuteResult::Continue(code, Vec::new(), handles)
580+
581+
// Determine exit code based on pipefail option
582+
let exit_code = if state.options().pipefail {
583+
// With pipefail: return the rightmost non-zero exit code, or 0 if all succeeded
584+
results
585+
.iter()
586+
.rev()
587+
.find_map(|r| {
588+
let code = match r {
589+
ExecuteResult::Exit(c, _) => *c,
590+
ExecuteResult::Continue(c, _, _) => *c,
591+
};
592+
if code != 0 { Some(code) } else { None }
593+
})
594+
.unwrap_or(0)
595+
} else {
596+
// Without pipefail: return the last command's exit code
597+
match results.last().unwrap() {
598+
ExecuteResult::Exit(code, _) => *code,
599+
ExecuteResult::Continue(code, _, _) => *code,
590600
}
591-
}
601+
};
602+
603+
let all_handles = results.into_iter().flat_map(|r| r.into_handles());
604+
ExecuteResult::Continue(exit_code, Vec::new(), all_handles.collect())
592605
}
593606

594607
async fn execute_subshell(

src/shell/types.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ impl TreeExitCodeCell {
4444
}
4545
}
4646

47+
/// Shell options that can be set via `set -o` / `set +o`.
48+
#[derive(Clone, Default)]
49+
pub struct ShellOptions {
50+
/// When enabled, pipeline exit code is the rightmost non-zero exit code.
51+
pub pipefail: bool,
52+
}
53+
4754
#[derive(Clone)]
4855
pub struct ShellState {
4956
/// Environment variables that should be passed down to sub commands
@@ -54,6 +61,7 @@ pub struct ShellState {
5461
shell_vars: HashMap<OsString, OsString>,
5562
cwd: PathBuf,
5663
commands: Rc<HashMap<String, Rc<dyn ShellCommand>>>,
64+
options: ShellOptions,
5765
kill_signal: KillSignal,
5866
process_tracker: ChildProcessTracker,
5967
tree_exit_code_cell: TreeExitCodeCell,
@@ -74,6 +82,7 @@ impl ShellState {
7482
shell_vars: Default::default(),
7583
cwd: PathBuf::new(),
7684
commands: Rc::new(commands),
85+
options: Default::default(),
7786
kill_signal,
7887
process_tracker: ChildProcessTracker::new(),
7988
tree_exit_code_cell: Default::default(),
@@ -94,6 +103,17 @@ impl ShellState {
94103
&self.env_vars
95104
}
96105

106+
pub fn options(&self) -> &ShellOptions {
107+
&self.options
108+
}
109+
110+
pub fn set_option(&mut self, name: &str, value: bool) {
111+
match name {
112+
"pipefail" => self.options.pipefail = value,
113+
_ => {} // ignore unknown options
114+
}
115+
}
116+
97117
pub fn get_var(&self, name: &OsStr) -> Option<&OsString> {
98118
let name = if cfg!(windows) {
99119
Cow::Owned(name.to_ascii_uppercase())
@@ -138,6 +158,9 @@ impl ShellState {
138158
EnvChange::Cd(new_dir) => {
139159
self.set_cwd(new_dir.clone());
140160
}
161+
EnvChange::SetOption(name, value) => {
162+
self.set_option(name, *value);
163+
}
141164
}
142165
}
143166

@@ -222,6 +245,8 @@ pub enum EnvChange {
222245
// `unset ENV_VAR`
223246
UnsetVar(OsString),
224247
Cd(PathBuf),
248+
// `set -o OPTION` or `set +o OPTION`
249+
SetOption(String, bool),
225250
}
226251

227252
pub type FutureExecuteResult = LocalBoxFuture<'static, ExecuteResult>;

tests/integration_test.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1677,3 +1677,53 @@ async fn sigpipe_from_pipeline() {
16771677
.run()
16781678
.await;
16791679
}
1680+
1681+
#[tokio::test]
1682+
async fn pipefail_option() {
1683+
// Without pipefail: exit code is from last command (0)
1684+
TestBuilder::new()
1685+
.command("sh -c 'exit 1' | true")
1686+
.assert_exit_code(0)
1687+
.run()
1688+
.await;
1689+
1690+
// With pipefail: exit code is rightmost non-zero (1)
1691+
TestBuilder::new()
1692+
.command("set -o pipefail && sh -c 'exit 1' | true")
1693+
.assert_exit_code(1)
1694+
.run()
1695+
.await;
1696+
1697+
// Multiple failures - should return rightmost non-zero
1698+
TestBuilder::new()
1699+
.command("set -o pipefail && sh -c 'exit 2' | sh -c 'exit 3' | true")
1700+
.assert_exit_code(3)
1701+
.run()
1702+
.await;
1703+
1704+
// All succeed - should return 0
1705+
TestBuilder::new()
1706+
.command("set -o pipefail && true | true | true")
1707+
.assert_exit_code(0)
1708+
.run()
1709+
.await;
1710+
1711+
// Disable pipefail with +o
1712+
TestBuilder::new()
1713+
.command("set -o pipefail && set +o pipefail && sh -c 'exit 1' | true")
1714+
.assert_exit_code(0)
1715+
.run()
1716+
.await;
1717+
}
1718+
1719+
#[tokio::test]
1720+
#[cfg(unix)]
1721+
async fn pipefail_with_sigpipe() {
1722+
// With pipefail and SIGPIPE: should return 141 (128 + 13)
1723+
TestBuilder::new()
1724+
.command("set -o pipefail && yes | head -n 1")
1725+
.assert_stdout("y\n")
1726+
.assert_exit_code(141)
1727+
.run()
1728+
.await;
1729+
}

0 commit comments

Comments
 (0)