#[cfg(target_os = "linux")]
use std::path as path_std_path;
use std::sync::atomic::Ordering;
use std::{
cell as path_std_cell, collections as path_std_collections, fs as path_std_fs,
io as path_std_io, process as path_std_process, time as path_std_time,
};
#[cfg(all(unix, not(target_os = "linux")))]
use nix::sys::signal as path_nix_signal;
use nix::unistd::Pid;
use super::*;
#[derive(Debug, PartialEq, Eq)]
enum DescendantObservation {
Absent,
Zombie,
Live(String),
}
fn wait_for_descendant_termination_with<Observe, Now, Wait>(
pid: i32,
deadline: path_std_time::Instant,
mut observe: Observe,
mut now: Now,
mut wait: Wait,
) -> Result<(), String>
where
Observe: FnMut(i32) -> Result<DescendantObservation, String>,
Now: FnMut() -> path_std_time::Instant,
Wait: FnMut(),
{
if pid <= 0 {
return Err(format!("descendant PID must be positive, got {pid}"));
}
loop {
let observation = observe(pid)
.map_err(|error| format!("could not observe descendant process {pid}: {error}"))?;
if matches!(
observation,
DescendantObservation::Absent | DescendantObservation::Zombie
) {
return Ok(());
}
if deadline <= now() {
return Err(format!(
"descendant process {pid} did not terminate before the observation deadline; \
last observation: {observation:?}"
));
}
wait();
}
}
fn wait_for_descendant_termination(pid: i32) -> Result<(), String> {
let deadline = path_std_time::Instant::now() + path_std_time::Duration::from_secs(1);
wait_for_descendant_termination_with(
pid,
deadline,
observe_descendant,
path_std_time::Instant::now,
std::thread::yield_now,
)
}
#[cfg(target_os = "linux")]
fn observe_descendant(pid: i32) -> Result<DescendantObservation, String> {
observe_linux_descendant_with(pid, |path| path_std_fs::read_to_string(path))
}
#[cfg(target_os = "linux")]
fn observe_linux_descendant_with<Read>(
pid: i32,
read: Read,
) -> Result<DescendantObservation, String>
where
Read: FnOnce(&path_std_path::Path) -> path_std_io::Result<String>,
{
let path = path_std_path::PathBuf::from(format!("/proc/{pid}/stat"));
let stat = match read(&path) {
Ok(stat) => stat,
Err(error) if error.kind() == path_std_io::ErrorKind::NotFound => {
return Ok(DescendantObservation::Absent);
}
Err(error) => return Err(format!("could not read {}: {error}", path.display())),
};
let (_, fields) = stat
.rsplit_once(") ")
.ok_or_else(|| format!("malformed process stat from {}", path.display()))?;
let (state, _) = fields
.split_once(' ')
.ok_or_else(|| format!("missing process state in {}", path.display()))?;
let mut state_chars = state.chars();
let state = state_chars
.next()
.filter(|_| state_chars.next().is_none())
.ok_or_else(|| format!("malformed process state in {}", path.display()))?;
if state == 'Z' {
Ok(DescendantObservation::Zombie)
} else {
Ok(DescendantObservation::Live(state.to_string()))
}
}
#[cfg(unix)]
fn classify_kill_observation(
result: Result<(), path_nix_errno::Errno>,
) -> Result<DescendantObservation, String> {
match result {
Ok(()) => Ok(DescendantObservation::Live(
"process still exists".to_owned(),
)),
Err(path_nix_errno::Errno::ESRCH) => Ok(DescendantObservation::Absent),
Err(error) => Err(format!("kill(pid, 0) failed: {error}")),
}
}
#[cfg(all(unix, not(target_os = "linux")))]
fn observe_descendant(pid: i32) -> Result<DescendantObservation, String> {
classify_kill_observation(path_nix_signal::kill(Pid::from_raw(pid), None))
}
#[cfg(not(unix))]
fn observe_descendant(pid: i32) -> Result<DescendantObservation, String> {
let status = path_std_process::Command::new("kill")
.arg("-0")
.arg(pid.to_string())
.stdin(path_std_process::Stdio::null())
.stdout(path_std_process::Stdio::null())
.stderr(path_std_process::Stdio::null())
.status()
.map_err(|error| format!("could not run kill -0: {error}"))?;
if status.success() {
Ok(DescendantObservation::Live(
"kill -0 reports that the process exists".to_owned(),
))
} else {
Ok(DescendantObservation::Absent)
}
}
#[test]
fn descendant_termination_poll_is_bounded() {
let start = path_std_time::Instant::now();
let clock_ticks = path_std_cell::Cell::new(0);
let error = wait_for_descendant_termination_with(
41,
start + path_std_time::Duration::from_millis(2),
|_| Ok(DescendantObservation::Live("R".to_owned())),
|| {
let tick = clock_ticks.get() + 1;
clock_ticks.set(tick);
start + path_std_time::Duration::from_millis(tick)
},
|| {},
)
.expect_err("a persistently live process must exhaust the deadline");
assert!(error.contains("last observation: Live(\"R\")"));
assert_eq!(clock_ticks.get(), 2);
for terminal in [DescendantObservation::Zombie, DescendantObservation::Absent] {
let mut observations = path_std_collections::VecDeque::from([
DescendantObservation::Live("S".to_owned()),
terminal,
]);
wait_for_descendant_termination_with(
42,
start + path_std_time::Duration::from_secs(1),
|_| {
Ok(observations
.pop_front()
.expect("bounded observation sequence"))
},
|| start,
|| {},
)
.expect("a terminal observation must complete the wait");
}
let error = wait_for_descendant_termination_with(
0,
start,
|_| unreachable!("invalid PIDs must be rejected before observation"),
|| start,
|| {},
)
.expect_err("zero is not a process PID");
assert!(error.contains("must be positive"));
}
#[cfg(unix)]
#[test]
fn descendant_kill_observation_classifies_only_esrch_as_absent() {
assert_eq!(
classify_kill_observation(Ok(())),
Ok(DescendantObservation::Live(
"process still exists".to_owned()
))
);
assert_eq!(
classify_kill_observation(Err(path_nix_errno::Errno::ESRCH)),
Ok(DescendantObservation::Absent)
);
let error = classify_kill_observation(Err(path_nix_errno::Errno::EPERM))
.expect_err("permission errors must not imply process absence");
assert!(error.contains("EPERM"));
}
#[cfg(target_os = "linux")]
#[test]
fn linux_descendant_observation_classifies_process_state() {
for state in ['R', 'S', 'D', 'T'] {
let stat = format!("41 (command) name (worker)) {state} 1 2 3");
assert_eq!(
observe_linux_descendant_with(41, |_| Ok(stat)),
Ok(DescendantObservation::Live(state.to_string()))
);
}
assert_eq!(
observe_linux_descendant_with(41, |_| {
Ok("41 (command) name (worker)) Z 1 2 3".to_owned())
}),
Ok(DescendantObservation::Zombie)
);
assert_eq!(
observe_linux_descendant_with(41, |_| {
Err(path_std_io::Error::from(path_std_io::ErrorKind::NotFound))
}),
Ok(DescendantObservation::Absent)
);
for malformed in ["41 command S 1 2 3", "41 (command)", "41 (command) RR 1"] {
assert!(
observe_linux_descendant_with(41, |_| Ok(malformed.to_owned())).is_err(),
"malformed stat was accepted: {malformed:?}"
);
}
let error = observe_linux_descendant_with(41, |_| {
Err(path_std_io::Error::from(
path_std_io::ErrorKind::PermissionDenied,
))
})
.expect_err("permission errors must not imply process absence");
assert!(error.contains("/proc/41/stat"));
assert!(error.to_ascii_lowercase().contains("permission denied"));
}
#[cfg(unix)]
#[test]
fn foreground_claim_injected_actual_tau_group_matrix() {
let tau_pgid = Pid::from_raw(4100);
let other_pgid = Pid::from_raw(4200);
let set_attempts = path_std_cell::Cell::new(0);
let claimed = claim_foreground_process_group_with(
tau_pgid,
|| Err(path_nix_errno::Errno::ENOTTY),
|| {
set_attempts.set(set_attempts.get() + 1);
Ok(())
},
)
.expect("no controlling terminal is a noninteractive no-handoff");
assert!(!claimed);
assert_eq!(
set_attempts.get(),
0,
"noninteractive detection must precede handoff"
);
let set_attempts = path_std_cell::Cell::new(0);
let error = claim_foreground_process_group_with(
tau_pgid,
|| Err(path_nix_errno::Errno::EIO),
|| {
set_attempts.set(set_attempts.get() + 1);
Ok(())
},
)
.expect_err("an unreadable initial foreground group must fail stop");
assert!(error.is_foreground_ownership_unconfirmed());
let diagnostic = error
.foreground_restoration_diagnostic()
.expect("initial query diagnostic");
assert_eq!(diagnostic.class(), "initial-foreground-unconfirmed");
assert_eq!(diagnostic.errno(), Some(path_nix_errno::Errno::EIO as i32));
assert_eq!(
set_attempts.get(),
0,
"failed initial query must precede handoff"
);
let set_attempts = path_std_cell::Cell::new(0);
let error = claim_foreground_process_group_with(
tau_pgid,
|| Ok(other_pgid),
|| {
set_attempts.set(set_attempts.get() + 1);
Ok(())
},
)
.expect_err("initial non-Tau foreground group must fail stop");
assert!(error.is_foreground_ownership_unconfirmed());
let diagnostic = error
.foreground_restoration_diagnostic()
.expect("initial mismatch diagnostic");
assert_eq!(diagnostic.class(), "initial-foreground-mismatch");
assert_eq!(diagnostic.errno(), None);
assert_eq!(set_attempts.get(), 0, "mismatch must precede handoff");
let get_attempts = path_std_cell::Cell::new(0);
let set_attempts = path_std_cell::Cell::new(0);
let error = claim_foreground_process_group_with(
tau_pgid,
|| {
let attempt = get_attempts.get();
get_attempts.set(attempt + 1);
match attempt {
0 => Err(path_nix_errno::Errno::EINTR),
_ => Ok(tau_pgid),
}
},
|| {
let attempt = set_attempts.get();
set_attempts.set(attempt + 1);
match attempt {
0 => Err(path_nix_errno::Errno::EINTR),
_ => Err(path_nix_errno::Errno::EPERM),
}
},
)
.expect_err("failed handoff remains an ordinary error only while Tau is foreground");
assert!(!error.is_foreground_ownership_unconfirmed());
assert_eq!(get_attempts.get(), 3);
assert_eq!(set_attempts.get(), 2);
let get_attempts = path_std_cell::Cell::new(0);
let error = claim_foreground_process_group_with(
tau_pgid,
|| {
let attempt = get_attempts.get();
get_attempts.set(attempt + 1);
if attempt == 0 {
Ok(tau_pgid)
} else {
Ok(other_pgid)
}
},
|| Err(path_nix_errno::Errno::EPERM),
)
.expect_err("changed non-Tau foreground group must fail stop");
assert!(error.is_foreground_ownership_unconfirmed());
let diagnostic = error
.foreground_restoration_diagnostic()
.expect("changed-foreground diagnostic");
assert_eq!(diagnostic.class(), "foreground-handoff-unconfirmed");
assert_eq!(
diagnostic.errno(),
Some(path_nix_errno::Errno::EPERM as i32)
);
}
#[cfg(unix)]
#[test]
fn foreground_restoration_injected_job_control_matrix() {
let target = Pid::from_raw(4100);
let set_attempts = path_std_cell::Cell::new(0);
let get_attempts = path_std_cell::Cell::new(0);
restore_foreground_process_group_with(
target,
|| {
let attempt = set_attempts.get();
set_attempts.set(attempt + 1);
if attempt < 2 {
Err(path_nix_errno::Errno::EINTR)
} else {
Ok(())
}
},
|| {
get_attempts.set(get_attempts.get() + 1);
Ok(target)
},
)
.expect("EINTR retries should reach successful restoration");
assert_eq!(set_attempts.get(), 3);
assert_eq!(get_attempts.get(), 0);
let error = restore_foreground_process_group_with(
target,
|| Err(path_nix_errno::Errno::ENOTTY),
|| Err(path_nix_errno::Errno::ENOTTY),
)
.expect_err("lost controlling terminal must retain fail-stop");
assert_eq!(
error.diagnostic(),
ForegroundRestorationDiagnostic::tcsetpgrp_unconfirmed(
path_nix_errno::Errno::ENOTTY as i32
)
);
let get_attempts = path_std_cell::Cell::new(0);
restore_foreground_process_group_with(
target,
|| Err(path_nix_errno::Errno::EPERM),
|| {
let attempt = get_attempts.get();
get_attempts.set(attempt + 1);
if attempt == 0 {
Err(path_nix_errno::Errno::EINTR)
} else {
Ok(target)
}
},
)
.expect("Tau already in the foreground confirms ownership");
assert_eq!(get_attempts.get(), 2);
let set_attempts = path_std_cell::Cell::new(0);
let error = restore_foreground_process_group_with(
target,
|| {
set_attempts.set(set_attempts.get() + 1);
Err(path_nix_errno::Errno::EAGAIN)
},
|| Ok(Pid::from_raw(4200)),
)
.expect_err("a different foreground group must retain fail-stop");
assert_eq!(set_attempts.get(), 1, "non-EINTR must not be retried");
assert_eq!(
error.diagnostic(),
ForegroundRestorationDiagnostic::tcsetpgrp_unconfirmed(
path_nix_errno::Errno::EAGAIN as i32
)
);
}
#[cfg(target_os = "linux")]
#[test]
fn pty_job_control_restores_external_foreground_owner() {
const CHILD_ENV: &str = "TAU_PTY_FOREGROUND_RESTORE_CHILD";
if std::env::var_os(CHILD_ENV).is_some() {
pty_job_control_restore_child();
return;
}
use std::os::unix::process::CommandExt as _;
let pty = nix::pty::openpty(None, None).expect("open private pty");
let slave = path_std_fs::File::from(pty.slave);
let mut child =
path_std_process::Command::new(std::env::current_exe().expect("current test executable"));
let test_name = format!(
"{}::pty_job_control_restores_external_foreground_owner",
module_path!()
.strip_prefix("tau_cli_term::")
.unwrap_or(module_path!())
);
child
.args(["--exact", &test_name, "--nocapture"])
.env(CHILD_ENV, "1")
.stdin(path_std_process::Stdio::from(
slave.try_clone().expect("clone pty slave"),
))
.stdout(path_std_process::Stdio::null())
.stderr(path_std_process::Stdio::piped());
#[allow(unsafe_code)]
unsafe {
child.pre_exec(|| {
if nix::libc::setsid() == -1 {
return Err(path_std_io::Error::last_os_error());
}
if nix::libc::ioctl(0, nix::libc::TIOCSCTTY, 0) == -1 {
return Err(path_std_io::Error::last_os_error());
}
Ok(())
});
}
let mut child = child.spawn().expect("spawn PTY fixture");
let mut child_stderr = child.stderr.take().expect("PTY fixture stderr");
let deadline = path_std_time::Instant::now() + path_std_time::Duration::from_secs(10);
loop {
if let Some(status) = child.try_wait().expect("poll PTY fixture") {
let mut stderr = String::new();
child_stderr
.read_to_string(&mut stderr)
.expect("read PTY fixture stderr");
assert!(
status.success(),
"PTY fixture failed with {status}: {stderr}"
);
break;
}
if deadline <= path_std_time::Instant::now() {
let _ = child.kill();
let _ = child.wait();
panic!("PTY foreground restoration fixture timed out");
}
std::thread::sleep(path_std_time::Duration::from_millis(10));
}
}
#[cfg(target_os = "linux")]
fn pty_job_control_restore_child() {
let tau_pgid = nix::unistd::getpgrp();
assert_eq!(
nix::unistd::tcgetpgrp(std::io::stdin().as_fd()).expect("initial foreground group"),
tau_pgid
);
let mut external = path_std_process::Command::new("sh");
external
.arg("-c")
.arg("read -r _")
.stdin(path_std_process::Stdio::piped());
configure_process_group(&mut external).expect("configure external process group");
let mut external = external.spawn().expect("spawn external foreground child");
let release_external = external.stdin.take().expect("external child stdin");
let external_pgid = Pid::from_raw(external.id() as i32);
let mut handle =
ProcessGroupHandle::claim_foreground(external.id()).expect("hand PTY to external child");
assert_eq!(
nix::unistd::tcgetpgrp(std::io::stdin().as_fd()).expect("external foreground group"),
external_pgid
);
drop(release_external);
let _ = external.wait().expect("wait external child");
handle
.restore_foreground()
.expect("restore Tau foreground group");
assert_eq!(
nix::unistd::tcgetpgrp(std::io::stdin().as_fd()).expect("restored foreground group"),
tau_pgid
);
restore_foreground_process_group_with(
tau_pgid,
|| Err(path_nix_errno::Errno::EPERM),
|| nix::unistd::tcgetpgrp(std::io::stdin().as_fd()),
)
.expect("already-restored Tau foreground group is confirmed");
}
#[cfg(unix)]
#[test]
fn restoration_failure_matrix_preserves_primary_outcomes() {
use std::os::unix::process::ExitStatusExt as _;
let rows = [
(
Ok(BoundedCommandStatus {
status: path_std_process::ExitStatus::from_raw(0),
}),
"exit status: 0",
),
(
Ok(BoundedCommandStatus {
status: path_std_process::ExitStatus::from_raw(7 << 8),
}),
"exit status: 7",
),
(
Err("could not wait for command: injected waiter failure".to_owned()),
"injected waiter failure",
),
];
for (primary, expected_primary) in rows {
let restore_attempts = path_std_cell::Cell::new(0);
let error = settle_after_child_with_restore(
primary,
|output| format!("command exited with {}", output.status),
|| {
restore_attempts.set(restore_attempts.get() + 1);
Err(ForegroundRestorationError::tcsetpgrp_unconfirmed(
path_nix_errno::Errno::EIO,
))
},
)
.expect_err("persistent restoration failure must fail stop");
let BoundedCommandError::ForegroundOwnershipUnconfirmed {
primary,
restoration,
} = error
else {
panic!("wrong failure classification");
};
assert!(
primary.contains(expected_primary),
"primary was {primary:?}"
);
assert_eq!(
restoration.diagnostic(),
ForegroundRestorationDiagnostic::tcsetpgrp_unconfirmed(
path_nix_errno::Errno::EIO as i32
)
);
assert_eq!(restore_attempts.get(), 1);
}
}
#[cfg(target_os = "linux")]
#[test]
fn inherited_timeout_restoration_failure_kills_child_group() {
let _guard = FOREGROUND_CLAIM_TEST_LOCK
.lock()
.expect("foreground restore test lock");
let dir = tempfile::tempdir().expect("pid directory");
let pid_path = dir.path().join("descendant.pid");
let pending_pid_path = dir.path().join("descendant.pid.pending");
let script = format!(
"sleep 30 & printf '%s\n' $! > {} && mv {} {}; wait",
pending_pid_path.display(),
pending_pid_path.display(),
pid_path.display()
);
let mut command = path_std_process::Command::new("sh");
command
.args(["-c", &script])
.stdin(path_std_process::Stdio::null())
.stdout(path_std_process::Stdio::null())
.stderr(path_std_process::Stdio::null());
FOREGROUND_RESTORE_ATTEMPTS.store(0, Ordering::SeqCst);
FAIL_FOREGROUND_RESTORE.store(true, Ordering::SeqCst);
let cleanup_started = path_std_cell::Cell::new(None);
let error = run_with_inherited_stdio_after_spawn(
&mut command,
path_std_time::Duration::from_millis(100),
ProcessOwnership::ForegroundProcessGroup,
|| {
let deadline = path_std_time::Instant::now() + path_std_time::Duration::from_secs(2);
while !pid_path.exists() {
if deadline <= path_std_time::Instant::now() {
return Err("descendant pid was not published".to_owned());
}
std::thread::yield_now();
}
cleanup_started.set(Some(path_std_time::Instant::now()));
Ok(())
},
)
.expect_err("timeout plus restoration failure must fail stop");
FAIL_FOREGROUND_RESTORE.store(false, Ordering::SeqCst);
let BoundedCommandError::ForegroundOwnershipUnconfirmed {
primary,
restoration,
} = error
else {
panic!("wrong failure classification");
};
assert!(primary.contains("timeout"), "primary was {primary:?}");
assert_eq!(
restoration.diagnostic(),
ForegroundRestorationDiagnostic::tcsetpgrp_unconfirmed(path_nix_errno::Errno::EIO as i32)
);
assert_eq!(
FOREGROUND_RESTORE_ATTEMPTS.load(Ordering::SeqCst),
2,
"checked restore plus Drop fallback"
);
assert!(
cleanup_started
.get()
.expect("cleanup phase started")
.elapsed()
< path_std_time::Duration::from_secs(2)
);
let pid = std::fs::read_to_string(&pid_path)
.expect("descendant pid")
.trim()
.parse::<i32>()
.expect("numeric descendant pid");
let cleanup_deadline = path_std_time::Instant::now() + path_std_time::Duration::from_secs(1);
let last_state = loop {
let process_state = std::fs::read_to_string(format!("/proc/{pid}/stat"))
.ok()
.and_then(|stat| stat.rsplit_once(") ").map(|(_, tail)| tail.to_owned()))
.and_then(|tail| tail.chars().next());
if process_state.is_none_or(|state| state == 'Z') {
break process_state;
}
if cleanup_deadline <= path_std_time::Instant::now() {
break process_state;
}
std::thread::yield_now();
};
assert!(
last_state.is_none_or(|state| state == 'Z'),
"descendant process {pid} remains runnable in state {last_state:?}"
);
}
#[cfg(unix)]
#[test]
fn foreground_restore_failure_is_reported() {
let _guard = FOREGROUND_CLAIM_TEST_LOCK
.lock()
.expect("foreground restore test lock");
let mut handle = ProcessGroupHandle {
child_pgid: None,
parent_pgid: Some(nix::unistd::getpgrp()),
};
FAIL_FOREGROUND_RESTORE.store(true, Ordering::SeqCst);
let error = handle
.restore_foreground()
.expect_err("injected restore failure");
FAIL_FOREGROUND_RESTORE.store(false, Ordering::SeqCst);
assert_eq!(
error.diagnostic(),
ForegroundRestorationDiagnostic::tcsetpgrp_unconfirmed(path_nix_errno::Errno::EIO as i32)
);
assert!(handle.parent_pgid.is_some(), "Drop fallback remains armed");
}
#[cfg(unix)]
#[test]
fn bounded_command_propagates_foreground_restore_failure() {
let _guard = FOREGROUND_CLAIM_TEST_LOCK
.lock()
.expect("foreground restore test lock");
let mut command = path_std_process::Command::new("sh");
command
.args(["-c", "printf done"])
.stdout(path_std_process::Stdio::piped())
.stderr(path_std_process::Stdio::null());
FAIL_FOREGROUND_RESTORE.store(true, Ordering::SeqCst);
let error = run_with_bounded_stdout(
&mut command,
None,
1024,
path_std_time::Duration::from_secs(2),
ProcessOwnership::ForegroundProcessGroup,
)
.expect_err("restore failure must replace otherwise successful output");
FAIL_FOREGROUND_RESTORE.store(false, Ordering::SeqCst);
assert!(error.to_string().contains("tcsetpgrp-unconfirmed"));
}
#[test]
fn bounded_stdout_reader_reports_overflow_without_storing_tail() {
let input = vec![b'x'; crate::PROMPT_COMMAND_OUTPUT_LIMIT_BYTES + 17];
let read = read_to_limit(input.as_slice(), crate::PROMPT_COMMAND_OUTPUT_LIMIT_BYTES)
.expect("in-memory read should succeed");
assert!(read.overflowed);
assert_eq!(read.bytes.len(), crate::PROMPT_COMMAND_OUTPUT_LIMIT_BYTES);
}
#[test]
fn bounded_command_kills_child_on_stdout_overflow() {
let mut command = path_std_process::Command::new("sh");
command
.arg("-c")
.arg("yes overflow")
.stdout(path_std_process::Stdio::piped())
.stderr(path_std_process::Stdio::null());
let start = path_std_time::Instant::now();
let err = run_with_bounded_stdout(
&mut command,
None,
1024,
path_std_time::Duration::from_secs(5),
ProcessOwnership::ProcessGroup,
)
.expect_err("overflow should fail");
assert!(err.to_string().contains("stdout exceeded"));
assert!(start.elapsed() < std::time::Duration::from_secs(2));
}
#[test]
fn bounded_command_drains_stdout_while_writing_stdin() {
let mut command = path_std_process::Command::new("sh");
command
.arg("-c")
.arg("printf '%65536s' x; bytes=$(wc -c); printf '\\n%s' \"$bytes\"")
.stdout(path_std_process::Stdio::piped())
.stderr(path_std_process::Stdio::null());
let stdin = vec![b'y'; 65536];
let output = run_with_bounded_stdout(
&mut command,
Some(&stdin),
200_000,
path_std_time::Duration::from_secs(5),
ProcessOwnership::ProcessGroup,
)
.expect("interleaved stdin/stdout command should finish");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("test output utf-8");
assert!(stdout.ends_with("\n65536"), "stdout was {stdout:?}");
}
#[test]
fn bounded_command_errors_when_stdout_holder_survives_child() {
let dir = tempfile::tempdir().expect("tempdir");
let pid_path = dir.path().join("holder.pid");
let script = format!("sleep 3 & echo $! > {}; printf done", pid_path.display());
let mut command = path_std_process::Command::new("sh");
command
.arg("-c")
.arg(script)
.stdout(path_std_process::Stdio::piped())
.stderr(path_std_process::Stdio::null());
let start = path_std_time::Instant::now();
let err = run_with_bounded_stdout(
&mut command,
None,
1024,
path_std_time::Duration::from_secs(5),
ProcessOwnership::ProcessGroup,
)
.expect_err("inherited stdout holder should fail promptly");
assert!(err.to_string().contains("stdout pipe did not close"));
assert!(start.elapsed() < std::time::Duration::from_secs(2));
let pid: i32 = std::fs::read_to_string(&pid_path)
.expect("pid file")
.trim()
.parse()
.expect("pid");
wait_for_descendant_termination(pid)
.unwrap_or_else(|error| panic!("stdout holder cleanup failed: {error}"));
}
#[test]
fn bounded_command_times_out_quiet_hung_child() {
let mut command = path_std_process::Command::new("sh");
command
.arg("-c")
.arg("sleep 5")
.stdout(path_std_process::Stdio::piped())
.stderr(path_std_process::Stdio::null());
let start = path_std_time::Instant::now();
let err = run_with_bounded_stdout(
&mut command,
None,
1024,
path_std_time::Duration::from_millis(100),
ProcessOwnership::ProcessGroup,
)
.expect_err("quiet hung child should time out");
assert!(err.to_string().contains("timeout"));
assert!(start.elapsed() < std::time::Duration::from_secs(2));
}
#[cfg(unix)]
#[test]
fn process_group_timeout_kills_descendant() {
let _foreground_claim_guard = FOREGROUND_CLAIM_TEST_LOCK
.lock()
.expect("foreground claim test lock");
let dir = tempfile::tempdir().expect("tempdir");
let pid_path = dir.path().join("child.pid");
let pending_pid_path = dir.path().join("child.pid.pending");
let script = format!(
"sleep 5 & printf '%s\n' $! > {} && mv {} {}; sleep 5",
pending_pid_path.display(),
pending_pid_path.display(),
pid_path.display()
);
let mut command = path_std_process::Command::new("sh");
command
.arg("-c")
.arg(script)
.stdout(path_std_process::Stdio::piped())
.stderr(path_std_process::Stdio::null());
let err = run_with_bounded_stdout_after_spawn(
&mut command,
None,
1024,
path_std_time::Duration::from_millis(100),
ProcessOwnership::ProcessGroup,
|| {
let deadline = path_std_time::Instant::now() + path_std_time::Duration::from_secs(2);
while !pid_path.exists() {
if deadline <= path_std_time::Instant::now() {
return Err("descendant PID was not published after command spawn".to_owned());
}
std::thread::yield_now();
}
Ok(())
},
)
.expect_err("process group should time out");
assert!(err.to_string().contains("timeout"));
let pid: i32 = std::fs::read_to_string(&pid_path)
.expect("pid file")
.trim()
.parse()
.expect("pid");
wait_for_descendant_termination(pid)
.unwrap_or_else(|error| panic!("descendant cleanup failed: {error}"));
}
#[cfg(unix)]
#[test]
fn process_group_setup_failure_kills_spawned_child() {
let _foreground_claim_guard = FOREGROUND_CLAIM_TEST_LOCK
.lock()
.expect("foreground claim test lock");
LAST_FAILED_FOREGROUND_CHILD_ID.store(0, Ordering::SeqCst);
let mut command = path_std_process::Command::new("sh");
command
.arg("-c")
.arg("sleep 5")
.stdout(path_std_process::Stdio::piped())
.stderr(path_std_process::Stdio::null());
FAIL_NEXT_FOREGROUND_CLAIM.store(true, Ordering::SeqCst);
let error = run_with_bounded_stdout(
&mut command,
None,
1024,
path_std_time::Duration::from_secs(5),
ProcessOwnership::ForegroundProcessGroup,
)
.expect_err("foreground handoff should fail");
assert!(error.to_string().contains("could not hand terminal"));
let pid = LAST_FAILED_FOREGROUND_CHILD_ID.load(Ordering::SeqCst);
assert_ne!(pid, 0, "test seam did not record spawned child pid");
std::thread::sleep(path_std_time::Duration::from_millis(200));
let alive = path_std_process::Command::new("kill")
.arg("-0")
.arg(pid.to_string())
.stdin(path_std_process::Stdio::null())
.stdout(path_std_process::Stdio::null())
.stderr(path_std_process::Stdio::null())
.status()
.is_ok_and(|status| status.success());
assert!(!alive, "spawned child {pid} should have been killed");
}
#[test]
fn inherited_stdio_command_times_out_quiet_hung_child() {
let mut command = path_std_process::Command::new("sh");
command
.arg("-c")
.arg("sleep 5")
.stdin(path_std_process::Stdio::null())
.stdout(path_std_process::Stdio::null())
.stderr(path_std_process::Stdio::null());
let start = path_std_time::Instant::now();
let err = run_with_inherited_stdio(
&mut command,
path_std_time::Duration::from_millis(100),
ProcessOwnership::ProcessGroup,
)
.expect_err("quiet hung child should time out");
assert!(err.to_string().contains("timeout"));
assert!(start.elapsed() < std::time::Duration::from_secs(2));
}
#[test]
fn inherited_stdio_command_returns_child_status() {
let mut command = path_std_process::Command::new("sh");
command
.arg("-c")
.arg("exit 7")
.stdin(path_std_process::Stdio::null())
.stdout(path_std_process::Stdio::null())
.stderr(path_std_process::Stdio::null());
let output = run_with_inherited_stdio(
&mut command,
path_std_time::Duration::from_secs(5),
ProcessOwnership::ProcessGroup,
)
.expect("short inherited-stdio command should finish");
assert_eq!(output.status.code(), Some(7));
}