use std::{
cell::Cell,
io::{self, Write},
path::Path,
process::{Command, Stdio},
};
use ic_host_process::{
child::OwnedChild,
tool::{
ExecutionFailure, ExecutionOperation, OutputLimit, OutputLimits, SuccessfulExit, ToolError,
communicate_child_with_observer,
},
};
use crate::{output_with_executable_busy_retry, with_executable_busy_retry};
const FOREGROUND_DIAGNOSTIC_BYTES: usize = 64 * 1024;
use super::{
command::{command_display, configure_inherited_fd, ensure_command_compatible},
error::IcpCommandError,
model::{IcpCli, IcpRawOutput},
version::compatible_version_output,
};
pub(super) fn run_output(command: &mut Command, icp: &IcpCli) -> Result<String, IcpCommandError> {
icp.ensure_compatible_command(command)?;
run_output_unchecked(command)
}
pub(super) fn run_secret_output(
command: &mut Command,
icp: &IcpCli,
) -> Result<Vec<u8>, IcpCommandError> {
icp.ensure_compatible_command(command)?;
let display = command_display(command);
let mut output = output_with_executable_busy_retry(command)?;
if output.status.success() {
Ok(output.stdout)
} else {
output.stdout.fill(0);
Err(IcpCommandError::Failed {
command: display,
stderr: command_stderr(&output),
})
}
}
fn run_output_unchecked(command: &mut Command) -> Result<String, IcpCommandError> {
let display = command_display(command);
let output = output_with_executable_busy_retry(command)?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
Err(IcpCommandError::Failed {
command: display,
stderr: command_stderr(&output),
})
}
}
pub fn run_output_with_stderr(command: &mut Command) -> Result<String, IcpCommandError> {
ensure_command_compatible(command)?;
let display = command_display(command);
let output = output_with_executable_busy_retry(command)?;
if output.status.success() {
let mut text = String::from_utf8_lossy(&output.stdout).to_string();
text.push_str(&String::from_utf8_lossy(&output.stderr));
Ok(text.trim().to_string())
} else {
Err(IcpCommandError::Failed {
command: display,
stderr: command_stderr(&output),
})
}
}
pub(super) fn run_json<T>(command: &mut Command, icp: &IcpCli) -> Result<T, IcpCommandError>
where
T: serde::de::DeserializeOwned,
{
icp.ensure_compatible_command(command)?;
let display = command_display(command);
let output = output_with_executable_busy_retry(command)?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
serde_json::from_str(&stdout).map_err(|source| IcpCommandError::Json {
command: display,
output: stdout,
source,
})
} else {
Err(IcpCommandError::Failed {
command: display,
stderr: command_stderr(&output),
})
}
}
pub fn run_status(command: &mut Command) -> Result<(), IcpCommandError> {
ensure_command_compatible(command)?;
let display = command_display(command);
let output = output_with_executable_busy_retry(command)?;
if output.status.success() {
Ok(())
} else {
Err(IcpCommandError::Failed {
command: display,
stderr: command_stderr(&output),
})
}
}
pub(super) fn run_status_inherit(command: &mut Command) -> Result<(), IcpCommandError> {
ensure_command_compatible(command)?;
run_status_inherit_unchecked(command)
}
pub(super) fn run_status_inherit_unchecked(command: &mut Command) -> Result<(), IcpCommandError> {
run_status_inherit_to(command, &mut io::stderr().lock())
}
fn run_status_inherit_to(
command: &mut Command,
terminal: &mut impl Write,
) -> Result<(), IcpCommandError> {
let display = command_display(command);
command.stdout(Stdio::inherit()).stderr(Stdio::piped());
let mut child = with_executable_busy_retry(|| OwnedChild::spawn_direct(command))?;
let forwarding_failed = Cell::new(false);
let mut forwarding_error = None;
let result = communicate_child_with_observer(
&mut child,
None,
OutputLimits {
stdout: OutputLimit::Terminate(0),
stderr: OutputLimit::Truncate(FOREGROUND_DIAGNOSTIC_BYTES),
timeout: None,
},
SuccessfulExit::Cleanup,
|| forwarding_failed.get(),
|_, bytes| {
if let Err(source) = terminal.write_all(bytes) {
forwarding_error = Some(source);
forwarding_failed.set(true);
}
},
);
if let Some(source) = forwarding_error {
let kind = source.kind();
if let Err(ToolError::Execution(mut error)) = result {
error.failure = ExecutionFailure::Io {
operation: ExecutionOperation::ReadOutput,
source,
};
return Err(io::Error::new(kind, ToolError::Execution(error)).into());
}
return Err(source.into());
}
terminal.flush()?;
result
.map(|_| ())
.map_err(|error| foreground_command_error(display, error))
}
fn foreground_command_error(command: String, error: ToolError) -> IcpCommandError {
if let Some(execution) = error.execution_error()
&& matches!(execution.failure, ExecutionFailure::ExitStatus)
&& execution.cleanup.is_none()
{
let mut stderr = if execution.evidence.stderr.is_empty() {
execution.evidence.status.map_or_else(
|| "command exited unsuccessfully".to_string(),
|status| format!("command exited with status {}", exit_status_label(status)),
)
} else {
String::from_utf8_lossy(&execution.evidence.stderr).to_string()
};
if execution.evidence.stderr_truncated {
stderr.push_str("\n[retained stderr truncated; full output was forwarded]");
}
return IcpCommandError::Failed { command, stderr };
}
let kind = match error.execution_error().map(|execution| &execution.failure) {
Some(ExecutionFailure::Io { source, .. }) => source.kind(),
_ => io::ErrorKind::Other,
};
IcpCommandError::Io(io::Error::new(kind, error))
}
pub fn run_success(command: &mut Command) -> Result<bool, IcpCommandError> {
ensure_command_compatible(command)?;
Ok(output_with_executable_busy_retry(command)?.status.success())
}
pub fn run_raw_output(
program: &str,
args: &[String],
inherited_fd: Option<i32>,
) -> Result<IcpRawOutput, std::io::Error> {
if is_icp_program(program) {
compatible_version_output(program, None)
.map_err(|err| io::Error::other(err.to_string()))?;
}
let mut command = Command::new(program);
command.args(args);
configure_inherited_fd(&mut command, inherited_fd);
let output = output_with_executable_busy_retry(&mut command)?;
Ok(IcpRawOutput {
success: output.status.success(),
status: exit_status_label(output.status),
stdout: output.stdout,
stderr: output.stderr,
})
}
fn is_icp_program(program: &str) -> bool {
Path::new(program)
.file_name()
.is_some_and(|file_name| file_name == "icp")
}
pub(super) fn command_stderr(output: &std::process::Output) -> String {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.trim().is_empty() {
String::from_utf8_lossy(&output.stdout).to_string()
} else {
stderr.to_string()
}
}
fn exit_status_label(status: std::process::ExitStatus) -> String {
status
.code()
.map_or_else(|| "signal".to_string(), |code| code.to_string())
}
impl IcpCli {
pub(super) fn ensure_compatible_command(
&self,
command: &Command,
) -> Result<(), IcpCommandError> {
if command.get_program() == std::ffi::OsStr::new(&self.executable)
&& command.get_current_dir() == self.cwd.as_deref()
{
self.compatible_version().map(|_| ())
} else {
ensure_command_compatible(command)
}
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::{fs, os::unix::process::CommandExt as _};
#[test]
fn foreground_runner_forwards_bytes_and_retains_failed_command_diagnostics() {
for (script, expected) in [
("printf 'progress' >&2", None),
("printf 'failed' >&2; exit 7", Some("failed")),
("exit 7", Some("command exited with status 7")),
] {
let mut terminal = Vec::new();
let result =
run_status_inherit_to(Command::new("sh").args(["-c", script]), &mut terminal);
match expected {
None => {
result.unwrap();
assert_eq!(terminal, b"progress");
}
Some(expected) => {
let IcpCommandError::Failed { stderr, .. } = result.unwrap_err() else {
panic!("expected failed command diagnostics");
};
assert_eq!(stderr, expected);
if expected == "failed" {
assert_eq!(terminal, b"failed");
} else {
assert!(terminal.is_empty());
}
}
}
}
}
#[test]
fn foreground_runner_forwards_complete_output_with_bounded_failure_diagnostics() {
let mut command = Command::new("sh");
command.args(["-c", "perl -e 'print \"x\" x 131072' >&2; exit 7"]);
let mut terminal = Vec::new();
let IcpCommandError::Failed { stderr, .. } =
run_status_inherit_to(&mut command, &mut terminal).unwrap_err()
else {
panic!("expected failed command diagnostics");
};
assert_eq!(terminal, vec![b'x'; 131_072]);
assert_eq!(
stderr,
format!(
"{}\n[retained stderr truncated; full output was forwarded]",
"x".repeat(FOREGROUND_DIAGNOSTIC_BYTES)
)
);
}
#[test]
fn foreground_runner_preserves_inherited_and_configured_process_groups() {
for configured_group in [false, true] {
let mut command = Command::new("sh");
command.args(["-c", "printf '%s ' \"$$\" >&2; ps -o pgid= -p $$ >&2"]);
if configured_group {
command.process_group(0);
}
let mut terminal = Vec::new();
run_status_inherit_to(&mut command, &mut terminal).unwrap();
let ids = String::from_utf8(terminal)
.unwrap()
.split_whitespace()
.map(|id| id.parse::<u32>().unwrap())
.collect::<Vec<_>>();
let expected_group = if configured_group {
ids[0]
} else {
rustix::process::getpgrp()
.as_raw_nonzero()
.get()
.cast_unsigned()
};
assert_eq!(ids[1], expected_group);
}
}
#[test]
fn foreground_terminal_failure_reaps_the_direct_child_and_retains_io_cause() {
struct BrokenTerminal;
impl Write for BrokenTerminal {
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
Err(io::ErrorKind::BrokenPipe.into())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
let root = crate::test_support::temp_dir("foreground-terminal-error");
fs::create_dir_all(&root).unwrap();
let pid_file = root.join("pid");
let mut command = Command::new("sh");
command.env("FOREGROUND_PID_FILE", &pid_file).args([
"-c",
"printf '%s' \"$$\" > \"$FOREGROUND_PID_FILE\"; printf ready >&2; exec sleep 30",
]);
let IcpCommandError::Io(source) =
run_status_inherit_to(&mut command, &mut BrokenTerminal).expect_err("terminal failure")
else {
panic!("expected terminal IO failure");
};
assert_eq!(source.kind(), io::ErrorKind::BrokenPipe);
let execution = source
.get_ref()
.and_then(|source| source.downcast_ref::<ToolError>())
.and_then(ToolError::execution_error)
.expect("shared cleanup evidence");
assert!(matches!(
&execution.failure,
ExecutionFailure::Io { source, .. } if source.kind() == io::ErrorKind::BrokenPipe
));
assert!(execution.cleanup.is_none());
let pid = fs::read_to_string(pid_file)
.unwrap()
.parse::<i32>()
.unwrap();
assert_eq!(
rustix::process::test_kill_process(rustix::process::Pid::from_raw(pid).unwrap()),
Err(rustix::io::Errno::SRCH)
);
fs::remove_dir_all(root).unwrap();
}
}