#![allow(
clippy::disallowed_methods,
reason = "this module exists to wrap wait_with_output safely"
)]
use std::io::{ErrorKind, Write};
use std::path::Path;
use std::process::{Command, ExitStatus, Output, Stdio};
use anyhow::{Context, Result, anyhow};
pub fn spawn_detached(command: &mut Command, log_path: &Path) -> Result<u32> {
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("create detached process log directory {}", parent.display())
})?;
}
let mut options = std::fs::OpenOptions::new();
options.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let log = options
.open(log_path)
.with_context(|| format!("open detached process log {}", log_path.display()))?;
let stderr = log.try_clone().context("clone detached process log")?;
command.stdin(Stdio::null()).stdout(log).stderr(stderr);
#[cfg(unix)]
{
spawn_detached_unix(command)
}
#[cfg(not(unix))]
{
let child = command.spawn().context("spawn detached child process")?;
Ok(child.id())
}
}
#[cfg(unix)]
fn spawn_detached_unix(command: &mut Command) -> Result<u32> {
use std::io::Read;
use std::os::fd::AsRawFd;
use std::os::unix::process::CommandExt;
let (mut reader, writer) = std::io::pipe().context("create detached spawn pid pipe")?;
let report_fd = writer.as_raw_fd();
command.process_group(0);
unsafe {
command.pre_exec(move || match libc::fork() {
-1 => Err(std::io::Error::last_os_error()),
0 => {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
grandchild => {
let pid = (grandchild as u32).to_ne_bytes();
let mut written = 0;
while written < pid.len() {
let count = libc::write(
report_fd,
pid.as_ptr().add(written).cast(),
pid.len() - written,
);
if count <= 0 {
break;
}
written += count as usize;
}
libc::_exit(0)
}
});
}
let spawned = command.spawn().context("spawn detached child process");
drop(writer);
let mut intermediate = spawned?;
let mut pid_bytes = [0_u8; 4];
let reported = reader.read_exact(&mut pid_bytes);
let status = intermediate
.wait()
.context("wait for detached spawn intermediate")?;
reported.context("read detached child pid from the spawn intermediate")?;
if !status.success() {
return Err(anyhow!(
"detached spawn intermediate exited with {status}, so the child may not have started"
));
}
Ok(u32::from_ne_bytes(pid_bytes))
}
pub fn run_with_input(command: &mut Command, input: &[u8]) -> Result<Output> {
let mut child = command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("spawn child process")?;
let mut stdin = child.stdin.take().context("child stdin is missing")?;
let input = input.to_vec();
let writer = std::thread::spawn(move || -> std::io::Result<()> {
let result = stdin.write_all(&input);
drop(stdin);
result
});
let output = child.wait_with_output().context("wait for child process")?;
match writer.join() {
Ok(Ok(())) => {}
Ok(Err(error)) if error.kind() == ErrorKind::BrokenPipe => {
}
Ok(Err(error)) => return Err(error).context("write child process stdin"),
Err(panic) => {
return Err(anyhow!(
"child process stdin writer thread panicked: {panic:?}"
));
}
}
Ok(output)
}
pub fn run_capturing_stdout(command: &mut Command) -> Result<Output> {
let child = command
.stdin(Stdio::inherit())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.context("spawn child process")?;
child.wait_with_output().context("wait for child process")
}
pub fn run_inherited(command: &mut Command) -> Result<ExitStatus> {
command
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.context("run child process")
}
#[cfg(unix)]
pub fn signal_process_group(pid: i32, signal: i32) -> std::io::Result<()> {
if unsafe { libc::kill(-pid, signal) } == 0 {
return Ok(());
}
let error = std::io::Error::last_os_error();
if group_signal_error_is_ignorable(&error) {
return Ok(());
}
Err(error)
}
#[cfg(unix)]
pub fn terminate_process_group(pid: i32, signal: i32) {
if let Err(error) = signal_process_group(pid, signal) {
tracing::warn!(pid, signal, %error, "could not signal process group");
}
}
#[cfg(unix)]
fn group_signal_error_is_ignorable(error: &std::io::Error) -> bool {
if error.raw_os_error() == Some(libc::ESRCH) {
return true;
}
#[cfg(target_os = "macos")]
if error.raw_os_error() == Some(libc::EPERM) {
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn run_with_input_completes_when_child_echoes_input_larger_than_pipe_buffer() {
let input = vec![b'x'; 512 * 1024];
let mut command = Command::new("sh");
command.arg("-c").arg("cat");
let output = run_with_input(&mut command, &input)
.expect("run_with_input should not deadlock or fail");
assert!(output.status.success());
assert_eq!(output.stdout, input);
}
#[cfg(unix)]
#[test]
fn run_with_input_reports_child_status_when_child_exits_before_reading_all_input() {
let input = vec![b'x'; 512 * 1024];
let mut command = Command::new("sh");
command.arg("-c").arg("exit 3");
let output = run_with_input(&mut command, &input)
.expect("a broken pipe from an early exit must not be a hard error");
assert_eq!(output.status.code(), Some(3));
}
#[cfg(unix)]
#[test]
fn run_capturing_stdout_collects_more_than_one_pipe_buffer() {
let mut command = Command::new("sh");
command
.arg("-c")
.arg("dd if=/dev/zero bs=1024 count=512 2>/dev/null | tr '\\0' 'x'");
let output = run_capturing_stdout(&mut command).expect("capture a large stdout");
assert!(output.status.success());
assert_eq!(output.stdout.len(), 512 * 1024);
}
#[test]
fn run_with_input_returns_output_for_empty_input() {
let mut command = Command::new("true");
let output = run_with_input(&mut command, &[]).expect("run_with_input should succeed");
assert!(output.status.success());
}
#[cfg(unix)]
#[test]
fn spawn_detached_leaves_no_zombie_under_a_spawner_that_keeps_running() {
use std::time::{Duration, Instant};
let log_dir = tempfile::tempdir().expect("create log directory");
let mut command = Command::new("sh");
command.arg("-c").arg("exit 0");
let pid = spawn_detached(&mut command, &log_dir.path().join("child.log"))
.expect("spawn_detached should start the child");
let raw_pid = libc::pid_t::try_from(pid).expect("pid fits pid_t");
let mut status = 0;
let waited = unsafe { libc::waitpid(raw_pid, &mut status, libc::WNOHANG) };
assert_eq!(waited, -1, "the detached child must not be our own child");
assert_eq!(
std::io::Error::last_os_error().raw_os_error(),
Some(libc::ECHILD)
);
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let state = detached_test_process_state(pid);
if state.is_none() {
break;
}
assert!(
Instant::now() < deadline,
"process {pid} never left the process table (last state: {state:?})"
);
std::thread::sleep(Duration::from_millis(20));
}
}
#[cfg(unix)]
fn detached_test_process_state(pid: u32) -> Option<char> {
#[cfg(target_os = "linux")]
{
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let after_comm = stat.rsplit_once(')')?.1;
after_comm.split_whitespace().next()?.chars().next()
}
#[cfg(not(target_os = "linux"))]
{
let raw_pid = libc::pid_t::try_from(pid).ok()?;
if unsafe { libc::kill(raw_pid, 0) } == 0 {
Some('?')
} else {
None
}
}
}
#[cfg(target_os = "linux")]
#[test]
fn spawn_detached_reports_the_real_child_and_leaves_it_leading_its_own_group() {
let log_dir = tempfile::tempdir().expect("create log directory");
let mut command = Command::new("sleep");
command.arg("30");
let pid = spawn_detached(&mut command, &log_dir.path().join("child.log"))
.expect("spawn_detached should start the child");
let comm = std::fs::read_to_string(format!("/proc/{pid}/comm"))
.expect("the reported pid must name a live process");
assert_eq!(comm.trim(), "sleep");
let raw_pid = libc::pid_t::try_from(pid).expect("pid fits pid_t");
let group = unsafe { libc::getpgid(raw_pid) };
assert_eq!(group, raw_pid, "the child must lead its own process group");
signal_process_group(raw_pid, libc::SIGKILL).expect("terminate the detached child group");
}
#[cfg(unix)]
#[test]
fn signalling_a_group_that_is_already_gone_succeeds() {
use std::os::unix::process::CommandExt as _;
let mut command = Command::new("sh");
command.arg("-c").arg("exit 0");
command.process_group(0);
let mut child = command.spawn().expect("spawn short-lived child");
let pid = child.id() as i32;
child.wait().expect("reap short-lived child");
signal_process_group(pid, libc::SIGKILL)
.expect("signalling an already-exited process group must succeed");
}
#[cfg(unix)]
#[test]
fn signalling_a_live_group_reports_a_real_failure() {
use std::os::unix::process::CommandExt as _;
let mut command = Command::new("sleep");
command.arg("30");
command.process_group(0);
let mut child = command.spawn().expect("spawn long-lived child");
let pid = child.id() as i32;
let error =
signal_process_group(pid, 1234).expect_err("an invalid signal number must be reported");
assert_eq!(error.raw_os_error(), Some(libc::EINVAL));
signal_process_group(pid, libc::SIGKILL).expect("terminate the test child");
child.wait().expect("reap long-lived child");
}
#[cfg(unix)]
#[test]
fn group_signal_error_only_ignores_a_gone_owned_group() {
let missing = std::io::Error::from_raw_os_error(libc::ESRCH);
assert!(group_signal_error_is_ignorable(&missing));
let invalid = std::io::Error::from_raw_os_error(libc::EINVAL);
assert!(!group_signal_error_is_ignorable(&invalid));
let denied = std::io::Error::from_raw_os_error(libc::EPERM);
#[cfg(target_os = "macos")]
assert!(group_signal_error_is_ignorable(&denied));
#[cfg(not(target_os = "macos"))]
assert!(!group_signal_error_is_ignorable(&denied));
}
}