Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions src/shell/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod head;
mod mkdir;
mod pwd;
mod rm;
mod set;
mod sleep;
mod unset;
mod xargs;
Expand Down Expand Up @@ -76,6 +77,10 @@ pub fn builtin_commands() -> HashMap<String, Rc<dyn ShellCommand>> {
"rm".to_string(),
Rc::new(rm::RmCommand) as Rc<dyn ShellCommand>,
),
(
"set".to_string(),
Rc::new(set::SetCommand) as Rc<dyn ShellCommand>,
),
(
"sleep".to_string(),
Rc::new(sleep::SleepCommand) as Rc<dyn ShellCommand>,
Expand Down
62 changes: 62 additions & 0 deletions src/shell/commands/set.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright 2018-2025 the Deno authors. MIT license.

use futures::future::LocalBoxFuture;

use crate::shell::types::EnvChange;
use crate::shell::types::ExecuteResult;

use super::ShellCommand;
use super::ShellCommandContext;

pub struct SetCommand;

impl ShellCommand for SetCommand {
fn execute(
&self,
mut context: ShellCommandContext,
) -> LocalBoxFuture<'static, ExecuteResult> {
let result = execute_set(&mut context);
Box::pin(futures::future::ready(result))
}
}

fn execute_set(context: &mut ShellCommandContext) -> ExecuteResult {
let mut changes = Vec::new();
let args: Vec<String> = context
.args
.iter()
.filter_map(|a| a.to_str().map(|s| s.to_string()))
.collect();

let mut i = 0;
while i < args.len() {
let arg = &args[i];
if arg == "-o" || arg == "+o" {
let enable = arg == "-o";
if i + 1 < args.len() {
let option_name = &args[i + 1];
match option_name.as_str() {
"pipefail" => {
changes.push(EnvChange::SetOption("pipefail".to_string(), enable));
}
_ => {
let _ = context
.stderr
.write_line(&format!("set: unknown option: {}", option_name));
return ExecuteResult::from_exit_code(1);
}
}
i += 2;
} else {
// No option name provided - in bash this would list options
// For now, just return success
i += 1;
}
} else {
// Unknown argument
i += 1;
}
}

ExecuteResult::Continue(0, changes, Vec::new())
}
37 changes: 25 additions & 12 deletions src/shell/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -575,20 +575,33 @@ async fn execute_pipe_sequence(
let output_handle = tokio::task::spawn_blocking(|| {
last_output.unwrap().pipe_to_sender(stdout).unwrap();
});
let mut results = futures::future::join_all(wait_tasks).await;
let results = futures::future::join_all(wait_tasks).await;
output_handle.await.unwrap();
let last_result = results.pop().unwrap();
let all_handles = results.into_iter().flat_map(|r| r.into_handles());
match last_result {
ExecuteResult::Exit(code, mut handles) => {
handles.extend(all_handles);
ExecuteResult::Continue(code, Vec::new(), handles)
}
ExecuteResult::Continue(code, _, mut handles) => {
handles.extend(all_handles);
ExecuteResult::Continue(code, Vec::new(), handles)

// Determine exit code based on pipefail option
let exit_code = if state.options().pipefail {
// With pipefail: return the rightmost non-zero exit code, or 0 if all succeeded
results
.iter()
.rev()
.find_map(|r| {
let code = match r {
ExecuteResult::Exit(c, _) => *c,
ExecuteResult::Continue(c, _, _) => *c,
};
if code != 0 { Some(code) } else { None }
})
.unwrap_or(0)
} else {
// Without pipefail: return the last command's exit code
match results.last().unwrap() {
ExecuteResult::Exit(code, _) => *code,
ExecuteResult::Continue(code, _, _) => *code,
}
}
};

let all_handles = results.into_iter().flat_map(|r| r.into_handles());
ExecuteResult::Continue(exit_code, Vec::new(), all_handles.collect())
}

async fn execute_subshell(
Expand Down
25 changes: 25 additions & 0 deletions src/shell/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ impl TreeExitCodeCell {
}
}

/// Shell options that can be set via `set -o` / `set +o`.
#[derive(Clone, Default)]
pub struct ShellOptions {
/// When enabled, pipeline exit code is the rightmost non-zero exit code.
pub pipefail: bool,
}

#[derive(Clone)]
pub struct ShellState {
/// Environment variables that should be passed down to sub commands
Expand All @@ -54,6 +61,7 @@ pub struct ShellState {
shell_vars: HashMap<OsString, OsString>,
cwd: PathBuf,
commands: Rc<HashMap<String, Rc<dyn ShellCommand>>>,
options: ShellOptions,
kill_signal: KillSignal,
process_tracker: ChildProcessTracker,
tree_exit_code_cell: TreeExitCodeCell,
Expand All @@ -74,6 +82,7 @@ impl ShellState {
shell_vars: Default::default(),
cwd: PathBuf::new(),
commands: Rc::new(commands),
options: Default::default(),
kill_signal,
process_tracker: ChildProcessTracker::new(),
tree_exit_code_cell: Default::default(),
Expand All @@ -94,6 +103,17 @@ impl ShellState {
&self.env_vars
}

pub fn options(&self) -> &ShellOptions {
&self.options
}

pub fn set_option(&mut self, name: &str, value: bool) {
match name {
"pipefail" => self.options.pipefail = value,
_ => {} // ignore unknown options
}
}

pub fn get_var(&self, name: &OsStr) -> Option<&OsString> {
let name = if cfg!(windows) {
Cow::Owned(name.to_ascii_uppercase())
Expand Down Expand Up @@ -138,6 +158,9 @@ impl ShellState {
EnvChange::Cd(new_dir) => {
self.set_cwd(new_dir.clone());
}
EnvChange::SetOption(name, value) => {
self.set_option(name, *value);
}
}
}

Expand Down Expand Up @@ -222,6 +245,8 @@ pub enum EnvChange {
// `unset ENV_VAR`
UnsetVar(OsString),
Cd(PathBuf),
// `set -o OPTION` or `set +o OPTION`
SetOption(String, bool),
}

pub type FutureExecuteResult = LocalBoxFuture<'static, ExecuteResult>;
Expand Down
50 changes: 50 additions & 0 deletions tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1677,3 +1677,53 @@ async fn sigpipe_from_pipeline() {
.run()
.await;
}

#[tokio::test]
async fn pipefail_option() {
// Without pipefail: exit code is from last command (0)
TestBuilder::new()
.command("sh -c 'exit 1' | true")
.assert_exit_code(0)
.run()
.await;

// With pipefail: exit code is rightmost non-zero (1)
TestBuilder::new()
.command("set -o pipefail && sh -c 'exit 1' | true")
.assert_exit_code(1)
.run()
.await;

// Multiple failures - should return rightmost non-zero
TestBuilder::new()
.command("set -o pipefail && sh -c 'exit 2' | sh -c 'exit 3' | true")
.assert_exit_code(3)
.run()
.await;

// All succeed - should return 0
TestBuilder::new()
.command("set -o pipefail && true | true | true")
.assert_exit_code(0)
.run()
.await;

// Disable pipefail with +o
TestBuilder::new()
.command("set -o pipefail && set +o pipefail && sh -c 'exit 1' | true")
.assert_exit_code(0)
.run()
.await;
}

#[tokio::test]
#[cfg(unix)]
async fn pipefail_with_sigpipe() {
// With pipefail and SIGPIPE: should return 141 (128 + 13)
TestBuilder::new()
.command("set -o pipefail && yes | head -n 1")
.assert_stdout("y\n")
.assert_exit_code(141)
.run()
.await;
}
Loading