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    /// Streams that are a terminal, each read from a terminal of its own.
186    #[cfg(unix)]
187    terminals: Vec<Terminal>,
188}
189
190/// A terminal the command writes to in place of the user's, so that what it
191/// writes can be put right on the way and the command still sees a terminal:
192/// its colours, its width and its progress lines stay as they were.
193#[cfg(unix)]
194struct Terminal {
195    /// The end this process reads what the command wrote from.
196    ours: std::fs::File,
197    /// The user's terminal, where it is passed on to.
198    real: std::fs::File,
199}
200
201#[cfg(unix)]
202impl Terminal {
203    /// A terminal of the size and settings of `real`, and the end of it the
204    /// command is given.
205    fn like(real: std::fs::File) -> Option<(Self, std::fs::File)> {
206        use std::os::fd::{AsRawFd, FromRawFd};
207        // SAFETY: both structures are plain data the calls fill in, and the
208        // descriptors `openpty` returns are owned by nothing else.
209        unsafe {
210            let mut settings: libc::termios = std::mem::zeroed();
211            let mut size: libc::winsize = std::mem::zeroed();
212            if libc::tcgetattr(real.as_raw_fd(), &mut settings) != 0
213                || libc::ioctl(real.as_raw_fd(), libc::TIOCGWINSZ, &mut size) != 0
214            {
215                return None;
216            }
217            // What the command writes is passed on byte for byte; the
218            // user's terminal ends each line as it always did.
219            settings.c_oflag &= !libc::OPOST;
220            let (mut ours, mut theirs) = (-1, -1);
221            // Raw pointers: Linux declares the last two as read only, macOS
222            // does not.
223            if libc::openpty(
224                &mut ours,
225                &mut theirs,
226                std::ptr::null_mut(),
227                &raw mut settings,
228                &raw mut size,
229            ) != 0
230            {
231                return None;
232            }
233            let ours = std::fs::File::from_raw_fd(ours);
234            let theirs = std::fs::File::from_raw_fd(theirs);
235            for end in [&ours, &theirs] {
236                // Neither end is for a process this one starts, other than
237                // as the stream `Command` makes of it.
238                libc::fcntl(end.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC);
239            }
240            Some((Self { ours, real }, theirs))
241        }
242    }
243
244    /// Gives the command's terminal the size the user's has now.
245    fn resize(&self) {
246        use std::os::fd::AsRawFd;
247        // SAFETY: `size` is plain data the first call fills in.
248        unsafe {
249            let mut size: libc::winsize = std::mem::zeroed();
250            if libc::ioctl(self.real.as_raw_fd(), libc::TIOCGWINSZ, &mut size) == 0 {
251                libc::ioctl(self.ours.as_raw_fd(), libc::TIOCSWINSZ, &size);
252            }
253        }
254    }
255
256    /// Waits for the command to write, up to `wait`.
257    fn written(&self, wait: Duration) -> bool {
258        use std::os::fd::AsRawFd;
259        let mut poll = libc::pollfd {
260            fd: self.ours.as_raw_fd(),
261            events: libc::POLLIN,
262            revents: 0,
263        };
264        // SAFETY: one descriptor this process owns.
265        unsafe { libc::poll(&mut poll, 1, wait.as_millis() as libc::c_int) > 0 }
266    }
267}
268
269/// Whether two handles are one file, pipe or device.
270#[cfg(unix)]
271fn one_place(left: &std::fs::File, right: &std::fs::File) -> bool {
272    use std::os::unix::fs::MetadataExt;
273    match (left.metadata(), right.metadata()) {
274        (Ok(left), Ok(right)) => left.dev() == right.dev() && left.ino() == right.ino(),
275        _ => false,
276    }
277}
278
279#[cfg(not(unix))]
280fn one_place(_left: &std::fs::File, _right: &std::fs::File) -> bool {
281    false
282}
283
284#[cfg(unix)]
285fn own_handle(stream: &impl std::os::fd::AsFd) -> Option<std::fs::File> {
286    stream.as_fd().try_clone_to_owned().ok().map(Into::into)
287}
288
289#[cfg(windows)]
290fn own_handle(stream: &impl std::os::windows::io::AsHandle) -> Option<std::fs::File> {
291    stream.as_handle().try_clone_to_owned().ok().map(Into::into)
292}
293
294#[cfg(not(any(unix, windows)))]
295fn own_handle<T>(_stream: &T) -> Option<std::fs::File> {
296    None
297}
298
299/// How long a stream is still read after the command has ended. It ends with
300/// the command unless a process the command left behind holds it open.
301const RELAY_GRACE: Duration = Duration::from_secs(2);
302
303impl Relays {
304    /// Ask for a pipe on each stream that is not a terminal and can be
305    /// written to by a handle of its own.
306    fn request(command: &mut Command, names: Option<&ConsoleNames>) -> RelayTargets {
307        use std::io::IsTerminal;
308        let mut targets = RelayTargets::default();
309        if names.is_none() {
310            return targets;
311        }
312        if !io::stdout().is_terminal() {
313            targets.stdout = own_handle(&io::stdout());
314        }
315        if !io::stderr().is_terminal() {
316            targets.stderr = own_handle(&io::stderr());
317        }
318        #[cfg(unix)]
319        {
320            let terminal = |is: bool, handle: Option<std::fs::File>| handle.filter(|_| is);
321            let stdout = terminal(io::stdout().is_terminal(), own_handle(&io::stdout()));
322            let stderr = terminal(io::stderr().is_terminal(), own_handle(&io::stderr()));
323            match (stdout, stderr) {
324                // One terminal for both, so the command's lines keep their order.
325                (Some(stdout), Some(stderr)) if one_place(&stdout, &stderr) => {
326                    if let Some((terminal, theirs)) = Terminal::like(stdout)
327                        && let Ok(second) = theirs.try_clone()
328                    {
329                        command.stdout(theirs).stderr(second);
330                        targets.terminals.push(terminal);
331                    }
332                }
333                (stdout, stderr) => {
334                    if let Some((terminal, theirs)) = stdout.and_then(Terminal::like) {
335                        command.stdout(theirs);
336                        targets.terminals.push(terminal);
337                    }
338                    if let Some((terminal, theirs)) = stderr.and_then(Terminal::like) {
339                        command.stderr(theirs);
340                        targets.terminals.push(terminal);
341                    }
342                }
343            }
344        }
345        if let (Some(stdout), Some(stderr)) = (&targets.stdout, &targets.stderr)
346            && one_place(stdout, stderr)
347            && let Ok((reader, writer)) = io::pipe()
348            && let Ok(second) = writer.try_clone()
349        {
350            command.stdout(writer).stderr(second);
351            targets.both = Some(reader);
352            return targets;
353        }
354        if targets.stdout.is_some() {
355            command.stdout(Stdio::piped());
356        }
357        if targets.stderr.is_some() {
358            command.stderr(Stdio::piped());
359        }
360        targets
361    }
362
363    fn start(
364        child: &mut std::process::Child,
365        targets: RelayTargets,
366        names: Option<&ConsoleNames>,
367    ) -> Self {
368        let mut relays = Vec::new();
369        let Some(names) = names else {
370            return Self(relays);
371        };
372        #[cfg(unix)]
373        for terminal in targets.terminals {
374            relays.push(relay_terminal(terminal, names.clone()));
375        }
376        if let Some(pipe) = targets.both {
377            if let Some(to) = targets.stderr.or(targets.stdout) {
378                relays.push(relay(pipe, to, names.clone()));
379            }
380            return Self(relays);
381        }
382        if let (Some(pipe), Some(to)) = (child.stdout.take(), targets.stdout) {
383            relays.push(relay(pipe, to, names.clone()));
384        }
385        if let (Some(pipe), Some(to)) = (child.stderr.take(), targets.stderr) {
386            relays.push(relay(pipe, to, names.clone()));
387        }
388        Self(relays)
389    }
390
391    fn finish(self) {
392        for ended in self.0 {
393            let _ = ended.recv_timeout(RELAY_GRACE);
394        }
395    }
396}
397
398/// A line longer than this is passed on in pieces.
399const RELAY_LINE_LIMIT: usize = 64 * 1024;
400
401fn relay(
402    mut pipe: impl Read + Send + 'static,
403    mut to: impl Write + Send + 'static,
404    names: ConsoleNames,
405) -> std::sync::mpsc::Receiver<()> {
406    let (ended, wait) = std::sync::mpsc::channel();
407    thread::spawn(move || {
408        let mut pass = |bytes: &[u8]| -> io::Result<()> {
409            match std::str::from_utf8(bytes) {
410                Ok(text) => to.write_all(names.line(text).as_bytes())?,
411                Err(_) => to.write_all(bytes)?,
412            }
413            to.flush()
414        };
415        let mut pending = Vec::new();
416        let mut buffer = [0_u8; 8192];
417        loop {
418            let read = match pipe.read(&mut buffer) {
419                Ok(0) => break,
420                Ok(read) => read,
421                Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
422                Err(_) => break,
423            };
424            pending.extend_from_slice(&buffer[..read]);
425            let mut passed = 0;
426            while let Some(end) = pending[passed..].iter().position(|byte| *byte == b'\n') {
427                // Whoever reads this has gone: the command is told so by
428                // its next write, as it would have been.
429                if pass(&pending[passed..=passed + end]).is_err() {
430                    let _ = ended.send(());
431                    return;
432                }
433                passed += end + 1;
434            }
435            pending.drain(..passed);
436            if pending.len() > RELAY_LINE_LIMIT {
437                if pass(&pending).is_err() {
438                    let _ = ended.send(());
439                    return;
440                }
441                pending.clear();
442            }
443        }
444        if !pending.is_empty() {
445            let _ = pass(&pending);
446        }
447        let _ = ended.send(());
448    });
449    wait
450}
451
452/// How long the end of a line is waited for once its beginning has come. A
453/// prompt or a progress line has no end and is passed on after this; a name
454/// is put right only in text that is passed on together, and a command
455/// writes a line at once.
456#[cfg(unix)]
457const TERMINAL_LINE_WAIT: Duration = Duration::from_millis(40);
458
459/// How often the command's terminal is given the size of the user's.
460#[cfg(unix)]
461const TERMINAL_SIZE_EVERY: Duration = Duration::from_millis(250);
462
463/// Passes on what a command writes to its terminal as it writes it: whole
464/// lines at once, and what is not yet a line after a moment.
465#[cfg(unix)]
466fn relay_terminal(mut terminal: Terminal, names: ConsoleNames) -> std::sync::mpsc::Receiver<()> {
467    let (ended, wait) = std::sync::mpsc::channel();
468    thread::spawn(move || {
469        let Ok(mut to) = terminal.real.try_clone() else {
470            let _ = ended.send(());
471            return;
472        };
473        let mut pass = |bytes: &[u8]| -> io::Result<()> {
474            match std::str::from_utf8(bytes) {
475                Ok(text) => to.write_all(names.line(text).as_bytes())?,
476                Err(_) => to.write_all(bytes)?,
477            }
478            to.flush()
479        };
480        let mut pending: Vec<u8> = Vec::new();
481        let mut buffer = [0_u8; 8192];
482        let mut sized = Instant::now();
483        loop {
484            let wait = if pending.is_empty() {
485                TERMINAL_SIZE_EVERY
486            } else {
487                TERMINAL_LINE_WAIT
488            };
489            if !terminal.written(wait) {
490                if !pending.is_empty() {
491                    if pass(&pending).is_err() {
492                        break;
493                    }
494                    pending.clear();
495                }
496                if sized.elapsed() >= TERMINAL_SIZE_EVERY {
497                    terminal.resize();
498                    sized = Instant::now();
499                }
500                continue;
501            }
502            // The last process holding the other end has gone: a read of
503            // nothing on macOS, an error on Linux.
504            let read = match terminal.ours.read(&mut buffer) {
505                Ok(0) => break,
506                Ok(read) => read,
507                Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
508                Err(_) => break,
509            };
510            pending.extend_from_slice(&buffer[..read]);
511            if let Some(end) = pending.iter().rposition(|byte| *byte == b'\n') {
512                if pass(&pending[..=end]).is_err() {
513                    break;
514                }
515                pending.drain(..=end);
516            }
517            if pending.len() > RELAY_LINE_LIMIT {
518                if pass(&pending).is_err() {
519                    break;
520                }
521                pending.clear();
522            }
523        }
524        if !pending.is_empty() {
525            let _ = pass(&pending);
526        }
527        let _ = ended.send(());
528    });
529    wait
530}
531
532impl CommandSpec {
533    pub fn command(&self) -> Result<Command, SupervisionError> {
534        if self.program.is_empty() {
535            return Err(SupervisionError::EmptyCommand);
536        }
537        let mut command = Command::new(self.resolved_program());
538        command
539            .args(&self.arguments)
540            .current_dir(&self.cwd)
541            .stdin(Stdio::inherit());
542        if let Some(path) = &self.captured_output {
543            let output = OpenOptions::new()
544                .write(true)
545                .create_new(true)
546                .open(path)
547                .map_err(|source| SupervisionError::PlatformOperation {
548                    operation: "create captured process output",
549                    source,
550                })?;
551            let errors =
552                output
553                    .try_clone()
554                    .map_err(|source| SupervisionError::PlatformOperation {
555                        operation: "clone captured process output",
556                        source,
557                    })?;
558            command
559                .stdout(Stdio::from(output))
560                .stderr(Stdio::from(errors));
561        } else {
562            command.stdout(Stdio::inherit()).stderr(Stdio::inherit());
563        }
564        if let Some(environment) = &self.environment {
565            command.env_clear().envs(environment.iter().cloned());
566        }
567        #[cfg(windows)]
568        {
569            use std::os::windows::process::CommandExt;
570            use windows_sys::Win32::System::Threading::{
571                CREATE_NEW_PROCESS_GROUP, CREATE_SUSPENDED,
572            };
573            command.creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_SUSPENDED);
574        }
575        Ok(command)
576    }
577}
578
579impl CommandSpec {
580    /// Windows finds `foo.exe` for a bare `foo` and nothing else, while a
581    /// gem's or npm's executable there is a `.bat` or `.cmd` shim: `rspec`
582    /// failed with "program not found" with `rspec.bat` sitting on PATH.
583    /// Resolve the program the way the shell does -- every PATH entry, every
584    /// PATHEXT extension -- so the standard library sees the extension and
585    /// runs a batch file through cmd.exe with its own quoting.
586    #[cfg(windows)]
587    fn resolved_program(&self) -> OsString {
588        // Variable names are case-insensitive there, and PATH is usually
589        // spelled `Path`.
590        let variable = |name: &str| match &self.environment {
591            Some(environment) => environment
592                .iter()
593                .find(|(key, _)| key.eq_ignore_ascii_case(name))
594                .map(|(_, value)| value.clone()),
595            None => std::env::var_os(name),
596        };
597        resolve_program_with(
598            &self.program,
599            &self.cwd,
600            variable("PATH"),
601            variable("PATHEXT"),
602        )
603    }
604
605    #[cfg(not(windows))]
606    fn resolved_program(&self) -> OsString {
607        self.program.clone()
608    }
609}
610
611/// The host-independent half of program resolution, so it is tested on every
612/// host. A bare name is looked for in each PATH directory, a name with a
613/// separator relative to the working directory; in either place a candidate
614/// carrying a PATHEXT extension wins over the bare file, as in cmd.exe,
615/// because an extensionless script cannot be started on Windows even when it
616/// exists. A program nothing matches is returned unchanged so the operating
617/// system reports the failure in its own words.
618#[cfg_attr(not(windows), allow(dead_code))]
619fn resolve_program_with(
620    program: &OsStr,
621    cwd: &Path,
622    path: Option<OsString>,
623    pathext: Option<OsString>,
624) -> OsString {
625    const DEFAULT_PATHEXT: &str = ".COM;.EXE;.BAT;.CMD";
626    let pathext = pathext
627        .filter(|value| !value.is_empty())
628        .unwrap_or_else(|| DEFAULT_PATHEXT.into());
629    let extensions = pathext
630        .to_string_lossy()
631        .split(';')
632        .filter(|extension| !extension.is_empty())
633        .map(str::to_owned)
634        .collect::<Vec<_>>();
635    let directories = if program.to_string_lossy().contains(['\\', '/']) {
636        vec![cwd.to_path_buf()]
637    } else {
638        path.map(|path| std::env::split_paths(&path).collect())
639            .unwrap_or_default()
640    };
641    let is_file = |candidate: &Path| std::fs::metadata(candidate).is_ok_and(|meta| meta.is_file());
642    for directory in directories {
643        let base = directory.join(program);
644        for extension in &extensions {
645            let mut candidate = base.clone().into_os_string();
646            candidate.push(extension);
647            if is_file(Path::new(&candidate)) {
648                return candidate;
649            }
650        }
651        if base.extension().is_some() && is_file(&base) {
652            return base.into_os_string();
653        }
654    }
655    program.to_owned()
656}
657
658#[derive(Debug, Clone, Copy, PartialEq, Eq)]
659pub struct SupervisionOptions {
660    pub diagnostic_interval: Duration,
661    pub timeout: Option<Duration>,
662    pub termination_grace: Duration,
663}
664
665impl Default for SupervisionOptions {
666    fn default() -> Self {
667        Self {
668            diagnostic_interval: Duration::from_millis(DEFAULT_DIAGNOSTIC_INTERVAL_MS),
669            timeout: None,
670            termination_grace: Duration::from_millis(COMMAND_TERMINATION_GRACE_MS),
671        }
672    }
673}
674
675impl SupervisionOptions {
676    pub fn from_environment() -> Result<Self, SupervisionError> {
677        Ok(Self {
678            diagnostic_interval: positive_milliseconds(
679                std::env::var("SUPERCOV_DIAGNOSTIC_INTERVAL_MS")
680                    .ok()
681                    .as_deref(),
682                "SUPERCOV_DIAGNOSTIC_INTERVAL_MS",
683            )?
684            .unwrap_or_else(|| Duration::from_millis(DEFAULT_DIAGNOSTIC_INTERVAL_MS)),
685            timeout: positive_milliseconds(
686                std::env::var("SUPERCOV_COMMAND_TIMEOUT_MS").ok().as_deref(),
687                "SUPERCOV_COMMAND_TIMEOUT_MS",
688            )?,
689            termination_grace: Duration::from_millis(COMMAND_TERMINATION_GRACE_MS),
690        })
691    }
692}
693
694#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
695#[serde(rename_all = "camelCase")]
696pub struct ProcessSnapshot {
697    pub pid: u32,
698    pub parent_pid: u32,
699    #[serde(skip_serializing_if = "Option::is_none")]
700    pub state: Option<String>,
701    #[serde(skip_serializing_if = "Option::is_none")]
702    pub cpu_tenths: Option<u64>,
703    pub executable: String,
704}
705
706#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
707#[serde(rename_all = "UPPERCASE")]
708pub enum ForwardedSignal {
709    Sighup,
710    Sigint,
711    Sigterm,
712}
713
714impl ForwardedSignal {
715    pub fn exit_code(self) -> i32 {
716        match self {
717            Self::Sighup => 129,
718            Self::Sigint => 130,
719            Self::Sigterm => 143,
720        }
721    }
722
723    #[cfg(unix)]
724    fn raw(self) -> i32 {
725        match self {
726            Self::Sighup => libc::SIGHUP,
727            Self::Sigint => libc::SIGINT,
728            Self::Sigterm => libc::SIGTERM,
729        }
730    }
731}
732
733#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
734#[serde(rename_all = "camelCase")]
735pub struct SupervisedResult {
736    pub status: Option<i32>,
737    pub signal: Option<i32>,
738    pub timed_out: bool,
739    pub interrupted_signal: Option<ForwardedSignal>,
740}
741
742#[derive(Debug)]
743pub struct SupervisedOutput {
744    pub result: SupervisedResult,
745    pub stdout: Vec<u8>,
746    pub stderr: Vec<u8>,
747}
748
749impl SupervisedResult {
750    pub fn exit_code(&self) -> i32 {
751        if self.timed_out {
752            COMMAND_TIMEOUT_EXIT_CODE
753        } else if let Some(signal) = self.interrupted_signal {
754            signal.exit_code()
755        } else {
756            self.status.unwrap_or(128)
757        }
758    }
759}
760
761pub fn positive_milliseconds(
762    value: Option<&str>,
763    name: &str,
764) -> Result<Option<Duration>, SupervisionError> {
765    let Some(value) = value.filter(|value| !value.is_empty()) else {
766        return Ok(None);
767    };
768    let milliseconds = value
769        .parse::<u64>()
770        .ok()
771        .filter(|milliseconds| *milliseconds > 0)
772        .ok_or_else(|| SupervisionError::InvalidMilliseconds { name: name.into() })?;
773    Ok(Some(Duration::from_millis(milliseconds)))
774}
775
776fn process_inventory() -> Vec<ProcessSnapshot> {
777    use sysinfo::{ProcessRefreshKind, RefreshKind, System, UpdateKind};
778
779    let system = System::new_with_specifics(
780        RefreshKind::nothing().with_processes(
781            ProcessRefreshKind::nothing()
782                .with_cpu()
783                .with_exe(UpdateKind::OnlyIfNotSet),
784        ),
785    );
786    system
787        .processes()
788        .iter()
789        // Linux lists a process's threads beside it as tasks of their own;
790        // a thread keeps no command running that its process does not.
791        .filter(|(_, process)| process.thread_kind().is_none())
792        .map(|(pid, process)| ProcessSnapshot {
793            pid: pid.as_u32(),
794            parent_pid: process.parent().map_or(0, sysinfo::Pid::as_u32),
795            state: Some(process_status(process.status()).into()),
796            cpu_tenths: Some(process.accumulated_cpu_time() / 100),
797            // Named by what it runs: a process's own name is its main
798            // thread's, which Node renames `MainThread` on Linux.
799            executable: process
800                .exe()
801                .and_then(Path::file_name)
802                .or_else(|| Path::new(process.name()).file_name())
803                .and_then(|value| value.to_str())
804                .unwrap_or("unknown")
805                .to_owned(),
806        })
807        .collect()
808}
809
810fn process_status(status: sysinfo::ProcessStatus) -> &'static str {
811    use sysinfo::ProcessStatus;
812    match status {
813        ProcessStatus::Idle => "I",
814        ProcessStatus::Run => "R",
815        ProcessStatus::Sleep => "S",
816        ProcessStatus::Stop => "T",
817        ProcessStatus::Zombie => "Z",
818        ProcessStatus::Tracing => "t",
819        ProcessStatus::Dead => "X",
820        ProcessStatus::Wakekill => "K",
821        ProcessStatus::Waking => "W",
822        ProcessStatus::Parked => "P",
823        ProcessStatus::LockBlocked => "L",
824        ProcessStatus::UninterruptibleDiskSleep => "D",
825        ProcessStatus::Suspended => "S",
826        ProcessStatus::Unknown(_) => "?",
827    }
828}
829
830pub fn descendant_process_tree(root_pid: u32) -> Vec<ProcessSnapshot> {
831    let inventory = process_inventory();
832    let mut descendants = std::collections::BTreeSet::from([root_pid]);
833    loop {
834        let before = descendants.len();
835        for process in &inventory {
836            if descendants.contains(&process.parent_pid) {
837                descendants.insert(process.pid);
838            }
839        }
840        if descendants.len() == before {
841            break;
842        }
843    }
844    let mut result = inventory
845        .into_iter()
846        .filter(|process| descendants.contains(&process.pid))
847        .collect::<Vec<_>>();
848    result.sort_by_key(|process| process.pid);
849    result
850}
851
852fn format_duration(milliseconds: u128) -> String {
853    if milliseconds < 1_000 {
854        return format!("{milliseconds}ms");
855    }
856    let seconds = (milliseconds + 500) / 1_000;
857    if seconds < 60 {
858        return format!("{seconds}s");
859    }
860    format!("{}m{:02}s", seconds / 60, seconds % 60)
861}
862
863pub fn format_process_diagnostic(
864    root_pid: u32,
865    elapsed: Duration,
866    tree: &[ProcessSnapshot],
867) -> String {
868    let mut output = format!(
869        "[supercov] command still running after {}",
870        format_duration(elapsed.as_millis())
871    );
872    if tree.is_empty() {
873        output.push_str(&format!("\n  pid={root_pid} process details unavailable"));
874        return output;
875    }
876    for process in tree {
877        output.push_str(&format!(
878            "\n  pid={} ppid={} exe={}",
879            process.pid, process.parent_pid, process.executable
880        ));
881        if let Some(state) = &process.state {
882            output.push_str(&format!(" state={state}"));
883        }
884        if let Some(cpu_tenths) = process.cpu_tenths {
885            output.push_str(&format!(" cpu={}.{}s", cpu_tenths / 10, cpu_tenths % 10));
886        }
887    }
888    output
889}
890
891#[cfg(unix)]
892struct SignalFlags {
893    _exclusive: MutexGuard<'static, ()>,
894    previous: Vec<(i32, libc::sigaction)>,
895}
896
897#[cfg(unix)]
898impl SignalFlags {
899    fn install() -> Result<Self, SupervisionError> {
900        let exclusive = SIGNAL_HANDLER_LOCK
901            .lock()
902            .unwrap_or_else(std::sync::PoisonError::into_inner);
903        RECEIVED_SIGNAL.store(0, Ordering::SeqCst);
904        let mut previous = Vec::new();
905        for signal in [libc::SIGHUP, libc::SIGINT, libc::SIGTERM] {
906            // SAFETY: zero is a valid initial state for `sigaction`; every
907            // field used by the kernel is initialized below before the call.
908            let mut action = unsafe { std::mem::zeroed::<libc::sigaction>() };
909            action.sa_sigaction = record_signal as *const () as usize;
910            // SAFETY: `action.sa_mask` is a valid, writable signal set.
911            unsafe { libc::sigemptyset(&mut action.sa_mask) };
912            action.sa_flags = 0;
913            // SAFETY: `old` is initialized by a successful `sigaction` call.
914            let mut old = unsafe { std::mem::zeroed::<libc::sigaction>() };
915            // SAFETY: pointers reference live `sigaction` values and the
916            // signal is one of the three catchable POSIX signals above.
917            if unsafe { libc::sigaction(signal, &action, &mut old) } != 0 {
918                for (installed, old) in previous.iter().rev() {
919                    // SAFETY: restores a handler returned by `sigaction`.
920                    let _ = unsafe { libc::sigaction(*installed, old, std::ptr::null_mut()) };
921                }
922                return Err(SupervisionError::Signal(io::Error::last_os_error()));
923            }
924            previous.push((signal, old));
925        }
926        Ok(Self {
927            _exclusive: exclusive,
928            previous,
929        })
930    }
931
932    fn received(&self) -> Option<ForwardedSignal> {
933        // A supervisor is shared by every concurrently running child in one
934        // execution session. Keep the signal visible until the session guard
935        // is dropped so every process group observes the same interruption.
936        match RECEIVED_SIGNAL.load(Ordering::SeqCst) {
937            libc::SIGHUP => Some(ForwardedSignal::Sighup),
938            libc::SIGINT => Some(ForwardedSignal::Sigint),
939            libc::SIGTERM => Some(ForwardedSignal::Sigterm),
940            _ => None,
941        }
942    }
943}
944
945#[cfg(unix)]
946impl Drop for SignalFlags {
947    fn drop(&mut self) {
948        for (signal, previous) in self.previous.drain(..).rev() {
949            // SAFETY: `previous` came directly from a successful `sigaction`
950            // call for the same signal and remains live for this call.
951            let _ = unsafe { libc::sigaction(signal, &previous, std::ptr::null_mut()) };
952        }
953        RECEIVED_SIGNAL.store(0, Ordering::SeqCst);
954    }
955}
956
957#[cfg(unix)]
958static SIGNAL_HANDLER_LOCK: Mutex<()> = Mutex::new(());
959#[cfg(unix)]
960static RECEIVED_SIGNAL: AtomicI32 = AtomicI32::new(0);
961
962#[cfg(unix)]
963extern "C" fn record_signal(signal: i32) {
964    RECEIVED_SIGNAL.store(signal, Ordering::SeqCst);
965}
966
967#[cfg(windows)]
968struct SignalFlags {
969    _exclusive: MutexGuard<'static, ()>,
970}
971
972#[cfg(windows)]
973impl SignalFlags {
974    fn install() -> Result<Self, SupervisionError> {
975        use windows_sys::Win32::System::Console::SetConsoleCtrlHandler;
976
977        let exclusive = SIGNAL_HANDLER_LOCK
978            .lock()
979            .unwrap_or_else(std::sync::PoisonError::into_inner);
980        RECEIVED_SIGNAL.store(0, Ordering::SeqCst);
981        // SAFETY: `record_console_signal` has the required system ABI and
982        // remains installed only while this guard is alive.
983        if unsafe { SetConsoleCtrlHandler(Some(record_console_signal), 1) } == 0 {
984            return Err(SupervisionError::Signal(io::Error::last_os_error()));
985        }
986        Ok(Self {
987            _exclusive: exclusive,
988        })
989    }
990
991    fn received(&self) -> Option<ForwardedSignal> {
992        match RECEIVED_SIGNAL.load(Ordering::SeqCst) {
993            2 => Some(ForwardedSignal::Sigint),
994            15 => Some(ForwardedSignal::Sigterm),
995            _ => None,
996        }
997    }
998}
999
1000#[cfg(windows)]
1001impl Drop for SignalFlags {
1002    fn drop(&mut self) {
1003        use windows_sys::Win32::System::Console::SetConsoleCtrlHandler;
1004
1005        // SAFETY: removes exactly the handler installed by `install`.
1006        let _ = unsafe { SetConsoleCtrlHandler(Some(record_console_signal), 0) };
1007        RECEIVED_SIGNAL.store(0, Ordering::SeqCst);
1008    }
1009}
1010
1011#[cfg(windows)]
1012static SIGNAL_HANDLER_LOCK: Mutex<()> = Mutex::new(());
1013#[cfg(windows)]
1014static RECEIVED_SIGNAL: AtomicI32 = AtomicI32::new(0);
1015
1016#[cfg(windows)]
1017unsafe extern "system" fn record_console_signal(control: u32) -> i32 {
1018    use windows_sys::Win32::System::Console::{
1019        CTRL_BREAK_EVENT, CTRL_C_EVENT, CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT,
1020    };
1021
1022    match control {
1023        CTRL_C_EVENT | CTRL_BREAK_EVENT => {
1024            RECEIVED_SIGNAL.store(2, Ordering::SeqCst);
1025            1
1026        }
1027        CTRL_CLOSE_EVENT | CTRL_LOGOFF_EVENT | CTRL_SHUTDOWN_EVENT => {
1028            RECEIVED_SIGNAL.store(15, Ordering::SeqCst);
1029            1
1030        }
1031        _ => 0,
1032    }
1033}
1034
1035#[cfg(windows)]
1036pub(crate) struct JobHandle(windows_sys::Win32::Foundation::HANDLE);
1037
1038// A job-object handle is a process-wide kernel token, not a pointer into this
1039// thread's memory: assigning a process to it, terminating it and closing it are
1040// all safe from any thread, which is the same invariant std's OwnedHandle
1041// carries by being Send and Sync. Without these the raw HANDLE makes the whole
1042// supervisor !Sync on Windows, and the Rust test runner, which shares one
1043// supervisor across scoped threads, does not compile there -- the first
1044// Windows build found exactly that.
1045#[cfg(windows)]
1046unsafe impl Send for JobHandle {}
1047#[cfg(windows)]
1048unsafe impl Sync for JobHandle {}
1049
1050#[cfg(windows)]
1051impl JobHandle {
1052    pub(crate) fn new() -> Result<Self, SupervisionError> {
1053        use windows_sys::Win32::System::JobObjects::{
1054            CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
1055            JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
1056            SetInformationJobObject,
1057        };
1058
1059        // SAFETY: null security attributes and name create one private job.
1060        let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
1061        if handle.is_null() {
1062            return Err(SupervisionError::PlatformOperation {
1063                operation: "create a Windows Job Object",
1064                source: io::Error::last_os_error(),
1065            });
1066        }
1067        let job = Self(handle);
1068        let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
1069        limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
1070        // SAFETY: the buffer is a live value of the exact information class
1071        // and length requested by SetInformationJobObject.
1072        if unsafe {
1073            SetInformationJobObject(
1074                job.0,
1075                JobObjectExtendedLimitInformation,
1076                (&raw const limits).cast(),
1077                std::mem::size_of_val(&limits) as u32,
1078            )
1079        } == 0
1080        {
1081            return Err(SupervisionError::PlatformOperation {
1082                operation: "configure Windows Job Object containment",
1083                source: io::Error::last_os_error(),
1084            });
1085        }
1086        Ok(job)
1087    }
1088
1089    pub(crate) fn assign(&self, child: &Child) -> Result<(), SupervisionError> {
1090        use std::os::windows::io::AsRawHandle;
1091        use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject;
1092
1093        // SAFETY: Child owns a live process handle with the rights granted by
1094        // CreateProcess; the job handle stays live for the complete plan.
1095        if unsafe { AssignProcessToJobObject(self.0, child.as_raw_handle().cast()) } == 0 {
1096            return Err(SupervisionError::PlatformOperation {
1097                operation: "assign the suspended command to its Windows Job Object",
1098                source: io::Error::last_os_error(),
1099            });
1100        }
1101        Ok(())
1102    }
1103
1104    fn terminate(&self) {
1105        use windows_sys::Win32::System::JobObjects::TerminateJobObject;
1106        // SAFETY: the handle owns this invocation's process tree. Failure can
1107        // only mean the tree has already exited, so termination is best effort.
1108        let _ = unsafe { TerminateJobObject(self.0, 1) };
1109    }
1110}
1111
1112#[cfg(windows)]
1113impl Drop for JobHandle {
1114    fn drop(&mut self) {
1115        use windows_sys::Win32::Foundation::CloseHandle;
1116        // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE makes this the final, crash-safe
1117        // containment boundary for descendants that outlive their root.
1118        let _ = unsafe { CloseHandle(self.0) };
1119    }
1120}
1121
1122#[cfg(windows)]
1123fn resume_suspended_process(pid: u32) -> Result<(), SupervisionError> {
1124    use windows_sys::Win32::{
1125        Foundation::{CloseHandle, INVALID_HANDLE_VALUE},
1126        System::{
1127            Diagnostics::ToolHelp::{
1128                CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First,
1129                Thread32Next,
1130            },
1131            Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME},
1132        },
1133    };
1134
1135    // The stdlib exposes the process handle but not CreateProcess's primary
1136    // thread handle. Starting suspended, assigning the job, then resuming the
1137    // process-owned thread from a ToolHelp snapshot closes the escape race.
1138    let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
1139    if snapshot == INVALID_HANDLE_VALUE {
1140        return Err(SupervisionError::PlatformOperation {
1141            operation: "enumerate the suspended command threads",
1142            source: io::Error::last_os_error(),
1143        });
1144    }
1145    struct Snapshot(windows_sys::Win32::Foundation::HANDLE);
1146    impl Drop for Snapshot {
1147        fn drop(&mut self) {
1148            let _ = unsafe { CloseHandle(self.0) };
1149        }
1150    }
1151    let _snapshot = Snapshot(snapshot);
1152    let mut entry = THREADENTRY32 {
1153        dwSize: std::mem::size_of::<THREADENTRY32>() as u32,
1154        ..Default::default()
1155    };
1156    if unsafe { Thread32First(snapshot, &raw mut entry) } == 0 {
1157        return Err(SupervisionError::PlatformOperation {
1158            operation: "read the suspended command thread snapshot",
1159            source: io::Error::last_os_error(),
1160        });
1161    }
1162    loop {
1163        if entry.th32OwnerProcessID == pid {
1164            let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
1165            if thread.is_null() {
1166                return Err(SupervisionError::PlatformOperation {
1167                    operation: "open the suspended command's primary thread",
1168                    source: io::Error::last_os_error(),
1169                });
1170            }
1171            // SAFETY: the handle identifies a suspended thread owned by the
1172            // just-created process and is closed immediately after resuming.
1173            let resumed = unsafe { ResumeThread(thread) };
1174            let resume_error = (resumed == u32::MAX).then(io::Error::last_os_error);
1175            let _ = unsafe { CloseHandle(thread) };
1176            if let Some(source) = resume_error {
1177                return Err(SupervisionError::PlatformOperation {
1178                    operation: "resume the contained command",
1179                    source,
1180                });
1181            }
1182            return Ok(());
1183        }
1184        entry.dwSize = std::mem::size_of::<THREADENTRY32>() as u32;
1185        if unsafe { Thread32Next(snapshot, &raw mut entry) } == 0 {
1186            break;
1187        }
1188    }
1189    Err(SupervisionError::PlatformOperation {
1190        operation: "locate the suspended command's primary thread",
1191        source: io::Error::new(io::ErrorKind::NotFound, "process thread was absent"),
1192    })
1193}
1194
1195#[cfg(windows)]
1196fn forward_windows_control(child: &Child) {
1197    use windows_sys::Win32::System::Console::{CTRL_BREAK_EVENT, GenerateConsoleCtrlEvent};
1198    // CREATE_NEW_PROCESS_GROUP makes the child's PID its console group ID.
1199    // Some non-console commands reject the event; the grace-period Job Object
1200    // termination remains authoritative in that case.
1201    let _ = unsafe { GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, child.id()) };
1202}
1203
1204/// The write end is held only by the supervising process. A tiny watchdog
1205/// created in the command's pre-exec child blocks on the read end. Normal
1206/// completion, an unwind, or uncatchable supervisor death all close this
1207/// descriptor and make the watchdog kill the command's complete process group.
1208#[cfg(unix)]
1209struct ParentDeathGuard {
1210    _writer: OwnedFd,
1211}
1212
1213#[cfg(unix)]
1214fn parent_death_pipe() -> Result<(OwnedFd, OwnedFd), SupervisionError> {
1215    let mut descriptors = [-1_i32; 2];
1216    // SAFETY: `descriptors` is a live two-element output buffer for pipe(2).
1217    if unsafe { libc::pipe(descriptors.as_mut_ptr()) } != 0 {
1218        return Err(SupervisionError::PlatformOperation {
1219            operation: "create parent-death supervision pipe",
1220            source: io::Error::last_os_error(),
1221        });
1222    }
1223    // SAFETY: pipe(2) returned two newly owned descriptors.
1224    let read = unsafe { OwnedFd::from_raw_fd(descriptors[0]) };
1225    // SAFETY: same as above for the write end.
1226    let write = unsafe { OwnedFd::from_raw_fd(descriptors[1]) };
1227    for descriptor in [read.as_raw_fd(), write.as_raw_fd()] {
1228        // SAFETY: the descriptor is live and F_SETFD accepts FD_CLOEXEC.
1229        if unsafe { libc::fcntl(descriptor, libc::F_SETFD, libc::FD_CLOEXEC) } != 0 {
1230            return Err(SupervisionError::PlatformOperation {
1231                operation: "protect parent-death supervision pipe across exec",
1232                source: io::Error::last_os_error(),
1233            });
1234        }
1235    }
1236    Ok((read, write))
1237}
1238
1239#[cfg(unix)]
1240fn spawn_contained(
1241    command: &mut Command,
1242    program: &OsString,
1243    watchdog_program: Option<&Path>,
1244) -> Result<(Child, Option<ParentDeathGuard>), SupervisionError> {
1245    let Some(watchdog_program) = watchdog_program else {
1246        command.process_group(0);
1247        let child = command.spawn().map_err(|source| SupervisionError::Spawn {
1248            program: program.clone(),
1249            source,
1250        })?;
1251        return Ok((child, None));
1252    };
1253    let (read, write) = parent_death_pipe()?;
1254    let (ready_read, ready_write) = parent_death_pipe()?;
1255    let read_descriptor = read.as_raw_fd();
1256    let write_descriptor = write.as_raw_fd();
1257    let ready_read_descriptor = ready_read.as_raw_fd();
1258    let ready_write_descriptor = ready_write.as_raw_fd();
1259    let watchdog_program = std::ffi::CString::new(watchdog_program.as_os_str().as_bytes())
1260        .map_err(|_| SupervisionError::PlatformOperation {
1261            operation: "encode parent-death watchdog executable",
1262            source: io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"),
1263        })?;
1264    let watchdog_argument =
1265        std::ffi::CString::new("__watch-process-group").expect("static CString");
1266    // SAFETY: this closure calls only async-signal-safe syscalls between fork
1267    // and exec. The forked watchdog immediately execs the already-loaded
1268    // Supercov binary; it never returns to Rust or touches an inherited lock.
1269    unsafe {
1270        command.pre_exec(move || {
1271            if libc::setpgid(0, 0) != 0 {
1272                return Err(io::Error::last_os_error());
1273            }
1274            let _ = libc::close(write_descriptor);
1275            let watchdog = libc::fork();
1276            if watchdog < 0 {
1277                return Err(io::Error::last_os_error());
1278            }
1279            if watchdog == 0 {
1280                let _ = libc::close(ready_read_descriptor);
1281                if libc::dup2(read_descriptor, 0) < 0 || libc::dup2(ready_write_descriptor, 3) < 0 {
1282                    libc::_exit(125);
1283                }
1284                for descriptor in [read_descriptor, ready_write_descriptor, 1, 2] {
1285                    if descriptor != 0 && descriptor != 3 {
1286                        let _ = libc::close(descriptor);
1287                    }
1288                }
1289                let arguments = [
1290                    watchdog_program.as_ptr(),
1291                    watchdog_argument.as_ptr(),
1292                    std::ptr::null(),
1293                ];
1294                libc::execv(watchdog_program.as_ptr(), arguments.as_ptr());
1295                libc::_exit(125);
1296            }
1297            let _ = libc::close(read_descriptor);
1298            let _ = libc::close(ready_write_descriptor);
1299            let mut ready = 0_u8;
1300            loop {
1301                let received = libc::read(ready_read_descriptor, (&raw mut ready).cast(), 1);
1302                if received == 1 && ready == 1 {
1303                    break;
1304                }
1305                if received < 0 && io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
1306                    continue;
1307                }
1308                return Err(io::Error::other(
1309                    "parent-death watchdog failed before command exec",
1310                ));
1311            }
1312            let _ = libc::close(ready_read_descriptor);
1313            Ok(())
1314        });
1315    }
1316    let child = command.spawn().map_err(|source| SupervisionError::Spawn {
1317        program: program.clone(),
1318        source,
1319    })?;
1320    drop(read);
1321    drop(ready_read);
1322    drop(ready_write);
1323    Ok((child, Some(ParentDeathGuard { _writer: write })))
1324}
1325
1326#[cfg(unix)]
1327pub fn watch_parent_process_group() -> io::Result<()> {
1328    // The target waits for our readiness byte, so its PID remains both our
1329    // parent PID and the process-group ID until containment is armed.
1330    let process_group = unsafe { libc::getppid() };
1331    if process_group <= 1 {
1332        return Err(io::Error::other(
1333            "parent-death watchdog has no target process",
1334        ));
1335    }
1336    // SAFETY: the watchdog is a non-leader child in the target's process group.
1337    if unsafe { libc::setsid() } < 0 {
1338        return Err(io::Error::last_os_error());
1339    }
1340    let descriptor_root = if Path::new("/proc/self/fd").is_dir() {
1341        Path::new("/proc/self/fd")
1342    } else {
1343        Path::new("/dev/fd")
1344    };
1345    let descriptors = fs::read_dir(descriptor_root)?
1346        .filter_map(Result::ok)
1347        .filter_map(|entry| entry.file_name().to_string_lossy().parse::<i32>().ok())
1348        .filter(|descriptor| !matches!(*descriptor, 0 | 3))
1349        .collect::<Vec<_>>();
1350    for descriptor in descriptors {
1351        // SAFETY: closing a descriptor that the directory iterator already
1352        // released, or one concurrently absent, is harmless.
1353        let _ = unsafe { libc::close(descriptor) };
1354    }
1355    let ready = [1_u8];
1356    // SAFETY: pre-exec mapped the private readiness pipe to descriptor 3.
1357    if unsafe { libc::write(3, ready.as_ptr().cast(), ready.len()) } != 1 {
1358        return Err(io::Error::last_os_error());
1359    }
1360    let _ = unsafe { libc::close(3) };
1361    let mut buffer = [0_u8; 1];
1362    loop {
1363        // The supervisor never writes. EOF means normal supervisor teardown,
1364        // unwind, or uncatchable process death.
1365        let read = unsafe { libc::read(0, buffer.as_mut_ptr().cast(), buffer.len()) };
1366        if read == 0 {
1367            break;
1368        }
1369        if read < 0 {
1370            if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
1371                continue;
1372            }
1373            break;
1374        }
1375    }
1376    let _ = unsafe { libc::kill(-process_group, libc::SIGKILL) };
1377    Ok(())
1378}
1379
1380#[cfg(not(unix))]
1381pub fn watch_parent_process_group() -> io::Result<()> {
1382    Err(io::Error::new(
1383        io::ErrorKind::Unsupported,
1384        "the POSIX parent-death watchdog is unavailable",
1385    ))
1386}
1387
1388/// The terminal, lent to a command that reads the keyboard.
1389///
1390/// A command runs in a process group of its own, so that all of it can be
1391/// ended, and the terminal belongs to Supercov's group: a read of the
1392/// keyboard (a debugger at a breakpoint, a watch mode, a question) stops the
1393/// command where it stands, as it stops a job a shell put in the background.
1394/// When that happens the command's group is given the terminal and
1395/// continued. Until then Ctrl-C reaches Supercov as it always has; after, it
1396/// reaches the command, which is what someone at a debugger means by it.
1397#[cfg(unix)]
1398#[derive(Default)]
1399struct Keyboard {
1400    terminal: Option<fs::File>,
1401    lent: bool,
1402}
1403
1404#[cfg(unix)]
1405impl Keyboard {
1406    /// The signal the command stopped on, if it has stopped since last asked.
1407    fn stopped(child: &Child) -> Option<i32> {
1408        // SAFETY: `info` is plain data the call fills in. Asking for stops
1409        // alone leaves the command's exit for `try_wait` to collect.
1410        unsafe {
1411            let mut info: libc::siginfo_t = std::mem::zeroed();
1412            if libc::waitid(
1413                libc::P_PID,
1414                child.id() as libc::id_t,
1415                &mut info,
1416                libc::WSTOPPED | libc::WNOHANG,
1417            ) != 0
1418                || info.si_code != libc::CLD_STOPPED
1419            {
1420                return None;
1421            }
1422            Some(info.si_status())
1423        }
1424    }
1425
1426    /// Makes `group` the one the terminal's keys and signals go to. False
1427    /// when there is no terminal or it is not this process's to give.
1428    fn give(&mut self, group: libc::pid_t, ours_to_give: bool) -> bool {
1429        use std::os::fd::AsRawFd;
1430        if self.terminal.is_none() {
1431            self.terminal = fs::OpenOptions::new()
1432                .read(true)
1433                .write(true)
1434                .open("/dev/tty")
1435                .ok();
1436        }
1437        let Some(terminal) = &self.terminal else {
1438            return false;
1439        };
1440        // SAFETY: plain calls on a descriptor this process owns. Setting the
1441        // group from one that is not the terminal's stops the caller unless
1442        // the signal that would do it is held back for the moment.
1443        unsafe {
1444            if ours_to_give && libc::tcgetpgrp(terminal.as_raw_fd()) != libc::getpgrp() {
1445                return false;
1446            }
1447            let mut held: libc::sigset_t = std::mem::zeroed();
1448            let mut before: libc::sigset_t = std::mem::zeroed();
1449            libc::sigemptyset(&mut held);
1450            libc::sigaddset(&mut held, libc::SIGTTOU);
1451            libc::pthread_sigmask(libc::SIG_BLOCK, &held, &mut before);
1452            let given = libc::tcsetpgrp(terminal.as_raw_fd(), group) == 0;
1453            libc::pthread_sigmask(libc::SIG_SETMASK, &before, std::ptr::null_mut());
1454            given
1455        }
1456    }
1457
1458    /// The command stopped on `signal`. Returns whether it was continued.
1459    fn command_stopped(&mut self, child: &mut Child, signal: i32) -> bool {
1460        let group = child.id() as libc::pid_t;
1461        match signal {
1462            libc::SIGTTIN | libc::SIGTTOU if !self.lent => {
1463                if !self.give(group, true) {
1464                    // Supercov is itself in the background: the command
1465                    // waits for the terminal as Supercov would.
1466                    return false;
1467                }
1468                self.lent = true;
1469            }
1470            // Ctrl-Z, with the terminal lent: it stopped the command and
1471            // not Supercov, and the shell is waiting on Supercov. Stop too,
1472            // and lend the terminal again when the shell continues us.
1473            libc::SIGTSTP if self.lent => {
1474                self.take_back();
1475                // SAFETY: stops this process until it is continued.
1476                unsafe { libc::raise(libc::SIGSTOP) };
1477                self.lent = self.give(group, true);
1478            }
1479            _ => return false,
1480        }
1481        signal_process_group(child, libc::SIGCONT);
1482        true
1483    }
1484
1485    fn take_back(&mut self) {
1486        if std::mem::take(&mut self.lent) {
1487            // SAFETY: reads this process's own group.
1488            let ours = unsafe { libc::getpgrp() };
1489            self.give(ours, false);
1490        }
1491    }
1492}
1493
1494#[cfg(unix)]
1495impl Drop for Keyboard {
1496    fn drop(&mut self) {
1497        self.take_back();
1498    }
1499}
1500
1501#[cfg(unix)]
1502fn signal_process_group(child: &mut Child, signal: i32) {
1503    let pid = child.id() as i32;
1504    // SAFETY: `kill` is async-signal-safe and receives a process-group ID
1505    // created for this child before exec. Failure can mean the child exited
1506    // between `try_wait` and this call, so it is intentionally non-fatal.
1507    let group_result = unsafe { libc::kill(-pid, signal) };
1508    if group_result != 0 {
1509        // SAFETY: same rationale, with the child PID as a last-resort target.
1510        let _ = unsafe { libc::kill(pid, signal) };
1511    }
1512}
1513
1514#[cfg(unix)]
1515fn exit_parts(status: ExitStatus) -> (Option<i32>, Option<i32>) {
1516    use std::os::unix::process::ExitStatusExt;
1517    (status.code(), status.signal())
1518}
1519
1520#[cfg(not(unix))]
1521fn exit_parts(status: ExitStatus) -> (Option<i32>, Option<i32>) {
1522    (status.code(), None)
1523}
1524
1525/// One diagnostic line in one write. `writeln!` with arguments writes the
1526/// pieces between them separately, and the command being supervised shares
1527/// this stderr: a child printing at the same moment landed inside the timeout
1528/// line, splitting it where the number goes. A single write of a short line to
1529/// a pipe cannot be interleaved.
1530fn write_line(writer: &mut dyn Write, line: &str) -> std::io::Result<()> {
1531    let mut bytes = String::with_capacity(line.len() + 1);
1532    bytes.push_str(line);
1533    bytes.push('\n');
1534    writer.write_all(bytes.as_bytes())?;
1535    writer.flush()
1536}
1537
1538fn write_diagnostic(child: &Child, started: Instant, writer: &mut dyn Write) {
1539    let tree = descendant_process_tree(child.id());
1540    let diagnostic = format_process_diagnostic(child.id(), started.elapsed(), &tree);
1541    let verbose = std::env::var("SUPERCOV_VERBOSE")
1542        .or_else(|_| std::env::var("SUPERCOV_DEBUG"))
1543        .is_ok_and(|value| matches!(value.as_str(), "1" | "true" | "yes"));
1544    let diagnostic = if verbose {
1545        diagnostic.as_str()
1546    } else {
1547        diagnostic.lines().next().unwrap_or(diagnostic.as_str())
1548    };
1549    let _ = writeln!(writer, "{}", diagnostic).and_then(|_| writer.flush());
1550}
1551
1552fn validate_options(options: SupervisionOptions) -> Result<(), SupervisionError> {
1553    if options.diagnostic_interval.is_zero() || options.termination_grace.is_zero() {
1554        return Err(SupervisionError::InvalidMilliseconds {
1555            name: "process supervision interval".into(),
1556        });
1557    }
1558    if options.timeout.is_some_and(|timeout| timeout.is_zero()) {
1559        return Err(SupervisionError::InvalidMilliseconds {
1560            name: "SUPERCOV_COMMAND_TIMEOUT_MS".into(),
1561        });
1562    }
1563    Ok(())
1564}
1565
1566fn read_pipe(mut pipe: impl Read) -> io::Result<Vec<u8>> {
1567    let mut bytes = Vec::new();
1568    pipe.read_to_end(&mut bytes)?;
1569    Ok(bytes)
1570}
1571
1572fn captured_bytes(
1573    reader: thread::JoinHandle<io::Result<Vec<u8>>>,
1574    stream: &'static str,
1575) -> Result<Vec<u8>, SupervisionError> {
1576    reader
1577        .join()
1578        .map_err(|_| SupervisionError::PlatformOperation {
1579            operation: "join captured process output reader",
1580            source: io::Error::other(format!("{stream} reader panicked")),
1581        })?
1582        .map_err(|source| SupervisionError::PlatformOperation {
1583            operation: "read captured process output",
1584            source,
1585        })
1586}
1587
1588#[cfg(unix)]
1589pub struct ProcessSupervisor {
1590    signals: SignalFlags,
1591    watchdog_program: Option<PathBuf>,
1592}
1593
1594#[cfg(unix)]
1595impl ProcessSupervisor {
1596    pub fn new() -> Result<Self, SupervisionError> {
1597        Ok(Self {
1598            signals: SignalFlags::install()?,
1599            watchdog_program: None,
1600        })
1601    }
1602
1603    pub fn new_crash_safe(watchdog_program: &Path) -> Result<Self, SupervisionError> {
1604        let watchdog_program = fs::canonicalize(watchdog_program).map_err(|source| {
1605            SupervisionError::PlatformOperation {
1606                operation: "resolve parent-death watchdog executable",
1607                source,
1608            }
1609        })?;
1610        if !fs::metadata(&watchdog_program).is_ok_and(|metadata| metadata.is_file()) {
1611            return Err(SupervisionError::PlatformOperation {
1612                operation: "validate parent-death watchdog executable",
1613                source: io::Error::new(io::ErrorKind::InvalidInput, "expected a regular file"),
1614            });
1615        }
1616        Ok(Self {
1617            signals: SignalFlags::install()?,
1618            watchdog_program: Some(watchdog_program),
1619        })
1620    }
1621
1622    pub fn supervise(
1623        &self,
1624        spec: &CommandSpec,
1625        options: SupervisionOptions,
1626        writer: &mut dyn Write,
1627    ) -> Result<SupervisedResult, SupervisionError> {
1628        validate_options(options)?;
1629        if let Some(signal) = self.signals.received() {
1630            return Ok(SupervisedResult {
1631                status: None,
1632                signal: Some(signal.raw()),
1633                timed_out: false,
1634                interrupted_signal: Some(signal),
1635            });
1636        }
1637        let mut command = spec.command()?;
1638        let targets = Relays::request(&mut command, spec.console.as_ref());
1639        let (mut child, _parent_death_guard) = spawn_contained(
1640            &mut command,
1641            &spec.program,
1642            self.watchdog_program.as_deref(),
1643        )?;
1644        // This process's ends of a pipe the child was given go with the
1645        // command; held, the pipe would never end.
1646        drop(command);
1647        let relays = Relays::start(&mut child, targets, spec.console.as_ref());
1648        let result = self.monitor(&mut child, options, writer);
1649        relays.finish();
1650        result
1651    }
1652
1653    pub fn supervise_captured(
1654        &self,
1655        spec: &CommandSpec,
1656        options: SupervisionOptions,
1657        writer: &mut dyn Write,
1658    ) -> Result<SupervisedOutput, SupervisionError> {
1659        validate_options(options)?;
1660        if spec.captured_output.is_some() {
1661            return Err(SupervisionError::PlatformOperation {
1662                operation: "configure separate captured process output",
1663                source: io::Error::new(
1664                    io::ErrorKind::InvalidInput,
1665                    "merged and separate capture cannot be requested together",
1666                ),
1667            });
1668        }
1669        if let Some(signal) = self.signals.received() {
1670            return Ok(SupervisedOutput {
1671                result: SupervisedResult {
1672                    status: None,
1673                    signal: Some(signal.raw()),
1674                    timed_out: false,
1675                    interrupted_signal: Some(signal),
1676                },
1677                stdout: Vec::new(),
1678                stderr: Vec::new(),
1679            });
1680        }
1681        let mut command = spec.command()?;
1682        command.stdout(Stdio::piped()).stderr(Stdio::piped());
1683        let (mut child, parent_death_guard) = spawn_contained(
1684            &mut command,
1685            &spec.program,
1686            self.watchdog_program.as_deref(),
1687        )?;
1688        let stdout = child.stdout.take().expect("piped stdout");
1689        let stderr = child.stderr.take().expect("piped stderr");
1690        let stdout_reader = thread::spawn(move || read_pipe(stdout));
1691        let stderr_reader = thread::spawn(move || read_pipe(stderr));
1692        let result = self.monitor(&mut child, options, writer);
1693        // Closing the liveness writer makes the watchdog kill any descendants
1694        // that retained the output pipes after the root command exited.
1695        drop(parent_death_guard);
1696        let stdout = captured_bytes(stdout_reader, "stdout")?;
1697        let stderr = captured_bytes(stderr_reader, "stderr")?;
1698        Ok(SupervisedOutput {
1699            result: result?,
1700            stdout,
1701            stderr,
1702        })
1703    }
1704
1705    fn monitor(
1706        &self,
1707        child: &mut Child,
1708        options: SupervisionOptions,
1709        writer: &mut dyn Write,
1710    ) -> Result<SupervisedResult, SupervisionError> {
1711        let started = Instant::now();
1712        let mut next_diagnostic = started + options.diagnostic_interval;
1713        let timeout_at = options.timeout.map(|timeout| started + timeout);
1714        let mut termination: Option<(Instant, Option<ForwardedSignal>)> = None;
1715        let mut timed_out = false;
1716        let mut interrupted_signal = None;
1717        let mut escalated = false;
1718        let mut keyboard = Keyboard::default();
1719
1720        loop {
1721            let status = match child.try_wait() {
1722                Ok(status) => status,
1723                Err(error) => {
1724                    signal_process_group(child, libc::SIGKILL);
1725                    let _ = child.wait();
1726                    return Err(SupervisionError::Wait(error));
1727                }
1728            };
1729            if let Some(status) = status {
1730                let (status, signal) = exit_parts(status);
1731                // With the terminal lent, Ctrl-C went to the command alone.
1732                // A command it ended is a run that was interrupted, as it
1733                // is when Supercov is the one told.
1734                if keyboard.lent
1735                    && interrupted_signal.is_none()
1736                    && !timed_out
1737                    && (signal == Some(libc::SIGINT) || status == Some(128 + libc::SIGINT))
1738                {
1739                    interrupted_signal = Some(ForwardedSignal::Sigint);
1740                }
1741                keyboard.take_back();
1742                return Ok(SupervisedResult {
1743                    status,
1744                    signal,
1745                    timed_out,
1746                    interrupted_signal,
1747                });
1748            }
1749            if termination.is_none()
1750                && let Some(signal) = Keyboard::stopped(child)
1751            {
1752                keyboard.command_stopped(child, signal);
1753            }
1754            let now = Instant::now();
1755            if termination.is_none()
1756                && let Some(signal) = self.signals.received()
1757            {
1758                interrupted_signal = Some(signal);
1759                signal_process_group(child, signal.raw());
1760                termination = Some((now, Some(signal)));
1761            }
1762            if termination.is_none() && timeout_at.is_some_and(|deadline| now >= deadline) {
1763                timed_out = true;
1764                let _ = write_line(
1765                    writer,
1766                    &format!(
1767                        "[supercov] command exceeded SUPERCOV_COMMAND_TIMEOUT_MS={}; terminating process group",
1768                        options.timeout.expect("timeout deadline").as_millis()
1769                    ),
1770                );
1771                signal_process_group(child, libc::SIGTERM);
1772                termination = Some((now, None));
1773                write_diagnostic(child, started, writer);
1774            }
1775            if now >= next_diagnostic && !timed_out {
1776                write_diagnostic(child, started, writer);
1777                while next_diagnostic <= now {
1778                    next_diagnostic += options.diagnostic_interval;
1779                }
1780            }
1781            if !escalated
1782                && termination.is_some_and(|(terminated_at, _)| {
1783                    now.duration_since(terminated_at) >= options.termination_grace
1784                })
1785            {
1786                signal_process_group(child, libc::SIGKILL);
1787                escalated = true;
1788            }
1789            thread::sleep(POLL_INTERVAL);
1790        }
1791    }
1792}
1793
1794#[cfg(windows)]
1795pub struct ProcessSupervisor {
1796    signals: SignalFlags,
1797    job: JobHandle,
1798}
1799
1800#[cfg(windows)]
1801impl ProcessSupervisor {
1802    pub fn new() -> Result<Self, SupervisionError> {
1803        Ok(Self {
1804            signals: SignalFlags::install()?,
1805            job: JobHandle::new()?,
1806        })
1807    }
1808
1809    pub fn new_crash_safe(_watchdog_program: &Path) -> Result<Self, SupervisionError> {
1810        Self::new()
1811    }
1812
1813    pub fn supervise(
1814        &self,
1815        spec: &CommandSpec,
1816        options: SupervisionOptions,
1817        writer: &mut dyn Write,
1818    ) -> Result<SupervisedResult, SupervisionError> {
1819        validate_options(options)?;
1820        if let Some(signal) = self.signals.received() {
1821            return Ok(SupervisedResult {
1822                status: None,
1823                signal: None,
1824                timed_out: false,
1825                interrupted_signal: Some(signal),
1826            });
1827        }
1828        let mut command = spec.command()?;
1829        let targets = Relays::request(&mut command, spec.console.as_ref());
1830        let mut child = command.spawn().map_err(|source| SupervisionError::Spawn {
1831            program: spec.program.clone(),
1832            source,
1833        })?;
1834        if let Err(error) = self.job.assign(&child) {
1835            let _ = child.kill();
1836            let _ = child.wait();
1837            return Err(error);
1838        }
1839        if let Err(error) = resume_suspended_process(child.id()) {
1840            self.job.terminate();
1841            let _ = child.wait();
1842            return Err(error);
1843        }
1844        // This process's ends of a pipe the child was given go with the
1845        // command; held, the pipe would never end.
1846        drop(command);
1847        let relays = Relays::start(&mut child, targets, spec.console.as_ref());
1848        let result = self.monitor(&mut child, options, writer);
1849        relays.finish();
1850        result
1851    }
1852
1853    pub fn supervise_captured(
1854        &self,
1855        spec: &CommandSpec,
1856        options: SupervisionOptions,
1857        writer: &mut dyn Write,
1858    ) -> Result<SupervisedOutput, SupervisionError> {
1859        validate_options(options)?;
1860        if spec.captured_output.is_some() {
1861            return Err(SupervisionError::PlatformOperation {
1862                operation: "configure separate captured process output",
1863                source: io::Error::new(
1864                    io::ErrorKind::InvalidInput,
1865                    "merged and separate capture cannot be requested together",
1866                ),
1867            });
1868        }
1869        if let Some(signal) = self.signals.received() {
1870            return Ok(SupervisedOutput {
1871                result: SupervisedResult {
1872                    status: None,
1873                    signal: None,
1874                    timed_out: false,
1875                    interrupted_signal: Some(signal),
1876                },
1877                stdout: Vec::new(),
1878                stderr: Vec::new(),
1879            });
1880        }
1881        let mut command = spec.command()?;
1882        command.stdout(Stdio::piped()).stderr(Stdio::piped());
1883        let mut child = command.spawn().map_err(|source| SupervisionError::Spawn {
1884            program: spec.program.clone(),
1885            source,
1886        })?;
1887        if let Err(error) = self.job.assign(&child) {
1888            let _ = child.kill();
1889            let _ = child.wait();
1890            return Err(error);
1891        }
1892        if let Err(error) = resume_suspended_process(child.id()) {
1893            self.job.terminate();
1894            let _ = child.wait();
1895            return Err(error);
1896        }
1897        let stdout = child.stdout.take().expect("piped stdout");
1898        let stderr = child.stderr.take().expect("piped stderr");
1899        let stdout_reader = thread::spawn(move || read_pipe(stdout));
1900        let stderr_reader = thread::spawn(move || read_pipe(stderr));
1901        let result = self.monitor(&mut child, options, writer);
1902        let stdout = captured_bytes(stdout_reader, "stdout")?;
1903        let stderr = captured_bytes(stderr_reader, "stderr")?;
1904        Ok(SupervisedOutput {
1905            result: result?,
1906            stdout,
1907            stderr,
1908        })
1909    }
1910
1911    fn monitor(
1912        &self,
1913        child: &mut Child,
1914        options: SupervisionOptions,
1915        writer: &mut dyn Write,
1916    ) -> Result<SupervisedResult, SupervisionError> {
1917        let started = Instant::now();
1918        let mut next_diagnostic = started + options.diagnostic_interval;
1919        let timeout_at = options.timeout.map(|timeout| started + timeout);
1920        let mut termination: Option<Instant> = None;
1921        let mut timed_out = false;
1922        let mut interrupted_signal = None;
1923        let mut escalated = false;
1924
1925        loop {
1926            let status = match child.try_wait() {
1927                Ok(status) => status,
1928                Err(error) => {
1929                    self.job.terminate();
1930                    let _ = child.wait();
1931                    return Err(SupervisionError::Wait(error));
1932                }
1933            };
1934            if let Some(status) = status {
1935                let (status, signal) = exit_parts(status);
1936                return Ok(SupervisedResult {
1937                    status,
1938                    signal,
1939                    timed_out,
1940                    interrupted_signal,
1941                });
1942            }
1943            let now = Instant::now();
1944            if termination.is_none()
1945                && let Some(signal) = self.signals.received()
1946            {
1947                interrupted_signal = Some(signal);
1948                forward_windows_control(child);
1949                termination = Some(now);
1950            }
1951            if termination.is_none() && timeout_at.is_some_and(|deadline| now >= deadline) {
1952                timed_out = true;
1953                let _ = write_line(
1954                    writer,
1955                    &format!(
1956                        "[supercov] command exceeded SUPERCOV_COMMAND_TIMEOUT_MS={}; terminating process group",
1957                        options.timeout.expect("timeout deadline").as_millis()
1958                    ),
1959                );
1960                forward_windows_control(child);
1961                termination = Some(now);
1962                write_diagnostic(child, started, writer);
1963            }
1964            if now >= next_diagnostic && !timed_out {
1965                write_diagnostic(child, started, writer);
1966                while next_diagnostic <= now {
1967                    next_diagnostic += options.diagnostic_interval;
1968                }
1969            }
1970            if !escalated
1971                && termination.is_some_and(|terminated_at| {
1972                    now.duration_since(terminated_at) >= options.termination_grace
1973                })
1974            {
1975                self.job.terminate();
1976                escalated = true;
1977            }
1978            thread::sleep(POLL_INTERVAL);
1979        }
1980    }
1981}
1982
1983#[cfg(not(any(unix, windows)))]
1984pub struct ProcessSupervisor;
1985
1986#[cfg(not(any(unix, windows)))]
1987impl ProcessSupervisor {
1988    pub fn new() -> Result<Self, SupervisionError> {
1989        Err(SupervisionError::UnsupportedPlatform(
1990            "this target has no process-tree containment implementation",
1991        ))
1992    }
1993
1994    pub fn new_crash_safe(_watchdog_program: &Path) -> Result<Self, SupervisionError> {
1995        Self::new()
1996    }
1997
1998    pub fn supervise(
1999        &self,
2000        _spec: &CommandSpec,
2001        _options: SupervisionOptions,
2002        _writer: &mut dyn Write,
2003    ) -> Result<SupervisedResult, SupervisionError> {
2004        Err(SupervisionError::UnsupportedPlatform(
2005            "this target has no process-tree containment implementation",
2006        ))
2007    }
2008
2009    pub fn supervise_captured(
2010        &self,
2011        _spec: &CommandSpec,
2012        _options: SupervisionOptions,
2013        _writer: &mut dyn Write,
2014    ) -> Result<SupervisedOutput, SupervisionError> {
2015        Err(SupervisionError::UnsupportedPlatform(
2016            "this target has no process-tree containment implementation",
2017        ))
2018    }
2019}
2020
2021pub fn supervise_command(
2022    spec: &CommandSpec,
2023    options: SupervisionOptions,
2024    writer: &mut dyn Write,
2025) -> Result<SupervisedResult, SupervisionError> {
2026    ProcessSupervisor::new()?.supervise(spec, options, writer)
2027}
2028
2029pub fn supervise_captured_command(
2030    spec: &CommandSpec,
2031    options: SupervisionOptions,
2032    writer: &mut dyn Write,
2033) -> Result<SupervisedOutput, SupervisionError> {
2034    ProcessSupervisor::new()?.supervise_captured(spec, options, writer)
2035}
2036
2037#[cfg(test)]
2038mod tests {
2039    use super::*;
2040
2041    /// Somewhere a relay can write that the test can read afterwards.
2042    #[derive(Clone, Default)]
2043    struct Kept(std::sync::Arc<Mutex<Vec<u8>>>);
2044
2045    impl Write for Kept {
2046        fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
2047            self.0.lock().unwrap().extend_from_slice(bytes);
2048            Ok(bytes.len())
2049        }
2050
2051        fn flush(&mut self) -> io::Result<()> {
2052            Ok(())
2053        }
2054    }
2055
2056    #[test]
2057    fn what_a_command_prints_names_the_project() {
2058        // Go names a file in a compile error from where it runs, Maven and
2059        // Gradle end a failed run with the address of a report, and each
2060        // named Supercov's copy.
2061        let names = ConsoleNames {
2062            places: vec![(
2063                "/home/me/shop/.supercov/workspaces/workspace/shop".into(),
2064                "/home/me/shop".into(),
2065            )],
2066            up_to_the_project: Some("../../../../".into()),
2067        };
2068        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";
2069        let kept = Kept::default();
2070        let ended = relay(
2071            io::Cursor::new(printed.as_bytes().to_vec()),
2072            kept.clone(),
2073            names,
2074        );
2075        ended
2076            .recv_timeout(Duration::from_secs(10))
2077            .expect("the relay ends with its input");
2078        assert_eq!(
2079            String::from_utf8(kept.0.lock().unwrap().clone()).unwrap(),
2080            "# 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"
2081        );
2082    }
2083
2084    #[cfg(unix)]
2085    #[test]
2086    fn a_command_on_a_terminal_still_writes_to_one_and_names_the_project() {
2087        use std::os::fd::{AsRawFd, FromRawFd};
2088        // The user's terminal: what is written to `users` shows at `screen`.
2089        let (mut screen, mut users) = (-1, -1);
2090        // SAFETY: the two descriptors are owned by nothing else.
2091        let (mut screen, users) = unsafe {
2092            assert_eq!(
2093                libc::openpty(
2094                    &mut screen,
2095                    &mut users,
2096                    std::ptr::null_mut(),
2097                    std::ptr::null_mut::<libc::termios>(),
2098                    std::ptr::null_mut::<libc::winsize>(),
2099                ),
2100                0
2101            );
2102            let size = libc::winsize {
2103                ws_row: 40,
2104                ws_col: 132,
2105                ws_xpixel: 0,
2106                ws_ypixel: 0,
2107            };
2108            libc::ioctl(users, libc::TIOCSWINSZ, &size);
2109            (
2110                std::fs::File::from_raw_fd(screen),
2111                std::fs::File::from_raw_fd(users),
2112            )
2113        };
2114        let (terminal, theirs) = Terminal::like(users).expect("a terminal like the user's");
2115        let names = ConsoleNames {
2116            places: vec![(
2117                "/home/me/shop/.supercov/workspaces/workspace/shop".into(),
2118                "/home/me/shop".into(),
2119            )],
2120            up_to_the_project: None,
2121        };
2122        // A line whose name comes in two writes, the terminal's width as the
2123        // command reads it, and a question that has no end of line.
2124        let mut command = Command::new("sh");
2125        command
2126            .args([
2127                "-c",
2128                "test -t 1 && printf 'a terminal %s wide\\n' \"$(stty size <&2 | cut -d' ' -f2)\"; printf 'see /home/me/shop/.supercov/work'; printf 'spaces/workspace/shop/target/report\\n'; printf 'Install? '",
2129            ])
2130            .stdin(Stdio::null())
2131            .stdout(theirs.try_clone().unwrap())
2132            .stderr(theirs);
2133        let mut child = command.spawn().unwrap();
2134        drop(command);
2135        let ended = relay_terminal(terminal, names);
2136        assert!(child.wait().unwrap().success());
2137        ended
2138            .recv_timeout(Duration::from_secs(10))
2139            .expect("the relay ends when the command has gone");
2140        // SAFETY: a flag on a descriptor this test owns.
2141        unsafe {
2142            let flags = libc::fcntl(screen.as_raw_fd(), libc::F_GETFL);
2143            libc::fcntl(screen.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK);
2144        }
2145        let mut shown = Vec::new();
2146        let mut buffer = [0_u8; 4096];
2147        while let Ok(read) = screen.read(&mut buffer) {
2148            if read == 0 {
2149                break;
2150            }
2151            shown.extend_from_slice(&buffer[..read]);
2152        }
2153        // The user's terminal ends each line itself, once.
2154        assert_eq!(
2155            String::from_utf8(shown).unwrap(),
2156            "a terminal 132 wide\r\nsee /home/me/shop/target/report\r\nInstall? "
2157        );
2158    }
2159
2160    fn scratch_directory(label: &str) -> PathBuf {
2161        static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2162        let root = std::env::temp_dir().join(format!(
2163            "supercov-{label}-{}-{}-{}",
2164            std::process::id(),
2165            std::time::SystemTime::now()
2166                .duration_since(std::time::UNIX_EPOCH)
2167                .unwrap()
2168                .as_nanos(),
2169            COUNTER.fetch_add(1, Ordering::Relaxed)
2170        ));
2171        std::fs::create_dir(&root).unwrap();
2172        root
2173    }
2174
2175    #[test]
2176    fn resolves_programs_the_way_the_windows_shell_does() {
2177        let root = scratch_directory("program-resolution");
2178        std::fs::create_dir(root.join("sub")).unwrap();
2179        // `tool.CMD` is the same file as `tool.cmd` where names are
2180        // case-insensitive and a second file where they are not; either way
2181        // the default extension list finds it under its own spelling.
2182        for name in ["tool.cmd", "tool.CMD", "named.exe", "plain", "sub/tool.cmd"] {
2183            std::fs::write(root.join(name), "").unwrap();
2184        }
2185        let path = Some(std::env::join_paths([root.join("elsewhere"), root.clone()]).unwrap());
2186        let pathext = Some(OsString::from(".exe;.cmd"));
2187        let resolve = |program: &str| {
2188            resolve_program_with(OsStr::new(program), &root, path.clone(), pathext.clone())
2189        };
2190
2191        assert_eq!(resolve("tool"), root.join("tool.cmd").into_os_string());
2192        assert_eq!(resolve("named"), root.join("named.exe").into_os_string());
2193        assert_eq!(
2194            resolve("named.exe"),
2195            root.join("named.exe").into_os_string()
2196        );
2197        // An extensionless file is never chosen: cmd.exe cannot start it either.
2198        assert_eq!(resolve("plain"), OsString::from("plain"));
2199        assert_eq!(resolve("missing"), OsString::from("missing"));
2200        // A separator means relative to the working directory, not PATH.
2201        assert_eq!(
2202            resolve("sub/tool"),
2203            root.join("sub/tool.cmd").into_os_string()
2204        );
2205        assert_eq!(
2206            resolve("./sub/tool"),
2207            root.join("./sub/tool.cmd").into_os_string()
2208        );
2209        let absolute = root.join("named.exe");
2210        assert_eq!(
2211            resolve_program_with(
2212                absolute.as_os_str(),
2213                Path::new("/nowhere"),
2214                None,
2215                pathext.clone()
2216            ),
2217            absolute.clone().into_os_string()
2218        );
2219        // No PATHEXT at all means the Windows default list.
2220        assert_eq!(
2221            resolve_program_with(OsStr::new("tool"), &root, path.clone(), None),
2222            root.join("tool.CMD").into_os_string()
2223        );
2224        std::fs::remove_dir_all(root).unwrap();
2225    }
2226
2227    #[cfg(windows)]
2228    #[test]
2229    fn starts_a_batch_shim_found_through_pathext() {
2230        let root = scratch_directory("batch-shim");
2231        std::fs::write(root.join("shim.cmd"), "@echo off\r\nexit /b 3\r\n").unwrap();
2232        let mut environment = vec![
2233            (OsString::from("Path"), root.clone().into_os_string()),
2234            (
2235                OsString::from("PATHEXT"),
2236                OsString::from(".COM;.EXE;.BAT;.CMD"),
2237            ),
2238        ];
2239        if let Some(system_root) = std::env::var_os("SystemRoot") {
2240            environment.push((OsString::from("SystemRoot"), system_root));
2241        }
2242        let spec = CommandSpec {
2243            program: "shim".into(),
2244            arguments: vec!["ignored".into()],
2245            cwd: std::env::current_dir().unwrap(),
2246            environment: Some(environment),
2247            captured_output: None,
2248            console: None,
2249        };
2250        let mut diagnostics = Vec::new();
2251        let result =
2252            supervise_command(&spec, SupervisionOptions::default(), &mut diagnostics).unwrap();
2253        assert_eq!(result.exit_code(), 3);
2254        std::fs::remove_dir_all(root).unwrap();
2255    }
2256
2257    #[test]
2258    fn parses_only_positive_integer_milliseconds() {
2259        assert_eq!(positive_milliseconds(None, "VALUE").unwrap(), None);
2260        assert_eq!(
2261            positive_milliseconds(Some("50"), "VALUE").unwrap(),
2262            Some(Duration::from_millis(50))
2263        );
2264        for value in ["0", "-1", "1.5", "NaN", " 1"] {
2265            assert!(positive_milliseconds(Some(value), "VALUE").is_err());
2266        }
2267    }
2268
2269    #[test]
2270    fn diagnostic_format_is_sanitized_and_reference_compatible() {
2271        let output = format_process_diagnostic(
2272            20,
2273            Duration::from_millis(61_000),
2274            &[ProcessSnapshot {
2275                pid: 20,
2276                parent_pid: 10,
2277                executable: "node".into(),
2278                state: Some("S".into()),
2279                cpu_tenths: Some(13),
2280            }],
2281        );
2282        assert_eq!(
2283            output,
2284            "[supercov] command still running after 1m01s\n  pid=20 ppid=10 exe=node state=S cpu=1.3s"
2285        );
2286        assert!(!output.contains("argv"));
2287    }
2288
2289    #[cfg(unix)]
2290    #[test]
2291    fn returns_the_child_status_without_a_default_timeout() {
2292        let root = std::env::current_dir().unwrap();
2293        let spec = CommandSpec {
2294            program: "/bin/sh".into(),
2295            arguments: vec!["-c".into(), "exit 7".into()],
2296            cwd: root,
2297            environment: None,
2298            captured_output: None,
2299            console: None,
2300        };
2301        let mut diagnostics = Vec::new();
2302        let result =
2303            supervise_command(&spec, SupervisionOptions::default(), &mut diagnostics).unwrap();
2304        assert_eq!(result.exit_code(), 7);
2305        assert!(!result.timed_out);
2306        assert!(diagnostics.is_empty());
2307    }
2308
2309    #[cfg(unix)]
2310    #[test]
2311    fn captures_stdout_and_stderr_separately_without_losing_status() {
2312        let spec = CommandSpec {
2313            program: "/bin/sh".into(),
2314            arguments: vec![
2315                "-c".into(),
2316                "printf stdout-value; printf stderr-value >&2; exit 9".into(),
2317            ],
2318            cwd: std::env::current_dir().unwrap(),
2319            environment: None,
2320            captured_output: None,
2321            console: None,
2322        };
2323        let mut diagnostics = Vec::new();
2324        let output =
2325            supervise_captured_command(&spec, SupervisionOptions::default(), &mut diagnostics)
2326                .unwrap();
2327        assert_eq!(output.result.exit_code(), 9);
2328        assert_eq!(output.stdout, b"stdout-value");
2329        assert_eq!(output.stderr, b"stderr-value");
2330        assert!(diagnostics.is_empty());
2331    }
2332
2333    #[cfg(windows)]
2334    #[test]
2335    fn returns_the_windows_child_status_without_a_default_timeout() {
2336        let spec = CommandSpec {
2337            program: "cmd.exe".into(),
2338            arguments: vec!["/D".into(), "/S".into(), "/C".into(), "exit /b 7".into()],
2339            cwd: std::env::current_dir().unwrap(),
2340            environment: None,
2341            captured_output: None,
2342            console: None,
2343        };
2344        let mut diagnostics = Vec::new();
2345        let result =
2346            supervise_command(&spec, SupervisionOptions::default(), &mut diagnostics).unwrap();
2347        assert_eq!(result.exit_code(), 7);
2348        assert!(!result.timed_out);
2349        assert!(diagnostics.is_empty());
2350    }
2351
2352    #[cfg(unix)]
2353    #[test]
2354    fn explicit_timeout_reports_and_returns_124() {
2355        let root = std::env::current_dir().unwrap();
2356        let spec = CommandSpec {
2357            program: "/bin/sh".into(),
2358            arguments: vec!["-c".into(), "while :; do sleep 1; done".into()],
2359            cwd: root,
2360            environment: None,
2361            captured_output: None,
2362            console: None,
2363        };
2364        let mut diagnostics = Vec::new();
2365        let result = supervise_command(
2366            &spec,
2367            SupervisionOptions {
2368                diagnostic_interval: Duration::from_millis(20),
2369                timeout: Some(Duration::from_millis(70)),
2370                termination_grace: Duration::from_millis(50),
2371            },
2372            &mut diagnostics,
2373        )
2374        .unwrap();
2375        let diagnostics = String::from_utf8(diagnostics).unwrap();
2376        assert_eq!(result.exit_code(), COMMAND_TIMEOUT_EXIT_CODE);
2377        assert!(result.timed_out);
2378        assert!(diagnostics.contains("command still running after"));
2379        assert!(diagnostics.contains("SUPERCOV_COMMAND_TIMEOUT_MS=70"));
2380    }
2381
2382    #[cfg(windows)]
2383    #[test]
2384    fn timeout_terminates_the_complete_windows_job() {
2385        use std::{
2386            fs,
2387            time::{SystemTime, UNIX_EPOCH},
2388        };
2389
2390        let unique = SystemTime::now()
2391            .duration_since(UNIX_EPOCH)
2392            .unwrap()
2393            .as_nanos();
2394        let root = std::env::temp_dir().join(format!(
2395            "supercov-windows-job-{}-{unique}",
2396            std::process::id()
2397        ));
2398        fs::create_dir_all(&root).unwrap();
2399        struct RemoveOnDrop(PathBuf);
2400        impl Drop for RemoveOnDrop {
2401            fn drop(&mut self) {
2402                let _ = fs::remove_dir_all(&self.0);
2403            }
2404        }
2405        let _cleanup = RemoveOnDrop(root.clone());
2406        let ready = root.join("descendant-ready");
2407        let marker = root.join("descendant-survived");
2408        let mut environment = std::env::vars_os().collect::<Vec<_>>();
2409        environment.extend([
2410            ("SUPERCOV_WINDOWS_PARENT_HELPER".into(), "1".into()),
2411            ("SUPERCOV_WINDOWS_READY".into(), ready.as_os_str().into()),
2412            ("SUPERCOV_WINDOWS_MARKER".into(), marker.as_os_str().into()),
2413        ]);
2414        let spec = CommandSpec {
2415            program: std::env::current_exe().unwrap().into_os_string(),
2416            arguments: vec![
2417                "--ignored".into(),
2418                "windows_timeout_parent_helper".into(),
2419                "--nocapture".into(),
2420            ],
2421            cwd: root,
2422            environment: Some(environment),
2423            captured_output: None,
2424            console: None,
2425        };
2426        let mut diagnostics = Vec::new();
2427        let result = supervise_command(
2428            &spec,
2429            SupervisionOptions {
2430                diagnostic_interval: Duration::from_secs(60),
2431                timeout: Some(Duration::from_millis(750)),
2432                termination_grace: Duration::from_millis(50),
2433            },
2434            &mut diagnostics,
2435        )
2436        .unwrap();
2437
2438        assert!(result.timed_out);
2439        assert_eq!(result.exit_code(), COMMAND_TIMEOUT_EXIT_CODE);
2440        assert!(
2441            ready.exists(),
2442            "the helper did not prove that its descendant started before timeout"
2443        );
2444        thread::sleep(Duration::from_millis(1_700));
2445        assert!(
2446            !marker.exists(),
2447            "a descendant escaped the Windows Job Object after timeout"
2448        );
2449        assert!(
2450            String::from_utf8(diagnostics)
2451                .unwrap()
2452                .contains("terminating process group")
2453        );
2454    }
2455
2456    #[cfg(windows)]
2457    #[test]
2458    #[ignore = "subprocess helper for timeout_terminates_the_complete_windows_job"]
2459    fn windows_timeout_parent_helper() {
2460        use std::fs;
2461
2462        if std::env::var_os("SUPERCOV_WINDOWS_PARENT_HELPER").is_none() {
2463            return;
2464        }
2465        let mut child = Command::new(std::env::current_exe().unwrap())
2466            .args(["--ignored", "windows_timeout_marker_helper", "--nocapture"])
2467            .stdin(Stdio::null())
2468            .stdout(Stdio::null())
2469            .stderr(Stdio::null())
2470            .spawn()
2471            .unwrap();
2472        fs::write(
2473            std::env::var_os("SUPERCOV_WINDOWS_READY").unwrap(),
2474            child.id().to_string(),
2475        )
2476        .unwrap();
2477        child.wait().unwrap();
2478    }
2479
2480    #[cfg(windows)]
2481    #[test]
2482    #[ignore = "subprocess helper for timeout_terminates_the_complete_windows_job"]
2483    fn windows_timeout_marker_helper() {
2484        use std::fs;
2485
2486        if std::env::var_os("SUPERCOV_WINDOWS_PARENT_HELPER").is_none() {
2487            return;
2488        }
2489        thread::sleep(Duration::from_millis(1_500));
2490        fs::write(
2491            std::env::var_os("SUPERCOV_WINDOWS_MARKER").unwrap(),
2492            b"escaped",
2493        )
2494        .unwrap();
2495    }
2496
2497    #[cfg(unix)]
2498    #[test]
2499    fn diagnostic_write_failures_never_change_the_child_result() {
2500        struct BrokenWriter;
2501        impl Write for BrokenWriter {
2502            fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
2503                Err(io::Error::new(
2504                    io::ErrorKind::BrokenPipe,
2505                    "closed diagnostic stream",
2506                ))
2507            }
2508
2509            fn flush(&mut self) -> io::Result<()> {
2510                Ok(())
2511            }
2512        }
2513
2514        let spec = CommandSpec {
2515            program: "/bin/sh".into(),
2516            arguments: vec!["-c".into(), "sleep 0.05; exit 0".into()],
2517            cwd: std::env::current_dir().unwrap(),
2518            environment: None,
2519            captured_output: None,
2520            console: None,
2521        };
2522        let result = supervise_command(
2523            &spec,
2524            SupervisionOptions {
2525                diagnostic_interval: Duration::from_millis(10),
2526                timeout: None,
2527                termination_grace: Duration::from_millis(50),
2528            },
2529            &mut BrokenWriter,
2530        )
2531        .unwrap();
2532        assert_eq!(result.exit_code(), 0);
2533    }
2534}