Skip to main content

supercov_engine/
process_supervision.rs

1//! Privacy-preserving child-process supervision for arbitrary test commands.
2
3// Descriptor and watchdog handling reads the filesystem only on Unix; on
4// Windows the module path is unused and the first Windows build said so.
5#[cfg(unix)]
6use std::fs;
7use std::{
8    ffi::{OsStr, OsString},
9    fs::OpenOptions,
10    io::{self, Read, Write},
11    path::{Path, PathBuf},
12    process::{Child, Command, ExitStatus, Stdio},
13    sync::{
14        Mutex, MutexGuard,
15        atomic::{AtomicI32, Ordering},
16    },
17    thread,
18    time::{Duration, Instant},
19};
20
21use serde::{Deserialize, Serialize};
22use supercov_contracts::{
23    COMMAND_TERMINATION_GRACE_MS, COMMAND_TIMEOUT_EXIT_CODE, DEFAULT_DIAGNOSTIC_INTERVAL_MS,
24};
25
26const POLL_INTERVAL: Duration = Duration::from_millis(10);
27
28#[cfg(unix)]
29use std::os::{
30    fd::{AsRawFd, FromRawFd, OwnedFd},
31    unix::{ffi::OsStrExt as _, process::CommandExt as _},
32};
33
34#[derive(Debug)]
35pub enum SupervisionError {
36    InvalidMilliseconds {
37        name: String,
38    },
39    EmptyCommand,
40    Spawn {
41        program: OsString,
42        source: io::Error,
43    },
44    Wait(io::Error),
45    Signal(io::Error),
46    PlatformOperation {
47        operation: &'static str,
48        source: io::Error,
49    },
50    UnsupportedPlatform(&'static str),
51}
52
53impl std::fmt::Display for SupervisionError {
54    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            Self::InvalidMilliseconds { name } => {
57                write!(
58                    formatter,
59                    "{name} must be a positive integer number of milliseconds"
60                )
61            }
62            Self::EmptyCommand => write!(formatter, "test command must not be empty"),
63            Self::Spawn { program, source } => {
64                write!(
65                    formatter,
66                    "could not spawn {}: {source}",
67                    program.to_string_lossy()
68                )
69            }
70            Self::Wait(error) => write!(formatter, "could not wait for test command: {error}"),
71            Self::Signal(error) => {
72                write!(formatter, "could not install signal forwarding: {error}")
73            }
74            Self::PlatformOperation { operation, source } => {
75                write!(formatter, "could not {operation}: {source}")
76            }
77            Self::UnsupportedPlatform(reason) => write!(
78                formatter,
79                "unsupported process supervision platform: {reason}"
80            ),
81        }
82    }
83}
84
85impl std::error::Error for SupervisionError {}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct CommandSpec {
89    pub program: OsString,
90    pub arguments: Vec<OsString>,
91    pub cwd: PathBuf,
92    /// `None` inherits the supervisor environment. `Some` clears it first and
93    /// installs exactly these values.
94    pub environment: Option<Vec<(OsString, OsString)>>,
95    /// When set, stdout and stderr are merged into this newly-created file.
96    /// The orchestration layer owns publication and cleanup of the file.
97    pub captured_output: Option<PathBuf>,
98    /// When set, what the command prints is passed on with these names put
99    /// right. A stream that is a terminal is still the command's own.
100    pub console: Option<ConsoleNames>,
101}
102
103/// How what a command prints names the project where it named Supercov's
104/// copy.
105#[derive(Debug, Clone, Default, PartialEq, Eq)]
106pub struct ConsoleNames {
107    /// Each spelling of the copy's directory with the project's, longest
108    /// first.
109    pub places: Vec<(String, String)>,
110    /// How a tool that names a file from where it runs reaches the project
111    /// from the copy: `../../../../`.
112    pub up_to_the_project: Option<String>,
113}
114
115impl ConsoleNames {
116    /// One line as the command would have printed it in the project.
117    pub fn line<'a>(&self, line: &'a str) -> std::borrow::Cow<'a, str> {
118        let named = crate::workspace::project_named(line, &self.places);
119        let text = named.as_deref().unwrap_or(line);
120        let Some(up) = self.up_to_the_project.as_deref() else {
121            return named.map_or(line.into(), Into::into);
122        };
123        let Some(relative) = relative_to_the_project(text, up) else {
124            return named.map_or(line.into(), Into::into);
125        };
126        relative.into()
127    }
128}
129
130/// A path written from the copy up to the project (`../../../../pkg/a.go`),
131/// as it is written from the project: `pkg/a.go`, and `./a.go` for a file of
132/// the directory itself, which is how Go writes one. Either separator.
133fn relative_to_the_project(text: &str, up: &str) -> Option<String> {
134    let other = up.replace('/', "\\");
135    let mut out = String::with_capacity(text.len());
136    let mut rest = text;
137    let mut changed = false;
138    while let Some((at, prefix)) = [up, other.as_str()]
139        .into_iter()
140        .filter_map(|prefix| Some((rest.find(prefix)?, prefix)))
141        .min_by_key(|(at, _)| *at)
142    {
143        let before = &rest[..at];
144        let after = &rest[at + prefix.len()..];
145        // Where a path begins, and not further up than the project.
146        let begins = before.chars().next_back().is_none_or(|last| {
147            !(last.is_alphanumeric() || matches!(last, '.' | '/' | '\\' | '_' | '-'))
148        });
149        if !begins || after.starts_with("..") {
150            out.push_str(&rest[..at + prefix.len()]);
151            rest = after;
152            continue;
153        }
154        out.push_str(before);
155        let name = after
156            .split(|next: char| next == ':' || next.is_whitespace())
157            .next()
158            .unwrap_or_default();
159        if !name.contains(['/', '\\']) {
160            out.push('.');
161            out.push_str(&prefix[prefix.len() - 1..]);
162        }
163        rest = after;
164        changed = true;
165    }
166    out.push_str(rest);
167    changed.then_some(out)
168}
169
170/// The streams of a child whose output is passed on line by line.
171struct Relays(Vec<std::sync::mpsc::Receiver<()>>);
172
173/// Where each relayed stream is passed on to: this process's own output, by
174/// a handle of its own. Rust's `stderr()` is one lock, the caller that
175/// reports the run holds it for as long as the command runs, and a relay
176/// writing through it waited for the command to end before its first line.
177#[derive(Default)]
178struct RelayTargets {
179    stdout: Option<std::fs::File>,
180    stderr: Option<std::fs::File>,
181    /// Both streams go to one place (`2>&1`, a CI log), and the command
182    /// writes both down this one pipe: two pipes read by two threads would
183    /// pass its lines on in an order it did not write them in.
184    both: Option<io::PipeReader>,
185}
186
187/// Whether two handles are one file, pipe or device.
188#[cfg(unix)]
189fn one_place(left: &std::fs::File, right: &std::fs::File) -> bool {
190    use std::os::unix::fs::MetadataExt;
191    match (left.metadata(), right.metadata()) {
192        (Ok(left), Ok(right)) => left.dev() == right.dev() && left.ino() == right.ino(),
193        _ => false,
194    }
195}
196
197#[cfg(not(unix))]
198fn one_place(_left: &std::fs::File, _right: &std::fs::File) -> bool {
199    false
200}
201
202#[cfg(unix)]
203fn own_handle(stream: &impl std::os::fd::AsFd) -> Option<std::fs::File> {
204    stream.as_fd().try_clone_to_owned().ok().map(Into::into)
205}
206
207#[cfg(windows)]
208fn own_handle(stream: &impl std::os::windows::io::AsHandle) -> Option<std::fs::File> {
209    stream.as_handle().try_clone_to_owned().ok().map(Into::into)
210}
211
212#[cfg(not(any(unix, windows)))]
213fn own_handle<T>(_stream: &T) -> Option<std::fs::File> {
214    None
215}
216
217/// How long a stream is still read after the command has ended. It ends with
218/// the command unless a process the command left behind holds it open.
219const RELAY_GRACE: Duration = Duration::from_secs(2);
220
221impl Relays {
222    /// Ask for a pipe on each stream that is not a terminal and can be
223    /// written to by a handle of its own.
224    fn request(command: &mut Command, names: Option<&ConsoleNames>) -> RelayTargets {
225        use std::io::IsTerminal;
226        let mut targets = RelayTargets::default();
227        if names.is_none() {
228            return targets;
229        }
230        if !io::stdout().is_terminal() {
231            targets.stdout = own_handle(&io::stdout());
232        }
233        if !io::stderr().is_terminal() {
234            targets.stderr = own_handle(&io::stderr());
235        }
236        if let (Some(stdout), Some(stderr)) = (&targets.stdout, &targets.stderr)
237            && one_place(stdout, stderr)
238            && let Ok((reader, writer)) = io::pipe()
239            && let Ok(second) = writer.try_clone()
240        {
241            command.stdout(writer).stderr(second);
242            targets.both = Some(reader);
243            return targets;
244        }
245        if targets.stdout.is_some() {
246            command.stdout(Stdio::piped());
247        }
248        if targets.stderr.is_some() {
249            command.stderr(Stdio::piped());
250        }
251        targets
252    }
253
254    fn start(
255        child: &mut std::process::Child,
256        targets: RelayTargets,
257        names: Option<&ConsoleNames>,
258    ) -> Self {
259        let mut relays = Vec::new();
260        let Some(names) = names else {
261            return Self(relays);
262        };
263        if let Some(pipe) = targets.both {
264            if let Some(to) = targets.stderr.or(targets.stdout) {
265                relays.push(relay(pipe, to, names.clone()));
266            }
267            return Self(relays);
268        }
269        if let (Some(pipe), Some(to)) = (child.stdout.take(), targets.stdout) {
270            relays.push(relay(pipe, to, names.clone()));
271        }
272        if let (Some(pipe), Some(to)) = (child.stderr.take(), targets.stderr) {
273            relays.push(relay(pipe, to, names.clone()));
274        }
275        Self(relays)
276    }
277
278    fn finish(self) {
279        for ended in self.0 {
280            let _ = ended.recv_timeout(RELAY_GRACE);
281        }
282    }
283}
284
285/// A line longer than this is passed on in pieces.
286const RELAY_LINE_LIMIT: usize = 64 * 1024;
287
288fn relay(
289    mut pipe: impl Read + Send + 'static,
290    mut to: impl Write + Send + 'static,
291    names: ConsoleNames,
292) -> std::sync::mpsc::Receiver<()> {
293    let (ended, wait) = std::sync::mpsc::channel();
294    thread::spawn(move || {
295        let mut pass = |bytes: &[u8]| -> io::Result<()> {
296            match std::str::from_utf8(bytes) {
297                Ok(text) => to.write_all(names.line(text).as_bytes())?,
298                Err(_) => to.write_all(bytes)?,
299            }
300            to.flush()
301        };
302        let mut pending = Vec::new();
303        let mut buffer = [0_u8; 8192];
304        loop {
305            let read = match pipe.read(&mut buffer) {
306                Ok(0) => break,
307                Ok(read) => read,
308                Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
309                Err(_) => break,
310            };
311            pending.extend_from_slice(&buffer[..read]);
312            let mut passed = 0;
313            while let Some(end) = pending[passed..].iter().position(|byte| *byte == b'\n') {
314                // Whoever reads this has gone: the command is told so by
315                // its next write, as it would have been.
316                if pass(&pending[passed..=passed + end]).is_err() {
317                    let _ = ended.send(());
318                    return;
319                }
320                passed += end + 1;
321            }
322            pending.drain(..passed);
323            if pending.len() > RELAY_LINE_LIMIT {
324                if pass(&pending).is_err() {
325                    let _ = ended.send(());
326                    return;
327                }
328                pending.clear();
329            }
330        }
331        if !pending.is_empty() {
332            let _ = pass(&pending);
333        }
334        let _ = ended.send(());
335    });
336    wait
337}
338
339impl CommandSpec {
340    pub fn command(&self) -> Result<Command, SupervisionError> {
341        if self.program.is_empty() {
342            return Err(SupervisionError::EmptyCommand);
343        }
344        let mut command = Command::new(self.resolved_program());
345        command
346            .args(&self.arguments)
347            .current_dir(&self.cwd)
348            .stdin(Stdio::inherit());
349        if let Some(path) = &self.captured_output {
350            let output = OpenOptions::new()
351                .write(true)
352                .create_new(true)
353                .open(path)
354                .map_err(|source| SupervisionError::PlatformOperation {
355                    operation: "create captured process output",
356                    source,
357                })?;
358            let errors =
359                output
360                    .try_clone()
361                    .map_err(|source| SupervisionError::PlatformOperation {
362                        operation: "clone captured process output",
363                        source,
364                    })?;
365            command
366                .stdout(Stdio::from(output))
367                .stderr(Stdio::from(errors));
368        } else {
369            command.stdout(Stdio::inherit()).stderr(Stdio::inherit());
370        }
371        if let Some(environment) = &self.environment {
372            command.env_clear().envs(environment.iter().cloned());
373        }
374        #[cfg(windows)]
375        {
376            use std::os::windows::process::CommandExt;
377            use windows_sys::Win32::System::Threading::{
378                CREATE_NEW_PROCESS_GROUP, CREATE_SUSPENDED,
379            };
380            command.creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_SUSPENDED);
381        }
382        Ok(command)
383    }
384}
385
386impl CommandSpec {
387    /// Windows finds `foo.exe` for a bare `foo` and nothing else, while a
388    /// gem's or npm's executable there is a `.bat` or `.cmd` shim: `rspec`
389    /// failed with "program not found" with `rspec.bat` sitting on PATH.
390    /// Resolve the program the way the shell does -- every PATH entry, every
391    /// PATHEXT extension -- so the standard library sees the extension and
392    /// runs a batch file through cmd.exe with its own quoting.
393    #[cfg(windows)]
394    fn resolved_program(&self) -> OsString {
395        // Variable names are case-insensitive there, and PATH is usually
396        // spelled `Path`.
397        let variable = |name: &str| match &self.environment {
398            Some(environment) => environment
399                .iter()
400                .find(|(key, _)| key.eq_ignore_ascii_case(name))
401                .map(|(_, value)| value.clone()),
402            None => std::env::var_os(name),
403        };
404        resolve_program_with(
405            &self.program,
406            &self.cwd,
407            variable("PATH"),
408            variable("PATHEXT"),
409        )
410    }
411
412    #[cfg(not(windows))]
413    fn resolved_program(&self) -> OsString {
414        self.program.clone()
415    }
416}
417
418/// The host-independent half of program resolution, so it is tested on every
419/// host. A bare name is looked for in each PATH directory, a name with a
420/// separator relative to the working directory; in either place a candidate
421/// carrying a PATHEXT extension wins over the bare file, as in cmd.exe,
422/// because an extensionless script cannot be started on Windows even when it
423/// exists. A program nothing matches is returned unchanged so the operating
424/// system reports the failure in its own words.
425#[cfg_attr(not(windows), allow(dead_code))]
426fn resolve_program_with(
427    program: &OsStr,
428    cwd: &Path,
429    path: Option<OsString>,
430    pathext: Option<OsString>,
431) -> OsString {
432    const DEFAULT_PATHEXT: &str = ".COM;.EXE;.BAT;.CMD";
433    let pathext = pathext
434        .filter(|value| !value.is_empty())
435        .unwrap_or_else(|| DEFAULT_PATHEXT.into());
436    let extensions = pathext
437        .to_string_lossy()
438        .split(';')
439        .filter(|extension| !extension.is_empty())
440        .map(str::to_owned)
441        .collect::<Vec<_>>();
442    let directories = if program.to_string_lossy().contains(['\\', '/']) {
443        vec![cwd.to_path_buf()]
444    } else {
445        path.map(|path| std::env::split_paths(&path).collect())
446            .unwrap_or_default()
447    };
448    let is_file = |candidate: &Path| std::fs::metadata(candidate).is_ok_and(|meta| meta.is_file());
449    for directory in directories {
450        let base = directory.join(program);
451        for extension in &extensions {
452            let mut candidate = base.clone().into_os_string();
453            candidate.push(extension);
454            if is_file(Path::new(&candidate)) {
455                return candidate;
456            }
457        }
458        if base.extension().is_some() && is_file(&base) {
459            return base.into_os_string();
460        }
461    }
462    program.to_owned()
463}
464
465#[derive(Debug, Clone, Copy, PartialEq, Eq)]
466pub struct SupervisionOptions {
467    pub diagnostic_interval: Duration,
468    pub timeout: Option<Duration>,
469    pub termination_grace: Duration,
470}
471
472impl Default for SupervisionOptions {
473    fn default() -> Self {
474        Self {
475            diagnostic_interval: Duration::from_millis(DEFAULT_DIAGNOSTIC_INTERVAL_MS),
476            timeout: None,
477            termination_grace: Duration::from_millis(COMMAND_TERMINATION_GRACE_MS),
478        }
479    }
480}
481
482impl SupervisionOptions {
483    pub fn from_environment() -> Result<Self, SupervisionError> {
484        Ok(Self {
485            diagnostic_interval: positive_milliseconds(
486                std::env::var("SUPERCOV_DIAGNOSTIC_INTERVAL_MS")
487                    .ok()
488                    .as_deref(),
489                "SUPERCOV_DIAGNOSTIC_INTERVAL_MS",
490            )?
491            .unwrap_or_else(|| Duration::from_millis(DEFAULT_DIAGNOSTIC_INTERVAL_MS)),
492            timeout: positive_milliseconds(
493                std::env::var("SUPERCOV_COMMAND_TIMEOUT_MS").ok().as_deref(),
494                "SUPERCOV_COMMAND_TIMEOUT_MS",
495            )?,
496            termination_grace: Duration::from_millis(COMMAND_TERMINATION_GRACE_MS),
497        })
498    }
499}
500
501#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
502#[serde(rename_all = "camelCase")]
503pub struct ProcessSnapshot {
504    pub pid: u32,
505    pub parent_pid: u32,
506    #[serde(skip_serializing_if = "Option::is_none")]
507    pub state: Option<String>,
508    #[serde(skip_serializing_if = "Option::is_none")]
509    pub cpu_tenths: Option<u64>,
510    pub executable: String,
511}
512
513#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
514#[serde(rename_all = "UPPERCASE")]
515pub enum ForwardedSignal {
516    Sighup,
517    Sigint,
518    Sigterm,
519}
520
521impl ForwardedSignal {
522    pub fn exit_code(self) -> i32 {
523        match self {
524            Self::Sighup => 129,
525            Self::Sigint => 130,
526            Self::Sigterm => 143,
527        }
528    }
529
530    #[cfg(unix)]
531    fn raw(self) -> i32 {
532        match self {
533            Self::Sighup => libc::SIGHUP,
534            Self::Sigint => libc::SIGINT,
535            Self::Sigterm => libc::SIGTERM,
536        }
537    }
538}
539
540#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
541#[serde(rename_all = "camelCase")]
542pub struct SupervisedResult {
543    pub status: Option<i32>,
544    pub signal: Option<i32>,
545    pub timed_out: bool,
546    pub interrupted_signal: Option<ForwardedSignal>,
547}
548
549#[derive(Debug)]
550pub struct SupervisedOutput {
551    pub result: SupervisedResult,
552    pub stdout: Vec<u8>,
553    pub stderr: Vec<u8>,
554}
555
556impl SupervisedResult {
557    pub fn exit_code(&self) -> i32 {
558        if self.timed_out {
559            COMMAND_TIMEOUT_EXIT_CODE
560        } else if let Some(signal) = self.interrupted_signal {
561            signal.exit_code()
562        } else {
563            self.status.unwrap_or(128)
564        }
565    }
566}
567
568pub fn positive_milliseconds(
569    value: Option<&str>,
570    name: &str,
571) -> Result<Option<Duration>, SupervisionError> {
572    let Some(value) = value.filter(|value| !value.is_empty()) else {
573        return Ok(None);
574    };
575    let milliseconds = value
576        .parse::<u64>()
577        .ok()
578        .filter(|milliseconds| *milliseconds > 0)
579        .ok_or_else(|| SupervisionError::InvalidMilliseconds { name: name.into() })?;
580    Ok(Some(Duration::from_millis(milliseconds)))
581}
582
583fn process_inventory() -> Vec<ProcessSnapshot> {
584    use sysinfo::{ProcessRefreshKind, RefreshKind, System};
585
586    let system = System::new_with_specifics(
587        RefreshKind::nothing().with_processes(ProcessRefreshKind::nothing().with_cpu()),
588    );
589    system
590        .processes()
591        .iter()
592        .map(|(pid, process)| ProcessSnapshot {
593            pid: pid.as_u32(),
594            parent_pid: process.parent().map_or(0, sysinfo::Pid::as_u32),
595            state: Some(process_status(process.status()).into()),
596            cpu_tenths: Some(process.accumulated_cpu_time() / 100),
597            executable: Path::new(process.name())
598                .file_name()
599                .and_then(|value| value.to_str())
600                .unwrap_or("unknown")
601                .to_owned(),
602        })
603        .collect()
604}
605
606fn process_status(status: sysinfo::ProcessStatus) -> &'static str {
607    use sysinfo::ProcessStatus;
608    match status {
609        ProcessStatus::Idle => "I",
610        ProcessStatus::Run => "R",
611        ProcessStatus::Sleep => "S",
612        ProcessStatus::Stop => "T",
613        ProcessStatus::Zombie => "Z",
614        ProcessStatus::Tracing => "t",
615        ProcessStatus::Dead => "X",
616        ProcessStatus::Wakekill => "K",
617        ProcessStatus::Waking => "W",
618        ProcessStatus::Parked => "P",
619        ProcessStatus::LockBlocked => "L",
620        ProcessStatus::UninterruptibleDiskSleep => "D",
621        ProcessStatus::Suspended => "S",
622        ProcessStatus::Unknown(_) => "?",
623    }
624}
625
626pub fn descendant_process_tree(root_pid: u32) -> Vec<ProcessSnapshot> {
627    let inventory = process_inventory();
628    let mut descendants = std::collections::BTreeSet::from([root_pid]);
629    loop {
630        let before = descendants.len();
631        for process in &inventory {
632            if descendants.contains(&process.parent_pid) {
633                descendants.insert(process.pid);
634            }
635        }
636        if descendants.len() == before {
637            break;
638        }
639    }
640    let mut result = inventory
641        .into_iter()
642        .filter(|process| descendants.contains(&process.pid))
643        .collect::<Vec<_>>();
644    result.sort_by_key(|process| process.pid);
645    result
646}
647
648fn format_duration(milliseconds: u128) -> String {
649    if milliseconds < 1_000 {
650        return format!("{milliseconds}ms");
651    }
652    let seconds = (milliseconds + 500) / 1_000;
653    if seconds < 60 {
654        return format!("{seconds}s");
655    }
656    format!("{}m{:02}s", seconds / 60, seconds % 60)
657}
658
659pub fn format_process_diagnostic(
660    root_pid: u32,
661    elapsed: Duration,
662    tree: &[ProcessSnapshot],
663) -> String {
664    let mut output = format!(
665        "[supercov] command still running after {}",
666        format_duration(elapsed.as_millis())
667    );
668    if tree.is_empty() {
669        output.push_str(&format!("\n  pid={root_pid} process details unavailable"));
670        return output;
671    }
672    for process in tree {
673        output.push_str(&format!(
674            "\n  pid={} ppid={} exe={}",
675            process.pid, process.parent_pid, process.executable
676        ));
677        if let Some(state) = &process.state {
678            output.push_str(&format!(" state={state}"));
679        }
680        if let Some(cpu_tenths) = process.cpu_tenths {
681            output.push_str(&format!(" cpu={}.{}s", cpu_tenths / 10, cpu_tenths % 10));
682        }
683    }
684    output
685}
686
687#[cfg(unix)]
688struct SignalFlags {
689    _exclusive: MutexGuard<'static, ()>,
690    previous: Vec<(i32, libc::sigaction)>,
691}
692
693#[cfg(unix)]
694impl SignalFlags {
695    fn install() -> Result<Self, SupervisionError> {
696        let exclusive = SIGNAL_HANDLER_LOCK
697            .lock()
698            .unwrap_or_else(std::sync::PoisonError::into_inner);
699        RECEIVED_SIGNAL.store(0, Ordering::SeqCst);
700        let mut previous = Vec::new();
701        for signal in [libc::SIGHUP, libc::SIGINT, libc::SIGTERM] {
702            // SAFETY: zero is a valid initial state for `sigaction`; every
703            // field used by the kernel is initialized below before the call.
704            let mut action = unsafe { std::mem::zeroed::<libc::sigaction>() };
705            action.sa_sigaction = record_signal as *const () as usize;
706            // SAFETY: `action.sa_mask` is a valid, writable signal set.
707            unsafe { libc::sigemptyset(&mut action.sa_mask) };
708            action.sa_flags = 0;
709            // SAFETY: `old` is initialized by a successful `sigaction` call.
710            let mut old = unsafe { std::mem::zeroed::<libc::sigaction>() };
711            // SAFETY: pointers reference live `sigaction` values and the
712            // signal is one of the three catchable POSIX signals above.
713            if unsafe { libc::sigaction(signal, &action, &mut old) } != 0 {
714                for (installed, old) in previous.iter().rev() {
715                    // SAFETY: restores a handler returned by `sigaction`.
716                    let _ = unsafe { libc::sigaction(*installed, old, std::ptr::null_mut()) };
717                }
718                return Err(SupervisionError::Signal(io::Error::last_os_error()));
719            }
720            previous.push((signal, old));
721        }
722        Ok(Self {
723            _exclusive: exclusive,
724            previous,
725        })
726    }
727
728    fn received(&self) -> Option<ForwardedSignal> {
729        // A supervisor is shared by every concurrently running child in one
730        // execution session. Keep the signal visible until the session guard
731        // is dropped so every process group observes the same interruption.
732        match RECEIVED_SIGNAL.load(Ordering::SeqCst) {
733            libc::SIGHUP => Some(ForwardedSignal::Sighup),
734            libc::SIGINT => Some(ForwardedSignal::Sigint),
735            libc::SIGTERM => Some(ForwardedSignal::Sigterm),
736            _ => None,
737        }
738    }
739}
740
741#[cfg(unix)]
742impl Drop for SignalFlags {
743    fn drop(&mut self) {
744        for (signal, previous) in self.previous.drain(..).rev() {
745            // SAFETY: `previous` came directly from a successful `sigaction`
746            // call for the same signal and remains live for this call.
747            let _ = unsafe { libc::sigaction(signal, &previous, std::ptr::null_mut()) };
748        }
749        RECEIVED_SIGNAL.store(0, Ordering::SeqCst);
750    }
751}
752
753#[cfg(unix)]
754static SIGNAL_HANDLER_LOCK: Mutex<()> = Mutex::new(());
755#[cfg(unix)]
756static RECEIVED_SIGNAL: AtomicI32 = AtomicI32::new(0);
757
758#[cfg(unix)]
759extern "C" fn record_signal(signal: i32) {
760    RECEIVED_SIGNAL.store(signal, Ordering::SeqCst);
761}
762
763#[cfg(windows)]
764struct SignalFlags {
765    _exclusive: MutexGuard<'static, ()>,
766}
767
768#[cfg(windows)]
769impl SignalFlags {
770    fn install() -> Result<Self, SupervisionError> {
771        use windows_sys::Win32::System::Console::SetConsoleCtrlHandler;
772
773        let exclusive = SIGNAL_HANDLER_LOCK
774            .lock()
775            .unwrap_or_else(std::sync::PoisonError::into_inner);
776        RECEIVED_SIGNAL.store(0, Ordering::SeqCst);
777        // SAFETY: `record_console_signal` has the required system ABI and
778        // remains installed only while this guard is alive.
779        if unsafe { SetConsoleCtrlHandler(Some(record_console_signal), 1) } == 0 {
780            return Err(SupervisionError::Signal(io::Error::last_os_error()));
781        }
782        Ok(Self {
783            _exclusive: exclusive,
784        })
785    }
786
787    fn received(&self) -> Option<ForwardedSignal> {
788        match RECEIVED_SIGNAL.load(Ordering::SeqCst) {
789            2 => Some(ForwardedSignal::Sigint),
790            15 => Some(ForwardedSignal::Sigterm),
791            _ => None,
792        }
793    }
794}
795
796#[cfg(windows)]
797impl Drop for SignalFlags {
798    fn drop(&mut self) {
799        use windows_sys::Win32::System::Console::SetConsoleCtrlHandler;
800
801        // SAFETY: removes exactly the handler installed by `install`.
802        let _ = unsafe { SetConsoleCtrlHandler(Some(record_console_signal), 0) };
803        RECEIVED_SIGNAL.store(0, Ordering::SeqCst);
804    }
805}
806
807#[cfg(windows)]
808static SIGNAL_HANDLER_LOCK: Mutex<()> = Mutex::new(());
809#[cfg(windows)]
810static RECEIVED_SIGNAL: AtomicI32 = AtomicI32::new(0);
811
812#[cfg(windows)]
813unsafe extern "system" fn record_console_signal(control: u32) -> i32 {
814    use windows_sys::Win32::System::Console::{
815        CTRL_BREAK_EVENT, CTRL_C_EVENT, CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT,
816    };
817
818    match control {
819        CTRL_C_EVENT | CTRL_BREAK_EVENT => {
820            RECEIVED_SIGNAL.store(2, Ordering::SeqCst);
821            1
822        }
823        CTRL_CLOSE_EVENT | CTRL_LOGOFF_EVENT | CTRL_SHUTDOWN_EVENT => {
824            RECEIVED_SIGNAL.store(15, Ordering::SeqCst);
825            1
826        }
827        _ => 0,
828    }
829}
830
831#[cfg(windows)]
832pub(crate) struct JobHandle(windows_sys::Win32::Foundation::HANDLE);
833
834// A job-object handle is a process-wide kernel token, not a pointer into this
835// thread's memory: assigning a process to it, terminating it and closing it are
836// all safe from any thread, which is the same invariant std's OwnedHandle
837// carries by being Send and Sync. Without these the raw HANDLE makes the whole
838// supervisor !Sync on Windows, and the Rust test runner, which shares one
839// supervisor across scoped threads, does not compile there -- the first
840// Windows build found exactly that.
841#[cfg(windows)]
842unsafe impl Send for JobHandle {}
843#[cfg(windows)]
844unsafe impl Sync for JobHandle {}
845
846#[cfg(windows)]
847impl JobHandle {
848    pub(crate) fn new() -> Result<Self, SupervisionError> {
849        use windows_sys::Win32::System::JobObjects::{
850            CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
851            JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
852            SetInformationJobObject,
853        };
854
855        // SAFETY: null security attributes and name create one private job.
856        let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
857        if handle.is_null() {
858            return Err(SupervisionError::PlatformOperation {
859                operation: "create a Windows Job Object",
860                source: io::Error::last_os_error(),
861            });
862        }
863        let job = Self(handle);
864        let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
865        limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
866        // SAFETY: the buffer is a live value of the exact information class
867        // and length requested by SetInformationJobObject.
868        if unsafe {
869            SetInformationJobObject(
870                job.0,
871                JobObjectExtendedLimitInformation,
872                (&raw const limits).cast(),
873                std::mem::size_of_val(&limits) as u32,
874            )
875        } == 0
876        {
877            return Err(SupervisionError::PlatformOperation {
878                operation: "configure Windows Job Object containment",
879                source: io::Error::last_os_error(),
880            });
881        }
882        Ok(job)
883    }
884
885    pub(crate) fn assign(&self, child: &Child) -> Result<(), SupervisionError> {
886        use std::os::windows::io::AsRawHandle;
887        use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject;
888
889        // SAFETY: Child owns a live process handle with the rights granted by
890        // CreateProcess; the job handle stays live for the complete plan.
891        if unsafe { AssignProcessToJobObject(self.0, child.as_raw_handle().cast()) } == 0 {
892            return Err(SupervisionError::PlatformOperation {
893                operation: "assign the suspended command to its Windows Job Object",
894                source: io::Error::last_os_error(),
895            });
896        }
897        Ok(())
898    }
899
900    fn terminate(&self) {
901        use windows_sys::Win32::System::JobObjects::TerminateJobObject;
902        // SAFETY: the handle owns this invocation's process tree. Failure can
903        // only mean the tree has already exited, so termination is best effort.
904        let _ = unsafe { TerminateJobObject(self.0, 1) };
905    }
906}
907
908#[cfg(windows)]
909impl Drop for JobHandle {
910    fn drop(&mut self) {
911        use windows_sys::Win32::Foundation::CloseHandle;
912        // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE makes this the final, crash-safe
913        // containment boundary for descendants that outlive their root.
914        let _ = unsafe { CloseHandle(self.0) };
915    }
916}
917
918#[cfg(windows)]
919fn resume_suspended_process(pid: u32) -> Result<(), SupervisionError> {
920    use windows_sys::Win32::{
921        Foundation::{CloseHandle, INVALID_HANDLE_VALUE},
922        System::{
923            Diagnostics::ToolHelp::{
924                CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First,
925                Thread32Next,
926            },
927            Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME},
928        },
929    };
930
931    // The stdlib exposes the process handle but not CreateProcess's primary
932    // thread handle. Starting suspended, assigning the job, then resuming the
933    // process-owned thread from a ToolHelp snapshot closes the escape race.
934    let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
935    if snapshot == INVALID_HANDLE_VALUE {
936        return Err(SupervisionError::PlatformOperation {
937            operation: "enumerate the suspended command threads",
938            source: io::Error::last_os_error(),
939        });
940    }
941    struct Snapshot(windows_sys::Win32::Foundation::HANDLE);
942    impl Drop for Snapshot {
943        fn drop(&mut self) {
944            let _ = unsafe { CloseHandle(self.0) };
945        }
946    }
947    let _snapshot = Snapshot(snapshot);
948    let mut entry = THREADENTRY32 {
949        dwSize: std::mem::size_of::<THREADENTRY32>() as u32,
950        ..Default::default()
951    };
952    if unsafe { Thread32First(snapshot, &raw mut entry) } == 0 {
953        return Err(SupervisionError::PlatformOperation {
954            operation: "read the suspended command thread snapshot",
955            source: io::Error::last_os_error(),
956        });
957    }
958    loop {
959        if entry.th32OwnerProcessID == pid {
960            let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
961            if thread.is_null() {
962                return Err(SupervisionError::PlatformOperation {
963                    operation: "open the suspended command's primary thread",
964                    source: io::Error::last_os_error(),
965                });
966            }
967            // SAFETY: the handle identifies a suspended thread owned by the
968            // just-created process and is closed immediately after resuming.
969            let resumed = unsafe { ResumeThread(thread) };
970            let resume_error = (resumed == u32::MAX).then(io::Error::last_os_error);
971            let _ = unsafe { CloseHandle(thread) };
972            if let Some(source) = resume_error {
973                return Err(SupervisionError::PlatformOperation {
974                    operation: "resume the contained command",
975                    source,
976                });
977            }
978            return Ok(());
979        }
980        entry.dwSize = std::mem::size_of::<THREADENTRY32>() as u32;
981        if unsafe { Thread32Next(snapshot, &raw mut entry) } == 0 {
982            break;
983        }
984    }
985    Err(SupervisionError::PlatformOperation {
986        operation: "locate the suspended command's primary thread",
987        source: io::Error::new(io::ErrorKind::NotFound, "process thread was absent"),
988    })
989}
990
991#[cfg(windows)]
992fn forward_windows_control(child: &Child) {
993    use windows_sys::Win32::System::Console::{CTRL_BREAK_EVENT, GenerateConsoleCtrlEvent};
994    // CREATE_NEW_PROCESS_GROUP makes the child's PID its console group ID.
995    // Some non-console commands reject the event; the grace-period Job Object
996    // termination remains authoritative in that case.
997    let _ = unsafe { GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, child.id()) };
998}
999
1000/// The write end is held only by the supervising process. A tiny watchdog
1001/// created in the command's pre-exec child blocks on the read end. Normal
1002/// completion, an unwind, or uncatchable supervisor death all close this
1003/// descriptor and make the watchdog kill the command's complete process group.
1004#[cfg(unix)]
1005struct ParentDeathGuard {
1006    _writer: OwnedFd,
1007}
1008
1009#[cfg(unix)]
1010fn parent_death_pipe() -> Result<(OwnedFd, OwnedFd), SupervisionError> {
1011    let mut descriptors = [-1_i32; 2];
1012    // SAFETY: `descriptors` is a live two-element output buffer for pipe(2).
1013    if unsafe { libc::pipe(descriptors.as_mut_ptr()) } != 0 {
1014        return Err(SupervisionError::PlatformOperation {
1015            operation: "create parent-death supervision pipe",
1016            source: io::Error::last_os_error(),
1017        });
1018    }
1019    // SAFETY: pipe(2) returned two newly owned descriptors.
1020    let read = unsafe { OwnedFd::from_raw_fd(descriptors[0]) };
1021    // SAFETY: same as above for the write end.
1022    let write = unsafe { OwnedFd::from_raw_fd(descriptors[1]) };
1023    for descriptor in [read.as_raw_fd(), write.as_raw_fd()] {
1024        // SAFETY: the descriptor is live and F_SETFD accepts FD_CLOEXEC.
1025        if unsafe { libc::fcntl(descriptor, libc::F_SETFD, libc::FD_CLOEXEC) } != 0 {
1026            return Err(SupervisionError::PlatformOperation {
1027                operation: "protect parent-death supervision pipe across exec",
1028                source: io::Error::last_os_error(),
1029            });
1030        }
1031    }
1032    Ok((read, write))
1033}
1034
1035#[cfg(unix)]
1036fn spawn_contained(
1037    command: &mut Command,
1038    program: &OsString,
1039    watchdog_program: Option<&Path>,
1040) -> Result<(Child, Option<ParentDeathGuard>), SupervisionError> {
1041    let Some(watchdog_program) = watchdog_program else {
1042        command.process_group(0);
1043        let child = command.spawn().map_err(|source| SupervisionError::Spawn {
1044            program: program.clone(),
1045            source,
1046        })?;
1047        return Ok((child, None));
1048    };
1049    let (read, write) = parent_death_pipe()?;
1050    let (ready_read, ready_write) = parent_death_pipe()?;
1051    let read_descriptor = read.as_raw_fd();
1052    let write_descriptor = write.as_raw_fd();
1053    let ready_read_descriptor = ready_read.as_raw_fd();
1054    let ready_write_descriptor = ready_write.as_raw_fd();
1055    let watchdog_program = std::ffi::CString::new(watchdog_program.as_os_str().as_bytes())
1056        .map_err(|_| SupervisionError::PlatformOperation {
1057            operation: "encode parent-death watchdog executable",
1058            source: io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"),
1059        })?;
1060    let watchdog_argument =
1061        std::ffi::CString::new("__watch-process-group").expect("static CString");
1062    // SAFETY: this closure calls only async-signal-safe syscalls between fork
1063    // and exec. The forked watchdog immediately execs the already-loaded
1064    // Supercov binary; it never returns to Rust or touches an inherited lock.
1065    unsafe {
1066        command.pre_exec(move || {
1067            if libc::setpgid(0, 0) != 0 {
1068                return Err(io::Error::last_os_error());
1069            }
1070            let _ = libc::close(write_descriptor);
1071            let watchdog = libc::fork();
1072            if watchdog < 0 {
1073                return Err(io::Error::last_os_error());
1074            }
1075            if watchdog == 0 {
1076                let _ = libc::close(ready_read_descriptor);
1077                if libc::dup2(read_descriptor, 0) < 0 || libc::dup2(ready_write_descriptor, 3) < 0 {
1078                    libc::_exit(125);
1079                }
1080                for descriptor in [read_descriptor, ready_write_descriptor, 1, 2] {
1081                    if descriptor != 0 && descriptor != 3 {
1082                        let _ = libc::close(descriptor);
1083                    }
1084                }
1085                let arguments = [
1086                    watchdog_program.as_ptr(),
1087                    watchdog_argument.as_ptr(),
1088                    std::ptr::null(),
1089                ];
1090                libc::execv(watchdog_program.as_ptr(), arguments.as_ptr());
1091                libc::_exit(125);
1092            }
1093            let _ = libc::close(read_descriptor);
1094            let _ = libc::close(ready_write_descriptor);
1095            let mut ready = 0_u8;
1096            loop {
1097                let received = libc::read(ready_read_descriptor, (&raw mut ready).cast(), 1);
1098                if received == 1 && ready == 1 {
1099                    break;
1100                }
1101                if received < 0 && io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
1102                    continue;
1103                }
1104                return Err(io::Error::other(
1105                    "parent-death watchdog failed before command exec",
1106                ));
1107            }
1108            let _ = libc::close(ready_read_descriptor);
1109            Ok(())
1110        });
1111    }
1112    let child = command.spawn().map_err(|source| SupervisionError::Spawn {
1113        program: program.clone(),
1114        source,
1115    })?;
1116    drop(read);
1117    drop(ready_read);
1118    drop(ready_write);
1119    Ok((child, Some(ParentDeathGuard { _writer: write })))
1120}
1121
1122#[cfg(unix)]
1123pub fn watch_parent_process_group() -> io::Result<()> {
1124    // The target waits for our readiness byte, so its PID remains both our
1125    // parent PID and the process-group ID until containment is armed.
1126    let process_group = unsafe { libc::getppid() };
1127    if process_group <= 1 {
1128        return Err(io::Error::other(
1129            "parent-death watchdog has no target process",
1130        ));
1131    }
1132    // SAFETY: the watchdog is a non-leader child in the target's process group.
1133    if unsafe { libc::setsid() } < 0 {
1134        return Err(io::Error::last_os_error());
1135    }
1136    let descriptor_root = if Path::new("/proc/self/fd").is_dir() {
1137        Path::new("/proc/self/fd")
1138    } else {
1139        Path::new("/dev/fd")
1140    };
1141    let descriptors = fs::read_dir(descriptor_root)?
1142        .filter_map(Result::ok)
1143        .filter_map(|entry| entry.file_name().to_string_lossy().parse::<i32>().ok())
1144        .filter(|descriptor| !matches!(*descriptor, 0 | 3))
1145        .collect::<Vec<_>>();
1146    for descriptor in descriptors {
1147        // SAFETY: closing a descriptor that the directory iterator already
1148        // released, or one concurrently absent, is harmless.
1149        let _ = unsafe { libc::close(descriptor) };
1150    }
1151    let ready = [1_u8];
1152    // SAFETY: pre-exec mapped the private readiness pipe to descriptor 3.
1153    if unsafe { libc::write(3, ready.as_ptr().cast(), ready.len()) } != 1 {
1154        return Err(io::Error::last_os_error());
1155    }
1156    let _ = unsafe { libc::close(3) };
1157    let mut buffer = [0_u8; 1];
1158    loop {
1159        // The supervisor never writes. EOF means normal supervisor teardown,
1160        // unwind, or uncatchable process death.
1161        let read = unsafe { libc::read(0, buffer.as_mut_ptr().cast(), buffer.len()) };
1162        if read == 0 {
1163            break;
1164        }
1165        if read < 0 {
1166            if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
1167                continue;
1168            }
1169            break;
1170        }
1171    }
1172    let _ = unsafe { libc::kill(-process_group, libc::SIGKILL) };
1173    Ok(())
1174}
1175
1176#[cfg(not(unix))]
1177pub fn watch_parent_process_group() -> io::Result<()> {
1178    Err(io::Error::new(
1179        io::ErrorKind::Unsupported,
1180        "the POSIX parent-death watchdog is unavailable",
1181    ))
1182}
1183
1184#[cfg(unix)]
1185fn signal_process_group(child: &mut Child, signal: i32) {
1186    let pid = child.id() as i32;
1187    // SAFETY: `kill` is async-signal-safe and receives a process-group ID
1188    // created for this child before exec. Failure can mean the child exited
1189    // between `try_wait` and this call, so it is intentionally non-fatal.
1190    let group_result = unsafe { libc::kill(-pid, signal) };
1191    if group_result != 0 {
1192        // SAFETY: same rationale, with the child PID as a last-resort target.
1193        let _ = unsafe { libc::kill(pid, signal) };
1194    }
1195}
1196
1197#[cfg(unix)]
1198fn exit_parts(status: ExitStatus) -> (Option<i32>, Option<i32>) {
1199    use std::os::unix::process::ExitStatusExt;
1200    (status.code(), status.signal())
1201}
1202
1203#[cfg(not(unix))]
1204fn exit_parts(status: ExitStatus) -> (Option<i32>, Option<i32>) {
1205    (status.code(), None)
1206}
1207
1208/// One diagnostic line in one write. `writeln!` with arguments writes the
1209/// pieces between them separately, and the command being supervised shares
1210/// this stderr: a child printing at the same moment landed inside the timeout
1211/// line, splitting it where the number goes. A single write of a short line to
1212/// a pipe cannot be interleaved.
1213fn write_line(writer: &mut dyn Write, line: &str) -> std::io::Result<()> {
1214    let mut bytes = String::with_capacity(line.len() + 1);
1215    bytes.push_str(line);
1216    bytes.push('\n');
1217    writer.write_all(bytes.as_bytes())?;
1218    writer.flush()
1219}
1220
1221fn write_diagnostic(child: &Child, started: Instant, writer: &mut dyn Write) {
1222    let tree = descendant_process_tree(child.id());
1223    let diagnostic = format_process_diagnostic(child.id(), started.elapsed(), &tree);
1224    let verbose = std::env::var("SUPERCOV_VERBOSE")
1225        .or_else(|_| std::env::var("SUPERCOV_DEBUG"))
1226        .is_ok_and(|value| matches!(value.as_str(), "1" | "true" | "yes"));
1227    let diagnostic = if verbose {
1228        diagnostic.as_str()
1229    } else {
1230        diagnostic.lines().next().unwrap_or(diagnostic.as_str())
1231    };
1232    let _ = writeln!(writer, "{}", diagnostic).and_then(|_| writer.flush());
1233}
1234
1235fn validate_options(options: SupervisionOptions) -> Result<(), SupervisionError> {
1236    if options.diagnostic_interval.is_zero() || options.termination_grace.is_zero() {
1237        return Err(SupervisionError::InvalidMilliseconds {
1238            name: "process supervision interval".into(),
1239        });
1240    }
1241    if options.timeout.is_some_and(|timeout| timeout.is_zero()) {
1242        return Err(SupervisionError::InvalidMilliseconds {
1243            name: "SUPERCOV_COMMAND_TIMEOUT_MS".into(),
1244        });
1245    }
1246    Ok(())
1247}
1248
1249fn read_pipe(mut pipe: impl Read) -> io::Result<Vec<u8>> {
1250    let mut bytes = Vec::new();
1251    pipe.read_to_end(&mut bytes)?;
1252    Ok(bytes)
1253}
1254
1255fn captured_bytes(
1256    reader: thread::JoinHandle<io::Result<Vec<u8>>>,
1257    stream: &'static str,
1258) -> Result<Vec<u8>, SupervisionError> {
1259    reader
1260        .join()
1261        .map_err(|_| SupervisionError::PlatformOperation {
1262            operation: "join captured process output reader",
1263            source: io::Error::other(format!("{stream} reader panicked")),
1264        })?
1265        .map_err(|source| SupervisionError::PlatformOperation {
1266            operation: "read captured process output",
1267            source,
1268        })
1269}
1270
1271#[cfg(unix)]
1272pub struct ProcessSupervisor {
1273    signals: SignalFlags,
1274    watchdog_program: Option<PathBuf>,
1275}
1276
1277#[cfg(unix)]
1278impl ProcessSupervisor {
1279    pub fn new() -> Result<Self, SupervisionError> {
1280        Ok(Self {
1281            signals: SignalFlags::install()?,
1282            watchdog_program: None,
1283        })
1284    }
1285
1286    pub fn new_crash_safe(watchdog_program: &Path) -> Result<Self, SupervisionError> {
1287        let watchdog_program = fs::canonicalize(watchdog_program).map_err(|source| {
1288            SupervisionError::PlatformOperation {
1289                operation: "resolve parent-death watchdog executable",
1290                source,
1291            }
1292        })?;
1293        if !fs::metadata(&watchdog_program).is_ok_and(|metadata| metadata.is_file()) {
1294            return Err(SupervisionError::PlatformOperation {
1295                operation: "validate parent-death watchdog executable",
1296                source: io::Error::new(io::ErrorKind::InvalidInput, "expected a regular file"),
1297            });
1298        }
1299        Ok(Self {
1300            signals: SignalFlags::install()?,
1301            watchdog_program: Some(watchdog_program),
1302        })
1303    }
1304
1305    pub fn supervise(
1306        &self,
1307        spec: &CommandSpec,
1308        options: SupervisionOptions,
1309        writer: &mut dyn Write,
1310    ) -> Result<SupervisedResult, SupervisionError> {
1311        validate_options(options)?;
1312        if let Some(signal) = self.signals.received() {
1313            return Ok(SupervisedResult {
1314                status: None,
1315                signal: Some(signal.raw()),
1316                timed_out: false,
1317                interrupted_signal: Some(signal),
1318            });
1319        }
1320        let mut command = spec.command()?;
1321        let targets = Relays::request(&mut command, spec.console.as_ref());
1322        let (mut child, _parent_death_guard) = spawn_contained(
1323            &mut command,
1324            &spec.program,
1325            self.watchdog_program.as_deref(),
1326        )?;
1327        // This process's ends of a pipe the child was given go with the
1328        // command; held, the pipe would never end.
1329        drop(command);
1330        let relays = Relays::start(&mut child, targets, spec.console.as_ref());
1331        let result = self.monitor(&mut child, options, writer);
1332        relays.finish();
1333        result
1334    }
1335
1336    pub fn supervise_captured(
1337        &self,
1338        spec: &CommandSpec,
1339        options: SupervisionOptions,
1340        writer: &mut dyn Write,
1341    ) -> Result<SupervisedOutput, SupervisionError> {
1342        validate_options(options)?;
1343        if spec.captured_output.is_some() {
1344            return Err(SupervisionError::PlatformOperation {
1345                operation: "configure separate captured process output",
1346                source: io::Error::new(
1347                    io::ErrorKind::InvalidInput,
1348                    "merged and separate capture cannot be requested together",
1349                ),
1350            });
1351        }
1352        if let Some(signal) = self.signals.received() {
1353            return Ok(SupervisedOutput {
1354                result: SupervisedResult {
1355                    status: None,
1356                    signal: Some(signal.raw()),
1357                    timed_out: false,
1358                    interrupted_signal: Some(signal),
1359                },
1360                stdout: Vec::new(),
1361                stderr: Vec::new(),
1362            });
1363        }
1364        let mut command = spec.command()?;
1365        command.stdout(Stdio::piped()).stderr(Stdio::piped());
1366        let (mut child, parent_death_guard) = spawn_contained(
1367            &mut command,
1368            &spec.program,
1369            self.watchdog_program.as_deref(),
1370        )?;
1371        let stdout = child.stdout.take().expect("piped stdout");
1372        let stderr = child.stderr.take().expect("piped stderr");
1373        let stdout_reader = thread::spawn(move || read_pipe(stdout));
1374        let stderr_reader = thread::spawn(move || read_pipe(stderr));
1375        let result = self.monitor(&mut child, options, writer);
1376        // Closing the liveness writer makes the watchdog kill any descendants
1377        // that retained the output pipes after the root command exited.
1378        drop(parent_death_guard);
1379        let stdout = captured_bytes(stdout_reader, "stdout")?;
1380        let stderr = captured_bytes(stderr_reader, "stderr")?;
1381        Ok(SupervisedOutput {
1382            result: result?,
1383            stdout,
1384            stderr,
1385        })
1386    }
1387
1388    fn monitor(
1389        &self,
1390        child: &mut Child,
1391        options: SupervisionOptions,
1392        writer: &mut dyn Write,
1393    ) -> Result<SupervisedResult, SupervisionError> {
1394        let started = Instant::now();
1395        let mut next_diagnostic = started + options.diagnostic_interval;
1396        let timeout_at = options.timeout.map(|timeout| started + timeout);
1397        let mut termination: Option<(Instant, Option<ForwardedSignal>)> = None;
1398        let mut timed_out = false;
1399        let mut interrupted_signal = None;
1400        let mut escalated = false;
1401
1402        loop {
1403            let status = match child.try_wait() {
1404                Ok(status) => status,
1405                Err(error) => {
1406                    signal_process_group(child, libc::SIGKILL);
1407                    let _ = child.wait();
1408                    return Err(SupervisionError::Wait(error));
1409                }
1410            };
1411            if let Some(status) = status {
1412                let (status, signal) = exit_parts(status);
1413                return Ok(SupervisedResult {
1414                    status,
1415                    signal,
1416                    timed_out,
1417                    interrupted_signal,
1418                });
1419            }
1420            let now = Instant::now();
1421            if termination.is_none()
1422                && let Some(signal) = self.signals.received()
1423            {
1424                interrupted_signal = Some(signal);
1425                signal_process_group(child, signal.raw());
1426                termination = Some((now, Some(signal)));
1427            }
1428            if termination.is_none() && timeout_at.is_some_and(|deadline| now >= deadline) {
1429                timed_out = true;
1430                let _ = write_line(
1431                    writer,
1432                    &format!(
1433                        "[supercov] command exceeded SUPERCOV_COMMAND_TIMEOUT_MS={}; terminating process group",
1434                        options.timeout.expect("timeout deadline").as_millis()
1435                    ),
1436                );
1437                signal_process_group(child, libc::SIGTERM);
1438                termination = Some((now, None));
1439                write_diagnostic(child, started, writer);
1440            }
1441            if now >= next_diagnostic && !timed_out {
1442                write_diagnostic(child, started, writer);
1443                while next_diagnostic <= now {
1444                    next_diagnostic += options.diagnostic_interval;
1445                }
1446            }
1447            if !escalated
1448                && termination.is_some_and(|(terminated_at, _)| {
1449                    now.duration_since(terminated_at) >= options.termination_grace
1450                })
1451            {
1452                signal_process_group(child, libc::SIGKILL);
1453                escalated = true;
1454            }
1455            thread::sleep(POLL_INTERVAL);
1456        }
1457    }
1458}
1459
1460#[cfg(windows)]
1461pub struct ProcessSupervisor {
1462    signals: SignalFlags,
1463    job: JobHandle,
1464}
1465
1466#[cfg(windows)]
1467impl ProcessSupervisor {
1468    pub fn new() -> Result<Self, SupervisionError> {
1469        Ok(Self {
1470            signals: SignalFlags::install()?,
1471            job: JobHandle::new()?,
1472        })
1473    }
1474
1475    pub fn new_crash_safe(_watchdog_program: &Path) -> Result<Self, SupervisionError> {
1476        Self::new()
1477    }
1478
1479    pub fn supervise(
1480        &self,
1481        spec: &CommandSpec,
1482        options: SupervisionOptions,
1483        writer: &mut dyn Write,
1484    ) -> Result<SupervisedResult, SupervisionError> {
1485        validate_options(options)?;
1486        if let Some(signal) = self.signals.received() {
1487            return Ok(SupervisedResult {
1488                status: None,
1489                signal: None,
1490                timed_out: false,
1491                interrupted_signal: Some(signal),
1492            });
1493        }
1494        let mut command = spec.command()?;
1495        let targets = Relays::request(&mut command, spec.console.as_ref());
1496        let mut child = command.spawn().map_err(|source| SupervisionError::Spawn {
1497            program: spec.program.clone(),
1498            source,
1499        })?;
1500        if let Err(error) = self.job.assign(&child) {
1501            let _ = child.kill();
1502            let _ = child.wait();
1503            return Err(error);
1504        }
1505        if let Err(error) = resume_suspended_process(child.id()) {
1506            self.job.terminate();
1507            let _ = child.wait();
1508            return Err(error);
1509        }
1510        // This process's ends of a pipe the child was given go with the
1511        // command; held, the pipe would never end.
1512        drop(command);
1513        let relays = Relays::start(&mut child, targets, spec.console.as_ref());
1514        let result = self.monitor(&mut child, options, writer);
1515        relays.finish();
1516        result
1517    }
1518
1519    pub fn supervise_captured(
1520        &self,
1521        spec: &CommandSpec,
1522        options: SupervisionOptions,
1523        writer: &mut dyn Write,
1524    ) -> Result<SupervisedOutput, SupervisionError> {
1525        validate_options(options)?;
1526        if spec.captured_output.is_some() {
1527            return Err(SupervisionError::PlatformOperation {
1528                operation: "configure separate captured process output",
1529                source: io::Error::new(
1530                    io::ErrorKind::InvalidInput,
1531                    "merged and separate capture cannot be requested together",
1532                ),
1533            });
1534        }
1535        if let Some(signal) = self.signals.received() {
1536            return Ok(SupervisedOutput {
1537                result: SupervisedResult {
1538                    status: None,
1539                    signal: None,
1540                    timed_out: false,
1541                    interrupted_signal: Some(signal),
1542                },
1543                stdout: Vec::new(),
1544                stderr: Vec::new(),
1545            });
1546        }
1547        let mut command = spec.command()?;
1548        command.stdout(Stdio::piped()).stderr(Stdio::piped());
1549        let mut child = command.spawn().map_err(|source| SupervisionError::Spawn {
1550            program: spec.program.clone(),
1551            source,
1552        })?;
1553        if let Err(error) = self.job.assign(&child) {
1554            let _ = child.kill();
1555            let _ = child.wait();
1556            return Err(error);
1557        }
1558        if let Err(error) = resume_suspended_process(child.id()) {
1559            self.job.terminate();
1560            let _ = child.wait();
1561            return Err(error);
1562        }
1563        let stdout = child.stdout.take().expect("piped stdout");
1564        let stderr = child.stderr.take().expect("piped stderr");
1565        let stdout_reader = thread::spawn(move || read_pipe(stdout));
1566        let stderr_reader = thread::spawn(move || read_pipe(stderr));
1567        let result = self.monitor(&mut child, options, writer);
1568        let stdout = captured_bytes(stdout_reader, "stdout")?;
1569        let stderr = captured_bytes(stderr_reader, "stderr")?;
1570        Ok(SupervisedOutput {
1571            result: result?,
1572            stdout,
1573            stderr,
1574        })
1575    }
1576
1577    fn monitor(
1578        &self,
1579        child: &mut Child,
1580        options: SupervisionOptions,
1581        writer: &mut dyn Write,
1582    ) -> Result<SupervisedResult, SupervisionError> {
1583        let started = Instant::now();
1584        let mut next_diagnostic = started + options.diagnostic_interval;
1585        let timeout_at = options.timeout.map(|timeout| started + timeout);
1586        let mut termination: Option<Instant> = None;
1587        let mut timed_out = false;
1588        let mut interrupted_signal = None;
1589        let mut escalated = false;
1590
1591        loop {
1592            let status = match child.try_wait() {
1593                Ok(status) => status,
1594                Err(error) => {
1595                    self.job.terminate();
1596                    let _ = child.wait();
1597                    return Err(SupervisionError::Wait(error));
1598                }
1599            };
1600            if let Some(status) = status {
1601                let (status, signal) = exit_parts(status);
1602                return Ok(SupervisedResult {
1603                    status,
1604                    signal,
1605                    timed_out,
1606                    interrupted_signal,
1607                });
1608            }
1609            let now = Instant::now();
1610            if termination.is_none()
1611                && let Some(signal) = self.signals.received()
1612            {
1613                interrupted_signal = Some(signal);
1614                forward_windows_control(child);
1615                termination = Some(now);
1616            }
1617            if termination.is_none() && timeout_at.is_some_and(|deadline| now >= deadline) {
1618                timed_out = true;
1619                let _ = write_line(
1620                    writer,
1621                    &format!(
1622                        "[supercov] command exceeded SUPERCOV_COMMAND_TIMEOUT_MS={}; terminating process group",
1623                        options.timeout.expect("timeout deadline").as_millis()
1624                    ),
1625                );
1626                forward_windows_control(child);
1627                termination = Some(now);
1628                write_diagnostic(child, started, writer);
1629            }
1630            if now >= next_diagnostic && !timed_out {
1631                write_diagnostic(child, started, writer);
1632                while next_diagnostic <= now {
1633                    next_diagnostic += options.diagnostic_interval;
1634                }
1635            }
1636            if !escalated
1637                && termination.is_some_and(|terminated_at| {
1638                    now.duration_since(terminated_at) >= options.termination_grace
1639                })
1640            {
1641                self.job.terminate();
1642                escalated = true;
1643            }
1644            thread::sleep(POLL_INTERVAL);
1645        }
1646    }
1647}
1648
1649#[cfg(not(any(unix, windows)))]
1650pub struct ProcessSupervisor;
1651
1652#[cfg(not(any(unix, windows)))]
1653impl ProcessSupervisor {
1654    pub fn new() -> Result<Self, SupervisionError> {
1655        Err(SupervisionError::UnsupportedPlatform(
1656            "this target has no process-tree containment implementation",
1657        ))
1658    }
1659
1660    pub fn new_crash_safe(_watchdog_program: &Path) -> Result<Self, SupervisionError> {
1661        Self::new()
1662    }
1663
1664    pub fn supervise(
1665        &self,
1666        _spec: &CommandSpec,
1667        _options: SupervisionOptions,
1668        _writer: &mut dyn Write,
1669    ) -> Result<SupervisedResult, SupervisionError> {
1670        Err(SupervisionError::UnsupportedPlatform(
1671            "this target has no process-tree containment implementation",
1672        ))
1673    }
1674
1675    pub fn supervise_captured(
1676        &self,
1677        _spec: &CommandSpec,
1678        _options: SupervisionOptions,
1679        _writer: &mut dyn Write,
1680    ) -> Result<SupervisedOutput, SupervisionError> {
1681        Err(SupervisionError::UnsupportedPlatform(
1682            "this target has no process-tree containment implementation",
1683        ))
1684    }
1685}
1686
1687pub fn supervise_command(
1688    spec: &CommandSpec,
1689    options: SupervisionOptions,
1690    writer: &mut dyn Write,
1691) -> Result<SupervisedResult, SupervisionError> {
1692    ProcessSupervisor::new()?.supervise(spec, options, writer)
1693}
1694
1695pub fn supervise_captured_command(
1696    spec: &CommandSpec,
1697    options: SupervisionOptions,
1698    writer: &mut dyn Write,
1699) -> Result<SupervisedOutput, SupervisionError> {
1700    ProcessSupervisor::new()?.supervise_captured(spec, options, writer)
1701}
1702
1703#[cfg(test)]
1704mod tests {
1705    use super::*;
1706
1707    /// Somewhere a relay can write that the test can read afterwards.
1708    #[derive(Clone, Default)]
1709    struct Kept(std::sync::Arc<Mutex<Vec<u8>>>);
1710
1711    impl Write for Kept {
1712        fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
1713            self.0.lock().unwrap().extend_from_slice(bytes);
1714            Ok(bytes.len())
1715        }
1716
1717        fn flush(&mut self) -> io::Result<()> {
1718            Ok(())
1719        }
1720    }
1721
1722    #[test]
1723    fn what_a_command_prints_names_the_project() {
1724        // Go names a file in a compile error from where it runs, Maven and
1725        // Gradle end a failed run with the address of a report, and each
1726        // named Supercov's copy.
1727        let names = ConsoleNames {
1728            places: vec![(
1729                "/home/me/shop/.supercov/workspaces/workspace/shop".into(),
1730                "/home/me/shop".into(),
1731            )],
1732            up_to_the_project: Some("../../../../".into()),
1733        };
1734        let printed = "# example.com/shop [example.com/shop.test]\n../../../../crypto.go:4:15: undefined: undefinedThing\n../../../../pkg/inner/inner.go:4:9: cannot use s\n\tat ../../../../../elsewhere.go:1 and a/../../../../b.go\n[ERROR] Please refer to /home/me/shop/.supercov/workspaces/workspace/shop/target/surefire-reports for the individual test results.\nlast line, with no end";
1735        let kept = Kept::default();
1736        let ended = relay(
1737            io::Cursor::new(printed.as_bytes().to_vec()),
1738            kept.clone(),
1739            names,
1740        );
1741        ended
1742            .recv_timeout(Duration::from_secs(10))
1743            .expect("the relay ends with its input");
1744        assert_eq!(
1745            String::from_utf8(kept.0.lock().unwrap().clone()).unwrap(),
1746            "# example.com/shop [example.com/shop.test]\n./crypto.go:4:15: undefined: undefinedThing\npkg/inner/inner.go:4:9: cannot use s\n\tat ../../../../../elsewhere.go:1 and a/../../../../b.go\n[ERROR] Please refer to /home/me/shop/target/surefire-reports for the individual test results.\nlast line, with no end"
1747        );
1748    }
1749
1750    fn scratch_directory(label: &str) -> PathBuf {
1751        static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1752        let root = std::env::temp_dir().join(format!(
1753            "supercov-{label}-{}-{}-{}",
1754            std::process::id(),
1755            std::time::SystemTime::now()
1756                .duration_since(std::time::UNIX_EPOCH)
1757                .unwrap()
1758                .as_nanos(),
1759            COUNTER.fetch_add(1, Ordering::Relaxed)
1760        ));
1761        std::fs::create_dir(&root).unwrap();
1762        root
1763    }
1764
1765    #[test]
1766    fn resolves_programs_the_way_the_windows_shell_does() {
1767        let root = scratch_directory("program-resolution");
1768        std::fs::create_dir(root.join("sub")).unwrap();
1769        // `tool.CMD` is the same file as `tool.cmd` where names are
1770        // case-insensitive and a second file where they are not; either way
1771        // the default extension list finds it under its own spelling.
1772        for name in ["tool.cmd", "tool.CMD", "named.exe", "plain", "sub/tool.cmd"] {
1773            std::fs::write(root.join(name), "").unwrap();
1774        }
1775        let path = Some(std::env::join_paths([root.join("elsewhere"), root.clone()]).unwrap());
1776        let pathext = Some(OsString::from(".exe;.cmd"));
1777        let resolve = |program: &str| {
1778            resolve_program_with(OsStr::new(program), &root, path.clone(), pathext.clone())
1779        };
1780
1781        assert_eq!(resolve("tool"), root.join("tool.cmd").into_os_string());
1782        assert_eq!(resolve("named"), root.join("named.exe").into_os_string());
1783        assert_eq!(
1784            resolve("named.exe"),
1785            root.join("named.exe").into_os_string()
1786        );
1787        // An extensionless file is never chosen: cmd.exe cannot start it either.
1788        assert_eq!(resolve("plain"), OsString::from("plain"));
1789        assert_eq!(resolve("missing"), OsString::from("missing"));
1790        // A separator means relative to the working directory, not PATH.
1791        assert_eq!(
1792            resolve("sub/tool"),
1793            root.join("sub/tool.cmd").into_os_string()
1794        );
1795        assert_eq!(
1796            resolve("./sub/tool"),
1797            root.join("./sub/tool.cmd").into_os_string()
1798        );
1799        let absolute = root.join("named.exe");
1800        assert_eq!(
1801            resolve_program_with(
1802                absolute.as_os_str(),
1803                Path::new("/nowhere"),
1804                None,
1805                pathext.clone()
1806            ),
1807            absolute.clone().into_os_string()
1808        );
1809        // No PATHEXT at all means the Windows default list.
1810        assert_eq!(
1811            resolve_program_with(OsStr::new("tool"), &root, path.clone(), None),
1812            root.join("tool.CMD").into_os_string()
1813        );
1814        std::fs::remove_dir_all(root).unwrap();
1815    }
1816
1817    #[cfg(windows)]
1818    #[test]
1819    fn starts_a_batch_shim_found_through_pathext() {
1820        let root = scratch_directory("batch-shim");
1821        std::fs::write(root.join("shim.cmd"), "@echo off\r\nexit /b 3\r\n").unwrap();
1822        let mut environment = vec![
1823            (OsString::from("Path"), root.clone().into_os_string()),
1824            (
1825                OsString::from("PATHEXT"),
1826                OsString::from(".COM;.EXE;.BAT;.CMD"),
1827            ),
1828        ];
1829        if let Some(system_root) = std::env::var_os("SystemRoot") {
1830            environment.push((OsString::from("SystemRoot"), system_root));
1831        }
1832        let spec = CommandSpec {
1833            program: "shim".into(),
1834            arguments: vec!["ignored".into()],
1835            cwd: std::env::current_dir().unwrap(),
1836            environment: Some(environment),
1837            captured_output: None,
1838            console: None,
1839        };
1840        let mut diagnostics = Vec::new();
1841        let result =
1842            supervise_command(&spec, SupervisionOptions::default(), &mut diagnostics).unwrap();
1843        assert_eq!(result.exit_code(), 3);
1844        std::fs::remove_dir_all(root).unwrap();
1845    }
1846
1847    #[test]
1848    fn parses_only_positive_integer_milliseconds() {
1849        assert_eq!(positive_milliseconds(None, "VALUE").unwrap(), None);
1850        assert_eq!(
1851            positive_milliseconds(Some("50"), "VALUE").unwrap(),
1852            Some(Duration::from_millis(50))
1853        );
1854        for value in ["0", "-1", "1.5", "NaN", " 1"] {
1855            assert!(positive_milliseconds(Some(value), "VALUE").is_err());
1856        }
1857    }
1858
1859    #[test]
1860    fn diagnostic_format_is_sanitized_and_reference_compatible() {
1861        let output = format_process_diagnostic(
1862            20,
1863            Duration::from_millis(61_000),
1864            &[ProcessSnapshot {
1865                pid: 20,
1866                parent_pid: 10,
1867                executable: "node".into(),
1868                state: Some("S".into()),
1869                cpu_tenths: Some(13),
1870            }],
1871        );
1872        assert_eq!(
1873            output,
1874            "[supercov] command still running after 1m01s\n  pid=20 ppid=10 exe=node state=S cpu=1.3s"
1875        );
1876        assert!(!output.contains("argv"));
1877    }
1878
1879    #[cfg(unix)]
1880    #[test]
1881    fn returns_the_child_status_without_a_default_timeout() {
1882        let root = std::env::current_dir().unwrap();
1883        let spec = CommandSpec {
1884            program: "/bin/sh".into(),
1885            arguments: vec!["-c".into(), "exit 7".into()],
1886            cwd: root,
1887            environment: None,
1888            captured_output: None,
1889            console: None,
1890        };
1891        let mut diagnostics = Vec::new();
1892        let result =
1893            supervise_command(&spec, SupervisionOptions::default(), &mut diagnostics).unwrap();
1894        assert_eq!(result.exit_code(), 7);
1895        assert!(!result.timed_out);
1896        assert!(diagnostics.is_empty());
1897    }
1898
1899    #[cfg(unix)]
1900    #[test]
1901    fn captures_stdout_and_stderr_separately_without_losing_status() {
1902        let spec = CommandSpec {
1903            program: "/bin/sh".into(),
1904            arguments: vec![
1905                "-c".into(),
1906                "printf stdout-value; printf stderr-value >&2; exit 9".into(),
1907            ],
1908            cwd: std::env::current_dir().unwrap(),
1909            environment: None,
1910            captured_output: None,
1911            console: None,
1912        };
1913        let mut diagnostics = Vec::new();
1914        let output =
1915            supervise_captured_command(&spec, SupervisionOptions::default(), &mut diagnostics)
1916                .unwrap();
1917        assert_eq!(output.result.exit_code(), 9);
1918        assert_eq!(output.stdout, b"stdout-value");
1919        assert_eq!(output.stderr, b"stderr-value");
1920        assert!(diagnostics.is_empty());
1921    }
1922
1923    #[cfg(windows)]
1924    #[test]
1925    fn returns_the_windows_child_status_without_a_default_timeout() {
1926        let spec = CommandSpec {
1927            program: "cmd.exe".into(),
1928            arguments: vec!["/D".into(), "/S".into(), "/C".into(), "exit /b 7".into()],
1929            cwd: std::env::current_dir().unwrap(),
1930            environment: None,
1931            captured_output: None,
1932            console: None,
1933        };
1934        let mut diagnostics = Vec::new();
1935        let result =
1936            supervise_command(&spec, SupervisionOptions::default(), &mut diagnostics).unwrap();
1937        assert_eq!(result.exit_code(), 7);
1938        assert!(!result.timed_out);
1939        assert!(diagnostics.is_empty());
1940    }
1941
1942    #[cfg(unix)]
1943    #[test]
1944    fn explicit_timeout_reports_and_returns_124() {
1945        let root = std::env::current_dir().unwrap();
1946        let spec = CommandSpec {
1947            program: "/bin/sh".into(),
1948            arguments: vec!["-c".into(), "while :; do sleep 1; done".into()],
1949            cwd: root,
1950            environment: None,
1951            captured_output: None,
1952            console: None,
1953        };
1954        let mut diagnostics = Vec::new();
1955        let result = supervise_command(
1956            &spec,
1957            SupervisionOptions {
1958                diagnostic_interval: Duration::from_millis(20),
1959                timeout: Some(Duration::from_millis(70)),
1960                termination_grace: Duration::from_millis(50),
1961            },
1962            &mut diagnostics,
1963        )
1964        .unwrap();
1965        let diagnostics = String::from_utf8(diagnostics).unwrap();
1966        assert_eq!(result.exit_code(), COMMAND_TIMEOUT_EXIT_CODE);
1967        assert!(result.timed_out);
1968        assert!(diagnostics.contains("command still running after"));
1969        assert!(diagnostics.contains("SUPERCOV_COMMAND_TIMEOUT_MS=70"));
1970    }
1971
1972    #[cfg(windows)]
1973    #[test]
1974    fn timeout_terminates_the_complete_windows_job() {
1975        use std::{
1976            fs,
1977            time::{SystemTime, UNIX_EPOCH},
1978        };
1979
1980        let unique = SystemTime::now()
1981            .duration_since(UNIX_EPOCH)
1982            .unwrap()
1983            .as_nanos();
1984        let root = std::env::temp_dir().join(format!(
1985            "supercov-windows-job-{}-{unique}",
1986            std::process::id()
1987        ));
1988        fs::create_dir_all(&root).unwrap();
1989        struct RemoveOnDrop(PathBuf);
1990        impl Drop for RemoveOnDrop {
1991            fn drop(&mut self) {
1992                let _ = fs::remove_dir_all(&self.0);
1993            }
1994        }
1995        let _cleanup = RemoveOnDrop(root.clone());
1996        let ready = root.join("descendant-ready");
1997        let marker = root.join("descendant-survived");
1998        let mut environment = std::env::vars_os().collect::<Vec<_>>();
1999        environment.extend([
2000            ("SUPERCOV_WINDOWS_PARENT_HELPER".into(), "1".into()),
2001            ("SUPERCOV_WINDOWS_READY".into(), ready.as_os_str().into()),
2002            ("SUPERCOV_WINDOWS_MARKER".into(), marker.as_os_str().into()),
2003        ]);
2004        let spec = CommandSpec {
2005            program: std::env::current_exe().unwrap().into_os_string(),
2006            arguments: vec![
2007                "--ignored".into(),
2008                "windows_timeout_parent_helper".into(),
2009                "--nocapture".into(),
2010            ],
2011            cwd: root,
2012            environment: Some(environment),
2013            captured_output: None,
2014            console: None,
2015        };
2016        let mut diagnostics = Vec::new();
2017        let result = supervise_command(
2018            &spec,
2019            SupervisionOptions {
2020                diagnostic_interval: Duration::from_secs(60),
2021                timeout: Some(Duration::from_millis(750)),
2022                termination_grace: Duration::from_millis(50),
2023            },
2024            &mut diagnostics,
2025        )
2026        .unwrap();
2027
2028        assert!(result.timed_out);
2029        assert_eq!(result.exit_code(), COMMAND_TIMEOUT_EXIT_CODE);
2030        assert!(
2031            ready.exists(),
2032            "the helper did not prove that its descendant started before timeout"
2033        );
2034        thread::sleep(Duration::from_millis(1_700));
2035        assert!(
2036            !marker.exists(),
2037            "a descendant escaped the Windows Job Object after timeout"
2038        );
2039        assert!(
2040            String::from_utf8(diagnostics)
2041                .unwrap()
2042                .contains("terminating process group")
2043        );
2044    }
2045
2046    #[cfg(windows)]
2047    #[test]
2048    #[ignore = "subprocess helper for timeout_terminates_the_complete_windows_job"]
2049    fn windows_timeout_parent_helper() {
2050        use std::fs;
2051
2052        if std::env::var_os("SUPERCOV_WINDOWS_PARENT_HELPER").is_none() {
2053            return;
2054        }
2055        let mut child = Command::new(std::env::current_exe().unwrap())
2056            .args(["--ignored", "windows_timeout_marker_helper", "--nocapture"])
2057            .stdin(Stdio::null())
2058            .stdout(Stdio::null())
2059            .stderr(Stdio::null())
2060            .spawn()
2061            .unwrap();
2062        fs::write(
2063            std::env::var_os("SUPERCOV_WINDOWS_READY").unwrap(),
2064            child.id().to_string(),
2065        )
2066        .unwrap();
2067        child.wait().unwrap();
2068    }
2069
2070    #[cfg(windows)]
2071    #[test]
2072    #[ignore = "subprocess helper for timeout_terminates_the_complete_windows_job"]
2073    fn windows_timeout_marker_helper() {
2074        use std::fs;
2075
2076        if std::env::var_os("SUPERCOV_WINDOWS_PARENT_HELPER").is_none() {
2077            return;
2078        }
2079        thread::sleep(Duration::from_millis(1_500));
2080        fs::write(
2081            std::env::var_os("SUPERCOV_WINDOWS_MARKER").unwrap(),
2082            b"escaped",
2083        )
2084        .unwrap();
2085    }
2086
2087    #[cfg(unix)]
2088    #[test]
2089    fn diagnostic_write_failures_never_change_the_child_result() {
2090        struct BrokenWriter;
2091        impl Write for BrokenWriter {
2092            fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
2093                Err(io::Error::new(
2094                    io::ErrorKind::BrokenPipe,
2095                    "closed diagnostic stream",
2096                ))
2097            }
2098
2099            fn flush(&mut self) -> io::Result<()> {
2100                Ok(())
2101            }
2102        }
2103
2104        let spec = CommandSpec {
2105            program: "/bin/sh".into(),
2106            arguments: vec!["-c".into(), "sleep 0.05; exit 0".into()],
2107            cwd: std::env::current_dir().unwrap(),
2108            environment: None,
2109            captured_output: None,
2110            console: None,
2111        };
2112        let result = supervise_command(
2113            &spec,
2114            SupervisionOptions {
2115                diagnostic_interval: Duration::from_millis(10),
2116                timeout: None,
2117                termination_grace: Duration::from_millis(50),
2118            },
2119            &mut BrokenWriter,
2120        )
2121        .unwrap();
2122        assert_eq!(result.exit_code(), 0);
2123    }
2124}