use std::collections::HashSet;
use std::ffi::OsStr;
use std::fs::File;
use std::io::{self, Read};
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use std::os::unix::process::{CommandExt, ExitStatusExt};
use std::path::Path;
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use crate::entity::{CaptureElision, StepOutcome, StepResult};
const CAPTURE_HEAD_LINES: usize = 200;
const CAPTURE_TAIL_LINES: usize = 200;
const PTY_WIDTH: u16 = 120;
const CANCEL_GRACE: Duration = Duration::from_millis(350);
pub(crate) struct RunControl {
cancelled: AtomicBool,
live_groups: Mutex<HashSet<libc::pid_t>>,
}
impl RunControl {
pub(crate) fn new() -> Arc<Self> {
Arc::new(Self {
cancelled: AtomicBool::new(false),
live_groups: Mutex::new(HashSet::new()),
})
}
pub(crate) fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::Acquire)
}
fn register(self: &Arc<Self>, pgid: libc::pid_t) {
self.live_groups.lock().unwrap().insert(pgid);
if self.cancelled.load(Ordering::Acquire) {
self.escalate(vec![pgid]);
}
}
fn deregister(&self, pgid: libc::pid_t) {
self.live_groups.lock().unwrap().remove(&pgid);
}
fn live_snapshot(&self) -> Vec<libc::pid_t> {
self.live_groups.lock().unwrap().iter().copied().collect()
}
pub(crate) fn hold(&self) {
for pgid in self.live_snapshot() {
signal_group(pgid, libc::SIGSTOP);
}
}
pub(crate) fn continue_run(&self) {
for pgid in self.live_snapshot() {
signal_group(pgid, libc::SIGCONT);
}
}
pub(crate) fn cancel(self: &Arc<Self>) {
self.cancelled.store(true, Ordering::Release);
let groups = self.live_snapshot();
self.escalate(groups);
}
fn escalate(self: &Arc<Self>, groups: Vec<libc::pid_t>) {
for &pgid in &groups {
signal_group(pgid, libc::SIGTERM);
}
let control = Arc::clone(self);
thread::spawn(move || {
thread::sleep(CANCEL_GRACE);
let live = control.live_groups.lock().unwrap();
let still_live: Vec<libc::pid_t> = groups
.into_iter()
.filter(|pgid| live.contains(pgid))
.collect();
drop(live);
for pgid in still_live {
signal_group(pgid, libc::SIGKILL);
}
});
}
}
fn signal_group(pgid: libc::pid_t, signal: libc::c_int) {
unsafe {
libc::kill(-pgid, signal);
}
}
pub(crate) fn run_step(
argv: &[String],
shell: bool,
interactive: bool,
cwd: &Path,
env: &[(String, Option<String>)],
control: &Arc<RunControl>,
) -> StepResult {
let label: Arc<str> = Arc::from(argv.join(" "));
let start = Instant::now();
let (master, slave) = match open_pty(PTY_WIDTH) {
Ok(fds) => fds,
Err(failure) => {
return step_failure(
label,
shell,
interactive,
cwd,
failure.code(),
failure.detail(),
start.elapsed(),
);
}
};
let slave_dup = match duplicate_cloexec(&slave) {
Ok(fd) => fd,
Err(error) => {
return spawn_failure(label, shell, interactive, cwd, &error, start.elapsed());
}
};
let keepalive = match duplicate_cloexec(&slave) {
Ok(fd) => fd,
Err(error) => {
return spawn_failure(label, shell, interactive, cwd, &error, start.elapsed());
}
};
let resolved_argv = if shell {
shell_argv(argv, interactive)
} else {
argv.to_vec()
};
let mut command = build_command(&resolved_argv, cwd, env, slave, slave_dup);
let child = match command.spawn() {
Ok(child) => child,
Err(error) => {
return spawn_failure(label, shell, interactive, cwd, &error, start.elapsed());
}
};
drop(command);
let pgid = child.id() as libc::pid_t;
control.register(pgid);
let (raw, status) = drain_until_exit(master, keepalive, child);
control.deregister(pgid);
let outcome = match status {
Some(status) if status.success() => StepOutcome::Ok,
Some(status) => StepOutcome::Failed(exit_code(&status)),
None => StepOutcome::Failed(-1),
};
let (output, elision) = bound_head_and_tail(&normalize_carriage_returns(&raw));
StepResult {
label,
outcome,
output: Arc::from(output),
elapsed: start.elapsed(),
elision,
shell,
interactive,
}
}
const OPEN_PTY_MAX_ATTEMPTS: u32 = 5;
const OPEN_PTY_RETRY_DELAY: Duration = Duration::from_millis(2);
#[derive(Debug)]
enum PtyOpenFailure {
TableExhausted(io::Error),
RaceUnresolved(io::Error),
Other(io::Error),
}
impl PtyOpenFailure {
fn code(&self) -> i32 {
match self {
PtyOpenFailure::TableExhausted(error) | PtyOpenFailure::RaceUnresolved(error) => error
.raw_os_error()
.expect("constructed via io::Error::from_raw_os_error"),
PtyOpenFailure::Other(error) => error.raw_os_error().unwrap_or(-1),
}
}
fn detail(&self) -> String {
match self {
PtyOpenFailure::TableExhausted(error) => {
format!("this machine's pty table appears to be full: {error}")
}
PtyOpenFailure::RaceUnresolved(error) => format!(
"openpty hit a transient allocation race and had not cleared after {OPEN_PTY_MAX_ATTEMPTS} attempts: {error}"
),
PtyOpenFailure::Other(error) => error.to_string(),
}
}
}
fn classify_pty_open_failure(raw: i32) -> PtyOpenFailure {
let normalized = io::Error::from_raw_os_error(raw.unsigned_abs() as i32);
if raw.is_negative() {
PtyOpenFailure::RaceUnresolved(normalized)
} else {
PtyOpenFailure::TableExhausted(normalized)
}
}
fn open_pty(width: u16) -> Result<(OwnedFd, OwnedFd), PtyOpenFailure> {
retrying_enxio(|| open_pty_once(width))
}
fn retrying_enxio<T>(mut attempt: impl FnMut() -> io::Result<T>) -> Result<T, PtyOpenFailure> {
let mut last_enxio = None;
for number in 1..=OPEN_PTY_MAX_ATTEMPTS {
match attempt() {
Ok(value) => return Ok(value),
Err(error) => {
if error.raw_os_error().map(i32::abs) != Some(libc::ENXIO) {
return Err(PtyOpenFailure::Other(error));
}
last_enxio = error.raw_os_error();
if number < OPEN_PTY_MAX_ATTEMPTS {
thread::sleep(OPEN_PTY_RETRY_DELAY);
}
}
}
}
let raw = last_enxio.expect("the loop above only falls through after recording an ENXIO");
Err(classify_pty_open_failure(raw))
}
fn open_pty_once(width: u16) -> io::Result<(OwnedFd, OwnedFd)> {
let winsize = libc::winsize {
ws_row: 40,
ws_col: width,
ws_xpixel: 0,
ws_ypixel: 0,
};
let mut master: libc::c_int = -1;
let mut slave: libc::c_int = -1;
let result = unsafe {
libc::openpty(
&mut master,
&mut slave,
std::ptr::null_mut(),
std::ptr::null_mut(),
&winsize as *const libc::winsize as *mut libc::winsize,
)
};
if result != 0 {
return Err(io::Error::last_os_error());
}
let (master, slave) = unsafe { (OwnedFd::from_raw_fd(master), OwnedFd::from_raw_fd(slave)) };
set_cloexec_or_fail(&master)?;
set_cloexec_or_fail(&slave)?;
Ok((master, slave))
}
fn set_cloexec_or_fail(fd: &OwnedFd) -> io::Result<()> {
let result = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) };
if result == -1 {
return Err(io::Error::last_os_error());
}
Ok(())
}
fn shell_argv(argv: &[String], interactive: bool) -> Vec<String> {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
let flag = if interactive { "-ic" } else { "-c" };
vec![shell, flag.to_string(), argv.join(" "), "repon".to_string()]
}
fn duplicate_cloexec(fd: &OwnedFd) -> io::Result<OwnedFd> {
let duplicated = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) };
if duplicated < 0 {
return Err(io::Error::last_os_error());
}
Ok(unsafe { OwnedFd::from_raw_fd(duplicated) })
}
fn build_command(
argv: &[String],
cwd: &Path,
env: &[(String, Option<String>)],
stdout: OwnedFd,
stderr: OwnedFd,
) -> Command {
let mut command = Command::new(&argv[0]);
command.args(argv[1..].iter().map(OsStr::new));
command.current_dir(cwd);
for (name, value) in env {
match value {
Some(value) => {
command.env(name, value);
}
None => {
command.env_remove(name);
}
}
}
command.stdin(Stdio::null());
command.stdout(Stdio::from(stdout));
command.stderr(Stdio::from(stderr));
unsafe {
command.pre_exec(|| {
if libc::setsid() == -1 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
command
}
fn drain_until_exit(
master: OwnedFd,
keepalive: OwnedFd,
mut child: Child,
) -> (Vec<u8>, Option<ExitStatus>) {
let Ok((notify_read, notify_write)) = self_pipe() else {
let raw = read_to_end_best_effort(master);
drop(keepalive);
return (raw, child.wait().ok());
};
let waiter = thread::spawn(move || {
let status = child.wait();
drop(notify_write);
status
});
let raw = drain_with_poll(&master, ¬ify_read);
drop(keepalive);
let status = waiter.join().ok().and_then(Result::ok);
(raw, status)
}
fn read_to_end_best_effort(master: OwnedFd) -> Vec<u8> {
let mut file = File::from(master);
let mut raw = Vec::new();
let _ = file.read_to_end(&mut raw);
raw
}
fn self_pipe() -> io::Result<(OwnedFd, OwnedFd)> {
let mut fds: [libc::c_int; 2] = [-1, -1];
let result = unsafe { libc::pipe(fds.as_mut_ptr()) };
if result != 0 {
return Err(io::Error::last_os_error());
}
let (read_end, write_end) =
unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) };
set_cloexec(&read_end);
set_cloexec(&write_end);
Ok((read_end, write_end))
}
fn set_cloexec(fd: &OwnedFd) {
unsafe {
libc::fcntl(fd.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC);
}
}
fn set_nonblocking(fd: &OwnedFd) {
let flags = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_GETFL) };
if flags >= 0 {
unsafe {
libc::fcntl(fd.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK);
}
}
}
fn drain_with_poll(master: &OwnedFd, notify_read: &OwnedFd) -> Vec<u8> {
let mut raw = Vec::new();
let mut buf = [0u8; 8192];
loop {
let mut fds = [
libc::pollfd {
fd: master.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
},
libc::pollfd {
fd: notify_read.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
},
];
let ready = unsafe { libc::poll(fds.as_mut_ptr(), fds.len() as libc::nfds_t, -1) };
if ready < 0 {
if io::Error::last_os_error().kind() == io::ErrorKind::Interrupted {
continue;
}
break;
}
if fds[0].revents & (libc::POLLIN | libc::POLLHUP) != 0 {
read_blocking(master, &mut buf, &mut raw);
}
if fds[1].revents != 0 {
break;
}
}
set_nonblocking(master);
loop {
match nonblocking_read(master, &mut buf) {
Some(n) if n > 0 => raw.extend_from_slice(&buf[..n]),
_ => break,
}
}
raw
}
fn read_blocking(fd: &OwnedFd, buf: &mut [u8], raw: &mut Vec<u8>) {
loop {
let n = unsafe {
libc::read(
fd.as_raw_fd(),
buf.as_mut_ptr() as *mut libc::c_void,
buf.len(),
)
};
if n < 0 {
if io::Error::last_os_error().kind() == io::ErrorKind::Interrupted {
continue;
}
return;
}
if n > 0 {
raw.extend_from_slice(&buf[..n as usize]);
}
return;
}
}
fn nonblocking_read(fd: &OwnedFd, buf: &mut [u8]) -> Option<usize> {
loop {
let n = unsafe {
libc::read(
fd.as_raw_fd(),
buf.as_mut_ptr() as *mut libc::c_void,
buf.len(),
)
};
if n < 0 {
let error = io::Error::last_os_error();
if error.kind() == io::ErrorKind::Interrupted {
continue;
}
return None;
}
return Some(n as usize);
}
}
fn spawn_failure(
label: Arc<str>,
shell: bool,
interactive: bool,
cwd: &Path,
error: &io::Error,
elapsed: Duration,
) -> StepResult {
step_failure(
label,
shell,
interactive,
cwd,
error.raw_os_error().unwrap_or(-1),
error,
elapsed,
)
}
fn step_failure(
label: Arc<str>,
shell: bool,
interactive: bool,
cwd: &Path,
code: i32,
detail: impl std::fmt::Display,
elapsed: Duration,
) -> StepResult {
let output = if std::fs::metadata(cwd).is_err() {
format!(
"repon: could not run this step because its working directory no longer exists: {}\n",
cwd.display()
)
} else {
format!("repon: could not start `{label}`: {detail}\n")
};
StepResult {
label,
outcome: StepOutcome::Failed(code),
output: Arc::from(output.into_bytes()),
elapsed,
elision: None,
shell,
interactive,
}
}
fn exit_code(status: &std::process::ExitStatus) -> i32 {
status
.code()
.or_else(|| status.signal().map(|signal| 128 + signal))
.unwrap_or(-1)
}
fn normalize_carriage_returns(raw: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(raw.len());
let mut frame_start = 0;
let mut index = 0;
while index < raw.len() {
match raw[index] {
b'\r' if raw.get(index + 1) == Some(&b'\n') => {
out.push(b'\n');
frame_start = out.len();
index += 2;
}
b'\r' => {
out.truncate(frame_start);
index += 1;
}
b'\n' => {
out.push(b'\n');
frame_start = out.len();
index += 1;
}
0x1b if raw.get(index + 1) == Some(&b'[') => match parse_csi(&raw[index..]) {
Some(csi) if csi.resets_the_frame() => {
out.truncate(frame_start);
index += csi.len;
}
Some(csi) => {
out.extend_from_slice(&raw[index..index + csi.len]);
index += csi.len;
}
None => {
out.push(raw[index]);
index += 1;
}
},
other => {
out.push(other);
index += 1;
}
}
}
out
}
struct Csi<'a> {
len: usize,
params: &'a [u8],
final_byte: u8,
}
impl Csi<'_> {
fn resets_the_frame(&self) -> bool {
match self.final_byte {
b'K' => true,
b'G' => matches!(self.params, b"" | b"1"),
_ => false,
}
}
}
fn parse_csi(raw: &[u8]) -> Option<Csi<'_>> {
let mut index = 2; while raw
.get(index)
.is_some_and(|byte| (0x30..=0x3f).contains(byte))
{
index += 1;
}
let params_end = index;
while raw
.get(index)
.is_some_and(|byte| (0x20..=0x2f).contains(byte))
{
index += 1;
}
let final_byte = *raw.get(index)?;
if !(0x40..=0x7e).contains(&final_byte) {
return None;
}
Some(Csi {
len: index + 1,
params: &raw[2..params_end],
final_byte,
})
}
fn split_into_lines(normalized: &[u8]) -> Vec<&[u8]> {
let mut lines = Vec::new();
let mut start = 0;
for (index, &byte) in normalized.iter().enumerate() {
if byte == b'\n' {
lines.push(&normalized[start..=index]);
start = index + 1;
}
}
if start < normalized.len() {
lines.push(&normalized[start..]);
}
lines
}
fn bound_head_and_tail(normalized: &[u8]) -> (Vec<u8>, Option<CaptureElision>) {
let lines = split_into_lines(normalized);
let bound = CAPTURE_HEAD_LINES + CAPTURE_TAIL_LINES;
if lines.len() <= bound {
return (normalized.to_vec(), None);
}
let dropped = lines.len() - bound;
let mut out = Vec::new();
for line in &lines[..CAPTURE_HEAD_LINES] {
out.extend_from_slice(line);
}
for line in &lines[lines.len() - CAPTURE_TAIL_LINES..] {
out.extend_from_slice(line);
}
(
out,
Some(CaptureElision {
dropped_lines: dropped,
kept_head_lines: CAPTURE_HEAD_LINES,
}),
)
}
#[cfg(test)]
mod tests {
use std::sync::mpsc;
use std::thread;
use super::*;
use crate::liveness::{BACKSTOP, FIXTURE_LIFETIME, wait_for};
fn run(argv: &[&str], cwd: &Path) -> StepResult {
let argv: Vec<String> = argv.iter().map(|s| s.to_string()).collect();
run_step(&argv, false, false, cwd, &[], &RunControl::new())
}
fn run_shell(command: &str, cwd: &Path) -> StepResult {
run_step(
&[command.to_string()],
true,
false,
cwd,
&[],
&RunControl::new(),
)
}
fn run_interactive_shell(command: &str, cwd: &Path) -> StepResult {
run_step(
&[command.to_string()],
true,
true,
cwd,
&[],
&RunControl::new(),
)
}
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create a temp dir")
}
#[test]
fn stdin_is_the_null_device_so_a_child_that_reads_it_terminates_rather_than_hanging() {
let dir = tempdir();
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let result = run(&["cat"], dir.path());
let _ = tx.send(result);
});
let result = rx
.recv_timeout(BACKSTOP)
.expect("a child reading a null stdin must terminate rather than hang");
assert_eq!(result.outcome, StepOutcome::Ok);
}
#[test]
fn stdin_is_wired_to_the_null_device_literally_not_merely_inherited() {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let whole = std::fs::read_to_string(manifest_dir.join("src/executor.rs"))
.expect("read this module's own source");
let production = whole
.split("#[cfg(test)]\nmod tests {")
.next()
.expect("this module has a #[cfg(test)] mod tests block");
let needle = format!("command.stdin(Stdio::{}());", "null");
assert!(
production.contains(&needle),
"expected the child's own stdin to be wired to Stdio::null() unconditionally"
);
}
#[test]
fn a_shell_true_step_receives_repon_literally_as_its_own_dollar_zero() {
let dir = tempdir();
let result = run_shell("echo \"[$0]\"", dir.path());
assert_eq!(result.outcome, StepOutcome::Ok);
assert_eq!(&*result.output, b"[repon]\n");
}
#[test]
fn step_result_shell_matches_the_mode_run_step_was_called_with() {
let dir = tempdir();
assert!(run_shell("true", dir.path()).shell);
assert!(!run(&["true"], dir.path()).shell);
}
#[test]
fn step_result_shell_matches_the_mode_even_on_a_step_that_never_reached_exec() {
let missing_dir = tempdir();
let missing_dir_path = missing_dir.path().join("gone");
let shell_result = run_shell("true", &missing_dir_path);
let argv_result = run(&["true"], &missing_dir_path);
assert!(
shell_result.outcome.is_failure(),
"a gone cwd must fail the step"
);
assert!(shell_result.shell);
assert!(
argv_result.outcome.is_failure(),
"a gone cwd must fail the step"
);
assert!(!argv_result.shell);
}
#[test]
fn interactive_true_builds_argv_with_the_ic_flag_instead_of_c() {
let argv = vec!["echo hi".to_string()];
let non_interactive = shell_argv(&argv, false);
let interactive = shell_argv(&argv, true);
assert_eq!(non_interactive[1], "-c");
assert_eq!(interactive[1], "-ic");
assert_eq!(non_interactive[0], interactive[0]);
assert_eq!(non_interactive[2..], interactive[2..]);
}
#[test]
fn step_result_interactive_matches_the_mode_run_step_was_called_with() {
let dir = tempdir();
assert!(run_interactive_shell("true", dir.path()).interactive);
assert!(!run_shell("true", dir.path()).interactive);
assert!(!run(&["true"], dir.path()).interactive);
}
fn alias_rc(
shell: &str,
dir: &Path,
alias_name: &str,
replacement: &str,
) -> Vec<(String, Option<String>)> {
let name = Path::new(shell)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("");
let line = format!("alias {alias_name}='{replacement}'\n");
match name {
"zsh" => {
std::fs::write(dir.join(".zshrc"), line).expect("write .zshrc");
vec![("ZDOTDIR".to_string(), Some(dir.display().to_string()))]
}
"bash" => {
std::fs::write(dir.join(".bashrc"), line).expect("write .bashrc");
vec![("HOME".to_string(), Some(dir.display().to_string()))]
}
_ => {
let rc = dir.join("rc");
std::fs::write(&rc, line).expect("write rc");
vec![("ENV".to_string(), Some(rc.display().to_string()))]
}
}
}
#[test]
fn interactive_true_sources_the_users_rc_file_so_an_alias_resolves() {
let dir = tempdir();
let rc_dir = tempdir();
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
let env = alias_rc(&shell, rc_dir.path(), "gff", "echo alias-resolved");
let argv = vec!["gff".to_string()];
let interactive = run_step(&argv, true, true, dir.path(), &env, &RunControl::new());
let non_interactive = run_step(&argv, true, false, dir.path(), &env, &RunControl::new());
let interactive_output = String::from_utf8_lossy(&interactive.output).into_owned();
assert_eq!(
interactive.outcome,
StepOutcome::Ok,
"an interactive shell must source the rc file and resolve the alias, got: {interactive_output:?}"
);
assert!(
interactive_output.contains("alias-resolved"),
"expected the alias's own output, got: {interactive_output:?}"
);
assert!(
non_interactive.outcome.is_failure(),
"a non-interactive shell must not see an alias defined only in the rc file"
);
}
#[test]
fn a_naive_shell_c_call_with_no_placeholder_shifts_the_next_argument_into_dollar_zero() {
let output = Command::new("sh")
.arg("-c")
.arg("echo \"[$0][$1]\"")
.arg("intended-as-dollar-one")
.output()
.expect("run the naive form directly");
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"[intended-as-dollar-one][]\n",
"sh -c must swallow the intended $1 into $0 with no placeholder in between"
);
}
#[test]
fn the_childs_process_group_equals_its_own_pid_because_setsid_made_it_a_session_leader() {
let dir = tempdir();
let result = run(&["sh", "-c", "echo $$; ps -o pgid= -p $$"], dir.path());
assert_eq!(result.outcome, StepOutcome::Ok);
let output = String::from_utf8(result.output.to_vec()).expect("utf8 output");
let mut lines = output.lines();
let pid: i64 = lines
.next()
.expect("a pid line")
.trim()
.parse()
.expect("pid parses as an integer");
let pgid: i64 = lines
.next()
.expect("a pgid line")
.trim()
.parse()
.expect("pgid parses as an integer");
assert_eq!(
pgid, pid,
"setsid must make the child its own process-group leader"
);
}
fn process_state(pid: libc::pid_t) -> Option<char> {
let output = Command::new("ps")
.args(["-o", "state=", "-p", &pid.to_string()])
.output()
.expect("run ps");
String::from_utf8_lossy(&output.stdout)
.trim()
.chars()
.next()
}
fn spawn_controlled(argv: &[&str], cwd: &Path) -> (OwnedFd, OwnedFd, Child, libc::pid_t) {
let argv: Vec<String> = argv.iter().map(|s| s.to_string()).collect();
let (master, slave) = open_pty(PTY_WIDTH).expect("open a pty");
let slave_dup = duplicate_cloexec(&slave).expect("duplicate the slave for stderr");
let keepalive = duplicate_cloexec(&slave).expect("duplicate the slave for keepalive");
let mut command = build_command(&argv, cwd, &[], slave, slave_dup);
let child = command.spawn().expect("spawn a controlled child");
drop(command);
let pgid = child.id() as libc::pid_t;
(master, keepalive, child, pgid)
}
#[test]
fn a_child_that_traps_sigterm_still_dies_once_cancel_escalates_to_sigkill() {
let dir = tempdir();
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let sleep_past_the_backstop = format!(
"trap '' TERM; echo ready; sleep {}",
FIXTURE_LIFETIME.as_secs()
);
let (master, keepalive, child, pgid) =
spawn_controlled(&["sh", "-c", &sleep_past_the_backstop], dir.path());
let control = RunControl::new();
control.register(pgid);
let mut buf = [0u8; 64];
let mut collected = Vec::new();
while !collected.windows(5).any(|window| window == b"ready") {
read_blocking(&master, &mut buf, &mut collected);
}
control.cancel();
let (_raw, status) = drain_until_exit(master, keepalive, child);
let _ = tx.send(status);
});
let status = rx
.recv_timeout(BACKSTOP)
.expect("a TERM-trapping child must still die once cancel escalates to SIGKILL");
assert!(
status.is_some_and(|status| !status.success()),
"a killed child must not report a clean exit"
);
}
#[test]
fn a_child_registered_after_cancel_already_swept_an_empty_registry_is_still_terminated() {
let dir = tempdir();
let control = RunControl::new();
control.cancel();
let sleep_past_the_backstop = format!("sleep {}", FIXTURE_LIFETIME.as_secs());
let (master, keepalive, child, pgid) =
spawn_controlled(&["sh", "-c", &sleep_past_the_backstop], dir.path());
control.register(pgid);
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let (_raw, status) = drain_until_exit(master, keepalive, child);
let _ = tx.send(status);
});
let status = rx.recv_timeout(BACKSTOP).expect(
"a child registered after cancel already swept an empty registry must still be \
reaped, not outlive the run that cancelled it",
);
assert!(
status.is_some_and(|status| !status.success()),
"a terminated child must not report a clean exit"
);
}
#[test]
fn hold_sigstops_a_live_group_and_continue_run_sigconts_it_back() {
let dir = tempdir();
let (master, keepalive, child, pgid) = spawn_controlled(&["sleep", "2"], dir.path());
let control = RunControl::new();
control.register(pgid);
control.hold();
wait_for(
"SIGSTOP to leave the child's own process state Stopped",
|| process_state(pgid) == Some('T'),
);
control.continue_run();
wait_for(
"SIGCONT to move the child back out of the Stopped state",
|| matches!(process_state(pgid), Some('S') | Some('R')),
);
control.deregister(pgid);
let (_raw, status) = drain_until_exit(master, keepalive, child);
assert!(status.is_some_and(|status| status.success()));
}
#[test]
fn colour_escape_sequences_survive_capture() {
let dir = tempdir();
let result = run(&["sh", "-c", "printf '\\033[31mred\\033[0m'"], dir.path());
assert_eq!(result.outcome, StepOutcome::Ok);
assert!(
result.output.windows(5).any(|window| window == b"\x1b[31m"),
"the raw escape sequence must survive capture, got {:?}",
result.output
);
}
#[test]
fn stdout_and_stderr_interleave_in_true_write_order_on_one_shared_stream() {
let dir = tempdir();
let result = run(
&["sh", "-c", "echo one; echo two 1>&2; echo three"],
dir.path(),
);
assert_eq!(result.outcome, StepOutcome::Ok);
assert_eq!(&*result.output, b"one\ntwo\nthree\n");
}
#[test]
fn a_carriage_return_immediately_before_a_newline_is_dropped_as_a_line_ending() {
let dir = tempdir();
let result = run(&["printf", "a\nb\n"], dir.path());
assert_eq!(result.outcome, StepOutcome::Ok);
assert_eq!(&*result.output, b"a\nb\n");
assert!(!result.output.contains(&b'\r'));
}
#[test]
fn a_bare_carriage_return_separates_progress_frames_and_only_the_last_survives() {
let dir = tempdir();
let result = run(&["printf", "frame1\rframe2\rframe3\n"], dir.path());
assert_eq!(result.outcome, StepOutcome::Ok);
assert_eq!(&*result.output, b"frame3\n");
}
#[test]
fn a_bare_carriage_return_after_a_real_line_leaves_that_line_intact() {
let dir = tempdir();
let result = run(&["printf", "line1\nframe1\rframe2\n"], dir.path());
assert_eq!(result.outcome, StepOutcome::Ok);
assert_eq!(&*result.output, b"line1\nframe2\n");
}
#[test]
fn a_slow_to_start_drain_does_not_lose_output_written_before_it_begins() {
let dir = tempdir();
let argv = vec!["echo".to_string(), "quick output".to_string()];
let (master, slave) = open_pty(PTY_WIDTH).expect("open a pty");
let slave_dup = duplicate_cloexec(&slave).expect("duplicate the slave for stderr");
let keepalive = duplicate_cloexec(&slave).expect("duplicate the slave for keepalive");
let mut command = build_command(&argv, dir.path(), &[], slave, slave_dup);
let child = command.spawn().expect("spawn the child");
drop(command);
thread::sleep(Duration::from_millis(600));
let (raw, status) = drain_until_exit(master, keepalive, child);
assert!(status.is_some_and(|status| status.success()));
assert_eq!(&raw, b"quick output\r\n");
}
#[test]
fn output_larger_than_the_ptys_own_buffer_is_captured_in_full() {
let dir = tempdir();
let byte_count = 300_000;
let result = run(
&[
"sh",
"-c",
&format!("yes a | tr -d '\\n' | head -c {byte_count}; echo"),
],
dir.path(),
);
assert_eq!(result.outcome, StepOutcome::Ok);
assert_eq!(result.output.len(), byte_count + 1);
assert!(result.output[..byte_count].iter().all(|&byte| byte == b'a'));
assert_eq!(result.output[byte_count], b'\n');
}
#[test]
fn a_missing_command_and_a_vanished_working_directory_are_distinguishable_failures() {
let existing_dir = tempdir();
let vanished_dir = tempdir();
let vanished_path = vanished_dir.path().to_path_buf();
drop(vanished_dir);
let missing_command = run(
&["definitely-not-a-real-repon-command"],
existing_dir.path(),
);
let vanished_directory = run(&["true"], &vanished_path);
assert!(matches!(missing_command.outcome, StepOutcome::Failed(_)));
assert!(matches!(vanished_directory.outcome, StepOutcome::Failed(_)));
let missing_command_output = String::from_utf8_lossy(&missing_command.output).to_string();
let vanished_directory_output =
String::from_utf8_lossy(&vanished_directory.output).to_string();
assert_ne!(
missing_command_output, vanished_directory_output,
"a missing command and a vanished directory must read differently"
);
assert!(vanished_directory_output.contains("working directory"));
assert!(!missing_command_output.contains("working directory"));
}
fn fd_is_open(fd: std::os::fd::RawFd) -> bool {
unsafe { libc::fcntl(fd, libc::F_GETFD) != -1 }
}
fn fd_device(fd: std::os::fd::RawFd) -> Option<libc::dev_t> {
let mut stat = std::mem::MaybeUninit::<libc::stat>::uninit();
let ok = unsafe { libc::fstat(fd, stat.as_mut_ptr()) } == 0;
ok.then(|| unsafe { stat.assume_init() }.st_rdev)
}
#[test]
fn a_successful_steps_keepalive_descriptor_closes_once_draining_finishes() {
let dir = tempdir();
let argv = vec!["true".to_string()];
let (master, slave) = open_pty(PTY_WIDTH).expect("open a pty");
let slave_dup = duplicate_cloexec(&slave).expect("duplicate the slave for stderr");
let keepalive = duplicate_cloexec(&slave).expect("duplicate the slave for keepalive");
let keepalive_fd = keepalive.as_raw_fd();
let pty_device = fd_device(keepalive_fd).expect("the keepalive is open before the drain");
let mut command = build_command(&argv, dir.path(), &[], slave, slave_dup);
let child = command.spawn().expect("spawn the child");
drop(command);
let (_raw, status) = drain_until_exit(master, keepalive, child);
assert!(status.is_some_and(|status| status.success()));
assert_ne!(
fd_device(keepalive_fd),
Some(pty_device),
"expected no descriptor onto this pty to survive draining; number \
{keepalive_fd} still refers to it"
);
}
#[test]
fn a_failed_spawns_keepalive_descriptor_closes_with_the_early_return() {
let dir = tempdir();
let argv = vec!["definitely-not-a-real-repon-command".to_string()];
let (_master, slave) = open_pty(PTY_WIDTH).expect("open a pty");
let slave_dup = duplicate_cloexec(&slave).expect("duplicate the slave for stderr");
let keepalive = duplicate_cloexec(&slave).expect("duplicate the slave for keepalive");
let keepalive_fd = keepalive.as_raw_fd();
let mut command = build_command(&argv, dir.path(), &[], slave, slave_dup);
let spawn_result = command.spawn();
assert!(spawn_result.is_err(), "expected this argv to fail to spawn");
drop(command);
drop(keepalive);
assert!(
!fd_is_open(keepalive_fd),
"expected keepalive's descriptor {keepalive_fd} to be closed after a spawn failure"
);
}
#[test]
fn a_steps_own_program_sees_the_pty_on_stdout_and_stderr_and_nowhere_else() {
let dir = tempdir();
let count_extra_pty_references = "\
import os
target = os.fstat(1).st_rdev
def points_at_target(fd):
try:
return os.fstat(fd).st_rdev == target
except OSError:
return False
extra = [
fd for fd in map(int, os.listdir('/dev/fd'))
if fd not in (0, 1, 2) and points_at_target(fd)
]
print(len(extra))";
let result = run(&["python3", "-c", count_extra_pty_references], dir.path());
assert_eq!(
result.outcome,
StepOutcome::Ok,
"the probe step failed; it needs `python3` on PATH. output: {}",
String::from_utf8_lossy(&result.output)
);
let extra_references: usize = String::from_utf8_lossy(&result.output)
.trim()
.parse()
.expect("the probe prints a single integer");
assert_eq!(
extra_references, 0,
"expected no descriptor onto the pty beyond the child's own stdout and \
stderr, got {extra_references} extra references"
);
}
#[test]
fn a_steps_own_program_never_inherits_the_ptys_master_side() {
let dir = tempdir();
let (master, slave) = open_pty(PTY_WIDTH).expect("open a pty");
let master_fd = master.as_raw_fd();
let master_device = fd_device(master_fd).expect("master is open before spawning");
let slave_dup = duplicate_cloexec(&slave).expect("duplicate the slave for stderr");
let keepalive = duplicate_cloexec(&slave).expect("duplicate the slave for keepalive");
let probe = format!(
"\
import os
fd = {master_fd}
expected_device = {master_device}
try:
inherited = os.fstat(fd).st_rdev == expected_device
except OSError:
inherited = False
print(1 if inherited else 0)"
);
let argv = vec!["python3".to_string(), "-c".to_string(), probe];
let mut command = build_command(&argv, dir.path(), &[], slave, slave_dup);
let child = match command.spawn() {
Ok(child) => child,
Err(error) => panic!("expected `python3` on PATH to spawn the probe: {error}"),
};
drop(command);
let (raw, status) = drain_until_exit(master, keepalive, child);
assert!(
status.is_some_and(|status| status.success()),
"the probe step failed; it needs `python3` on PATH. output: {}",
String::from_utf8_lossy(&raw)
);
let inherited = String::from_utf8_lossy(&raw).trim() == "1";
assert!(
!inherited,
"expected the pty's master side not to be inherited by the child, but fd \
{master_fd} was still open there and still pointed at it"
);
}
#[test]
fn elapsed_reflects_real_wall_clock_time_rather_than_a_fixed_value() {
let dir = tempdir();
let result = run(&["sh", "-c", "sleep 0.05"], dir.path());
assert_eq!(result.outcome, StepOutcome::Ok);
assert!(
result.elapsed >= Duration::from_millis(40),
"expected at least the 50ms the child slept, got {:?}",
result.elapsed
);
assert!(
result.elapsed < Duration::from_secs(5),
"expected a short step to report a short elapsed time, got {:?}",
result.elapsed
);
}
#[test]
fn a_zero_exit_is_ok() {
let dir = tempdir();
let result = run(&["true"], dir.path());
assert_eq!(result.outcome, StepOutcome::Ok);
}
#[test]
fn a_nonzero_exit_is_failed_with_its_own_code() {
let dir = tempdir();
let result = run(&["sh", "-c", "exit 7"], dir.path());
assert_eq!(result.outcome, StepOutcome::Failed(7));
}
#[test]
fn the_label_is_the_argv_rendered_for_display() {
let dir = tempdir();
let result = run(&["echo", "hello"], dir.path());
assert_eq!(&*result.label, "echo hello");
}
#[test]
fn env_none_unsets_a_variable_the_child_would_otherwise_inherit() {
unsafe {
std::env::set_var("REPON_EXECUTOR_TEST_VAR", "set-by-the-test-process");
}
let dir = tempdir();
let env = vec![("REPON_EXECUTOR_TEST_VAR".to_string(), None)];
let argv = vec![
"sh".to_string(),
"-c".to_string(),
"echo \"${REPON_EXECUTOR_TEST_VAR:-unset}\"".to_string(),
];
let result = run_step(&argv, false, false, dir.path(), &env, &RunControl::new());
assert_eq!(result.outcome, StepOutcome::Ok);
assert_eq!(&*result.output, b"unset\n");
unsafe {
std::env::remove_var("REPON_EXECUTOR_TEST_VAR");
}
}
fn spec_actions_md() -> String {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
std::fs::read_to_string(manifest_dir.join("../../docs/spec/actions.md"))
.expect("read docs/spec/actions.md")
}
fn spec_capture_head_and_tail_line_counts(spec: &str) -> (usize, usize) {
let anchor = "Capture is bounded to the head ";
let after = spec
.split(anchor)
.nth(1)
.expect("the capture bound sentence is present");
let mut parts = after.splitn(2, " lines plus the tail ");
let head: usize = parts
.next()
.expect("a head line count")
.parse()
.expect("the head line count is an integer");
let after_tail = parts.next().expect("a tail line count and beyond");
let tail: usize = after_tail
.split(" lines")
.next()
.expect("a tail line count")
.parse()
.expect("the tail line count is an integer");
(head, tail)
}
fn spec_pty_width_columns(spec: &str) -> u16 {
let anchor = "The PTY is a fixed ";
let after = spec
.split(anchor)
.nth(1)
.expect("the PTY width sentence is present");
after
.split(" columns")
.next()
.expect("a column count")
.parse()
.expect("the column count is an integer")
}
#[test]
fn capture_bound_constants_match_the_spec_of_record() {
let spec = spec_actions_md();
let (head, tail) = spec_capture_head_and_tail_line_counts(&spec);
assert_eq!(head, CAPTURE_HEAD_LINES);
assert_eq!(tail, CAPTURE_TAIL_LINES);
}
#[test]
fn pty_width_constant_matches_the_spec_of_record() {
let spec = spec_actions_md();
assert_eq!(spec_pty_width_columns(&spec), PTY_WIDTH);
}
fn spec_cancel_grace_ms(spec: &str) -> u64 {
let anchor = "The grace is ";
let after = spec
.split(anchor)
.nth(1)
.expect("the cancellation grace sentence is present");
after
.split("ms,")
.next()
.expect("a millisecond count")
.parse()
.expect("the grace is an integer number of milliseconds")
}
#[test]
fn cancel_grace_constant_matches_the_spec_of_record() {
let spec = spec_actions_md();
assert_eq!(
Duration::from_millis(spec_cancel_grace_ms(&spec)),
CANCEL_GRACE
);
}
#[test]
fn short_output_is_never_bounded_or_elided() {
let input = b"a\nb\nc\n".to_vec();
assert_eq!(bound_head_and_tail(&input), (input, None));
}
#[test]
fn output_of_exactly_the_bound_is_never_reported_as_elided() {
let numbered = |total: usize| {
let mut input = String::new();
for n in 0..total {
input.push_str(&format!("line {n}\n"));
}
input.into_bytes()
};
let bound = CAPTURE_HEAD_LINES + CAPTURE_TAIL_LINES;
let at_bound = numbered(bound);
let (kept, elision) = bound_head_and_tail(&at_bound);
assert_eq!(
elision, None,
"output of exactly the bound lost nothing and must report no elision"
);
assert!(
kept == at_bound,
"output of exactly the bound must be handed back untouched"
);
assert_eq!(
bound_head_and_tail(&numbered(bound - 1)).1,
None,
"one line under the bound must report no elision"
);
assert_eq!(
bound_head_and_tail(&numbered(bound + 1)).1,
Some(CaptureElision {
dropped_lines: 1,
kept_head_lines: CAPTURE_HEAD_LINES,
}),
"one line over the bound must report a drop of exactly that one line"
);
}
#[test]
fn a_bounded_capture_reports_the_drop_as_counts_and_writes_no_mark_into_the_bytes() {
let total = CAPTURE_HEAD_LINES + CAPTURE_TAIL_LINES + 37;
let mut input = String::new();
for n in 0..total {
input.push_str(&format!("line {n}\n"));
}
let (bounded, elision) = bound_head_and_tail(input.as_bytes());
let elision = elision.expect("output past the bound must report its own elision");
let bounded = String::from_utf8(bounded).expect("valid utf8");
let lines: Vec<&str> = bounded.lines().collect();
assert_eq!(elision.dropped_lines, 37);
assert_eq!(elision.kept_head_lines, CAPTURE_HEAD_LINES);
assert_eq!(lines.len(), CAPTURE_HEAD_LINES + CAPTURE_TAIL_LINES);
assert_eq!(lines[0], "line 0");
assert_eq!(
lines[CAPTURE_HEAD_LINES - 1],
format!("line {}", CAPTURE_HEAD_LINES - 1)
);
assert_eq!(
lines[CAPTURE_HEAD_LINES],
format!("line {}", total - CAPTURE_TAIL_LINES),
"the kept tail must follow the kept head directly, with no line between them"
);
assert_eq!(lines[lines.len() - 1], format!("line {}", total - 1));
assert!(
!bounded.contains("elided"),
"the core must report the drop as structure, never as a formatted line: {bounded:?}"
);
}
#[test]
fn a_real_steps_result_carries_the_elision_its_own_capture_bound_computed() {
let dir = tempdir();
let dropped = 61;
let total = CAPTURE_HEAD_LINES + CAPTURE_TAIL_LINES + dropped;
let long = run_shell(
&format!("i=0; while [ $i -lt {total} ]; do echo \"line $i\"; i=$((i+1)); done"),
dir.path(),
);
assert_eq!(long.outcome, StepOutcome::Ok);
assert_eq!(
long.elision,
Some(CaptureElision {
dropped_lines: dropped,
kept_head_lines: CAPTURE_HEAD_LINES,
})
);
let text = String::from_utf8(long.output.to_vec()).expect("valid utf8");
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), CAPTURE_HEAD_LINES + CAPTURE_TAIL_LINES);
assert_eq!(
lines[CAPTURE_HEAD_LINES - 1],
format!("line {}", CAPTURE_HEAD_LINES - 1)
);
assert_eq!(
lines[CAPTURE_HEAD_LINES],
format!("line {}", total - CAPTURE_TAIL_LINES),
"the kept tail must follow the kept head with nothing between them"
);
let short = run_shell("echo one; echo two", dir.path());
assert_eq!(short.outcome, StepOutcome::Ok);
assert_eq!(
short.elision, None,
"output that fitted whole must report no elision"
);
}
#[test]
fn the_elision_cut_never_splits_a_multi_byte_utf8_character() {
let total = CAPTURE_HEAD_LINES + CAPTURE_TAIL_LINES + 5;
let mut input = String::new();
for n in 0..total {
input.push_str(&format!("line {n} \u{4e2d}\u{6587}\n"));
}
let (bounded, elision) = bound_head_and_tail(input.as_bytes());
assert_eq!(elision.expect("an elision past the bound").dropped_lines, 5);
let decoded = String::from_utf8(bounded).expect("bounded output must stay valid UTF-8");
assert!(decoded.contains("\u{4e2d}\u{6587}"));
}
#[test]
fn normalize_carriage_returns_leaves_plain_output_with_no_carriage_returns_untouched() {
assert_eq!(normalize_carriage_returns(b"a\nb\nc"), b"a\nb\nc");
}
#[test]
fn a_csi_erase_in_line_sequence_collapses_the_previous_frame_like_a_bare_carriage_return() {
assert_eq!(
normalize_carriage_returns(b"frame1\x1b[2Kframe2\n"),
b"frame2\n"
);
}
#[test]
fn every_erase_in_line_parameter_variant_collapses_the_previous_frame() {
for variant in ["\x1b[K", "\x1b[0K", "\x1b[1K", "\x1b[2K"] {
let raw = format!("frame1{variant}frame2\n");
assert_eq!(
normalize_carriage_returns(raw.as_bytes()),
b"frame2\n",
"variant {variant:?} must collapse the previous frame"
);
}
}
#[test]
fn a_csi_cursor_to_column_one_sequence_collapses_the_previous_frame_like_a_bare_carriage_return()
{
assert_eq!(
normalize_carriage_returns(b"frame1\x1b[Gframe2\n"),
b"frame2\n"
);
assert_eq!(
normalize_carriage_returns(b"frame1\x1b[1Gframe2\n"),
b"frame2\n"
);
}
#[test]
fn a_carriage_return_immediately_followed_by_an_erase_in_line_sequence_still_collapses_once() {
let raw = format!(
"\x1b[36m{}\x1b[39m frame1\r\x1b[K\x1b[36m{}\x1b[39m frame2\r\x1b[K\x1b[36m{}\x1b[39m frame3\n",
'\u{280b}', '\u{2819}', '\u{2839}'
);
let expected = format!("\x1b[36m{}\x1b[39m frame3\n", '\u{2839}');
assert_eq!(
normalize_carriage_returns(raw.as_bytes()),
expected.as_bytes()
);
}
#[test]
fn sgr_colour_sequences_are_never_treated_as_a_frame_reset_and_survive_untouched() {
assert_eq!(
normalize_carriage_returns(b"\x1b[31mred\x1b[0m\n"),
b"\x1b[31mred\x1b[0m\n"
);
}
#[test]
fn a_cursor_up_sequence_is_left_untouched_as_out_of_scope() {
assert_eq!(
normalize_carriage_returns(b"frame1\x1b[Aframe2\n"),
b"frame1\x1b[Aframe2\n"
);
}
#[test]
fn a_cursor_to_a_later_column_is_left_untouched_since_it_is_not_a_frame_reset() {
assert_eq!(
normalize_carriage_returns(b"frame1\x1b[5Gframe2\n"),
b"frame1\x1b[5Gframe2\n"
);
}
#[test]
fn a_csi_sequence_truncated_at_the_end_of_the_stream_is_passed_through_rather_than_panicking() {
assert_eq!(normalize_carriage_returns(b"abc\x1b[2"), b"abc\x1b[2");
}
#[test]
fn a_negative_enxio_classifies_as_the_unresolved_race_with_a_normalised_positive_code() {
let failure = classify_pty_open_failure(-libc::ENXIO);
assert!(matches!(failure, PtyOpenFailure::RaceUnresolved(_)));
assert_eq!(failure.code(), libc::ENXIO);
}
#[test]
fn a_positive_enxio_classifies_as_table_exhaustion_with_its_code_unchanged() {
let failure = classify_pty_open_failure(libc::ENXIO);
assert!(matches!(failure, PtyOpenFailure::TableExhausted(_)));
assert_eq!(failure.code(), libc::ENXIO);
}
#[test]
fn the_two_enxio_cases_produce_different_words() {
let exhausted = classify_pty_open_failure(libc::ENXIO).detail();
let race = classify_pty_open_failure(-libc::ENXIO).detail();
assert_ne!(exhausted, race);
assert!(exhausted.contains("full"));
assert!(race.contains("attempts"));
}
#[test]
fn a_permanently_failing_attempt_stops_at_the_bound_rather_than_retrying_forever() {
let mut attempts = 0;
let failure = retrying_enxio::<()>(|| {
attempts += 1;
assert!(attempts <= 64, "retried {attempts} times without giving up");
Err(io::Error::from_raw_os_error(libc::ENXIO))
});
assert_eq!(attempts, OPEN_PTY_MAX_ATTEMPTS);
assert!(matches!(failure, Err(PtyOpenFailure::TableExhausted(_))));
}
#[test]
fn an_attempt_that_succeeds_after_one_enxio_returns_the_success() {
let mut attempts = 0;
let opened = retrying_enxio(|| {
attempts += 1;
if attempts == 1 {
Err(io::Error::from_raw_os_error(-libc::ENXIO))
} else {
Ok("opened")
}
});
assert_eq!(attempts, 2);
assert_eq!(opened.expect("retried past the race"), "opened");
}
#[test]
fn a_failure_that_is_not_enxio_is_not_retried() {
let mut attempts = 0;
let failure = retrying_enxio::<()>(|| {
attempts += 1;
Err(io::Error::from_raw_os_error(libc::EACCES))
});
assert_eq!(attempts, 1);
assert!(matches!(failure, Err(PtyOpenFailure::Other(_))));
}
#[test]
#[ignore = "exhausts the machine's whole pty table; must run alone, not in the default suite"]
fn exhausting_the_pty_table_still_fails_as_exhaustion_rather_than_spinning() {
let mut held = Vec::new();
let failure = loop {
match open_pty(PTY_WIDTH) {
Ok(fds) => held.push(fds),
Err(failure) => break failure,
}
};
assert!(
matches!(failure, PtyOpenFailure::TableExhausted(_)),
"a genuinely exhausted pty table must fail as exhaustion, got {failure:?}"
);
assert_eq!(failure.code(), libc::ENXIO);
}
}