1#[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 pub environment: Option<Vec<(OsString, OsString)>>,
95 pub captured_output: Option<PathBuf>,
98 pub console: Option<ConsoleNames>,
101}
102
103#[derive(Debug, Clone, Default, PartialEq, Eq)]
106pub struct ConsoleNames {
107 pub places: Vec<(String, String)>,
110 pub up_to_the_project: Option<String>,
113}
114
115impl ConsoleNames {
116 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
130fn 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 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
170struct Relays(Vec<std::sync::mpsc::Receiver<()>>);
172
173#[derive(Default)]
178struct RelayTargets {
179 stdout: Option<std::fs::File>,
180 stderr: Option<std::fs::File>,
181 both: Option<io::PipeReader>,
185 #[cfg(unix)]
187 terminals: Vec<Terminal>,
188}
189
190#[cfg(unix)]
194struct Terminal {
195 ours: std::fs::File,
197 real: std::fs::File,
199}
200
201#[cfg(unix)]
202impl Terminal {
203 fn like(real: std::fs::File) -> Option<(Self, std::fs::File)> {
206 use std::os::fd::{AsRawFd, FromRawFd};
207 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 settings.c_oflag &= !libc::OPOST;
220 let (mut ours, mut theirs) = (-1, -1);
221 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 libc::fcntl(end.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC);
239 }
240 Some((Self { ours, real }, theirs))
241 }
242 }
243
244 fn resize(&self) {
246 use std::os::fd::AsRawFd;
247 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 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 unsafe { libc::poll(&mut poll, 1, wait.as_millis() as libc::c_int) > 0 }
266 }
267}
268
269#[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
299const RELAY_GRACE: Duration = Duration::from_secs(2);
302
303impl Relays {
304 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 (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
398const 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 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#[cfg(unix)]
457const TERMINAL_LINE_WAIT: Duration = Duration::from_millis(40);
458
459#[cfg(unix)]
461const TERMINAL_SIZE_EVERY: Duration = Duration::from_millis(250);
462
463#[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 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 #[cfg(windows)]
587 fn resolved_program(&self) -> OsString {
588 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#[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};
778
779 let system = System::new_with_specifics(
780 RefreshKind::nothing().with_processes(ProcessRefreshKind::nothing().with_cpu()),
781 );
782 system
783 .processes()
784 .iter()
785 .map(|(pid, process)| ProcessSnapshot {
786 pid: pid.as_u32(),
787 parent_pid: process.parent().map_or(0, sysinfo::Pid::as_u32),
788 state: Some(process_status(process.status()).into()),
789 cpu_tenths: Some(process.accumulated_cpu_time() / 100),
790 executable: Path::new(process.name())
791 .file_name()
792 .and_then(|value| value.to_str())
793 .unwrap_or("unknown")
794 .to_owned(),
795 })
796 .collect()
797}
798
799fn process_status(status: sysinfo::ProcessStatus) -> &'static str {
800 use sysinfo::ProcessStatus;
801 match status {
802 ProcessStatus::Idle => "I",
803 ProcessStatus::Run => "R",
804 ProcessStatus::Sleep => "S",
805 ProcessStatus::Stop => "T",
806 ProcessStatus::Zombie => "Z",
807 ProcessStatus::Tracing => "t",
808 ProcessStatus::Dead => "X",
809 ProcessStatus::Wakekill => "K",
810 ProcessStatus::Waking => "W",
811 ProcessStatus::Parked => "P",
812 ProcessStatus::LockBlocked => "L",
813 ProcessStatus::UninterruptibleDiskSleep => "D",
814 ProcessStatus::Suspended => "S",
815 ProcessStatus::Unknown(_) => "?",
816 }
817}
818
819pub fn descendant_process_tree(root_pid: u32) -> Vec<ProcessSnapshot> {
820 let inventory = process_inventory();
821 let mut descendants = std::collections::BTreeSet::from([root_pid]);
822 loop {
823 let before = descendants.len();
824 for process in &inventory {
825 if descendants.contains(&process.parent_pid) {
826 descendants.insert(process.pid);
827 }
828 }
829 if descendants.len() == before {
830 break;
831 }
832 }
833 let mut result = inventory
834 .into_iter()
835 .filter(|process| descendants.contains(&process.pid))
836 .collect::<Vec<_>>();
837 result.sort_by_key(|process| process.pid);
838 result
839}
840
841fn format_duration(milliseconds: u128) -> String {
842 if milliseconds < 1_000 {
843 return format!("{milliseconds}ms");
844 }
845 let seconds = (milliseconds + 500) / 1_000;
846 if seconds < 60 {
847 return format!("{seconds}s");
848 }
849 format!("{}m{:02}s", seconds / 60, seconds % 60)
850}
851
852pub fn format_process_diagnostic(
853 root_pid: u32,
854 elapsed: Duration,
855 tree: &[ProcessSnapshot],
856) -> String {
857 let mut output = format!(
858 "[supercov] command still running after {}",
859 format_duration(elapsed.as_millis())
860 );
861 if tree.is_empty() {
862 output.push_str(&format!("\n pid={root_pid} process details unavailable"));
863 return output;
864 }
865 for process in tree {
866 output.push_str(&format!(
867 "\n pid={} ppid={} exe={}",
868 process.pid, process.parent_pid, process.executable
869 ));
870 if let Some(state) = &process.state {
871 output.push_str(&format!(" state={state}"));
872 }
873 if let Some(cpu_tenths) = process.cpu_tenths {
874 output.push_str(&format!(" cpu={}.{}s", cpu_tenths / 10, cpu_tenths % 10));
875 }
876 }
877 output
878}
879
880#[cfg(unix)]
881struct SignalFlags {
882 _exclusive: MutexGuard<'static, ()>,
883 previous: Vec<(i32, libc::sigaction)>,
884}
885
886#[cfg(unix)]
887impl SignalFlags {
888 fn install() -> Result<Self, SupervisionError> {
889 let exclusive = SIGNAL_HANDLER_LOCK
890 .lock()
891 .unwrap_or_else(std::sync::PoisonError::into_inner);
892 RECEIVED_SIGNAL.store(0, Ordering::SeqCst);
893 let mut previous = Vec::new();
894 for signal in [libc::SIGHUP, libc::SIGINT, libc::SIGTERM] {
895 let mut action = unsafe { std::mem::zeroed::<libc::sigaction>() };
898 action.sa_sigaction = record_signal as *const () as usize;
899 unsafe { libc::sigemptyset(&mut action.sa_mask) };
901 action.sa_flags = 0;
902 let mut old = unsafe { std::mem::zeroed::<libc::sigaction>() };
904 if unsafe { libc::sigaction(signal, &action, &mut old) } != 0 {
907 for (installed, old) in previous.iter().rev() {
908 let _ = unsafe { libc::sigaction(*installed, old, std::ptr::null_mut()) };
910 }
911 return Err(SupervisionError::Signal(io::Error::last_os_error()));
912 }
913 previous.push((signal, old));
914 }
915 Ok(Self {
916 _exclusive: exclusive,
917 previous,
918 })
919 }
920
921 fn received(&self) -> Option<ForwardedSignal> {
922 match RECEIVED_SIGNAL.load(Ordering::SeqCst) {
926 libc::SIGHUP => Some(ForwardedSignal::Sighup),
927 libc::SIGINT => Some(ForwardedSignal::Sigint),
928 libc::SIGTERM => Some(ForwardedSignal::Sigterm),
929 _ => None,
930 }
931 }
932}
933
934#[cfg(unix)]
935impl Drop for SignalFlags {
936 fn drop(&mut self) {
937 for (signal, previous) in self.previous.drain(..).rev() {
938 let _ = unsafe { libc::sigaction(signal, &previous, std::ptr::null_mut()) };
941 }
942 RECEIVED_SIGNAL.store(0, Ordering::SeqCst);
943 }
944}
945
946#[cfg(unix)]
947static SIGNAL_HANDLER_LOCK: Mutex<()> = Mutex::new(());
948#[cfg(unix)]
949static RECEIVED_SIGNAL: AtomicI32 = AtomicI32::new(0);
950
951#[cfg(unix)]
952extern "C" fn record_signal(signal: i32) {
953 RECEIVED_SIGNAL.store(signal, Ordering::SeqCst);
954}
955
956#[cfg(windows)]
957struct SignalFlags {
958 _exclusive: MutexGuard<'static, ()>,
959}
960
961#[cfg(windows)]
962impl SignalFlags {
963 fn install() -> Result<Self, SupervisionError> {
964 use windows_sys::Win32::System::Console::SetConsoleCtrlHandler;
965
966 let exclusive = SIGNAL_HANDLER_LOCK
967 .lock()
968 .unwrap_or_else(std::sync::PoisonError::into_inner);
969 RECEIVED_SIGNAL.store(0, Ordering::SeqCst);
970 if unsafe { SetConsoleCtrlHandler(Some(record_console_signal), 1) } == 0 {
973 return Err(SupervisionError::Signal(io::Error::last_os_error()));
974 }
975 Ok(Self {
976 _exclusive: exclusive,
977 })
978 }
979
980 fn received(&self) -> Option<ForwardedSignal> {
981 match RECEIVED_SIGNAL.load(Ordering::SeqCst) {
982 2 => Some(ForwardedSignal::Sigint),
983 15 => Some(ForwardedSignal::Sigterm),
984 _ => None,
985 }
986 }
987}
988
989#[cfg(windows)]
990impl Drop for SignalFlags {
991 fn drop(&mut self) {
992 use windows_sys::Win32::System::Console::SetConsoleCtrlHandler;
993
994 let _ = unsafe { SetConsoleCtrlHandler(Some(record_console_signal), 0) };
996 RECEIVED_SIGNAL.store(0, Ordering::SeqCst);
997 }
998}
999
1000#[cfg(windows)]
1001static SIGNAL_HANDLER_LOCK: Mutex<()> = Mutex::new(());
1002#[cfg(windows)]
1003static RECEIVED_SIGNAL: AtomicI32 = AtomicI32::new(0);
1004
1005#[cfg(windows)]
1006unsafe extern "system" fn record_console_signal(control: u32) -> i32 {
1007 use windows_sys::Win32::System::Console::{
1008 CTRL_BREAK_EVENT, CTRL_C_EVENT, CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT,
1009 };
1010
1011 match control {
1012 CTRL_C_EVENT | CTRL_BREAK_EVENT => {
1013 RECEIVED_SIGNAL.store(2, Ordering::SeqCst);
1014 1
1015 }
1016 CTRL_CLOSE_EVENT | CTRL_LOGOFF_EVENT | CTRL_SHUTDOWN_EVENT => {
1017 RECEIVED_SIGNAL.store(15, Ordering::SeqCst);
1018 1
1019 }
1020 _ => 0,
1021 }
1022}
1023
1024#[cfg(windows)]
1025pub(crate) struct JobHandle(windows_sys::Win32::Foundation::HANDLE);
1026
1027#[cfg(windows)]
1035unsafe impl Send for JobHandle {}
1036#[cfg(windows)]
1037unsafe impl Sync for JobHandle {}
1038
1039#[cfg(windows)]
1040impl JobHandle {
1041 pub(crate) fn new() -> Result<Self, SupervisionError> {
1042 use windows_sys::Win32::System::JobObjects::{
1043 CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
1044 JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
1045 SetInformationJobObject,
1046 };
1047
1048 let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
1050 if handle.is_null() {
1051 return Err(SupervisionError::PlatformOperation {
1052 operation: "create a Windows Job Object",
1053 source: io::Error::last_os_error(),
1054 });
1055 }
1056 let job = Self(handle);
1057 let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
1058 limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
1059 if unsafe {
1062 SetInformationJobObject(
1063 job.0,
1064 JobObjectExtendedLimitInformation,
1065 (&raw const limits).cast(),
1066 std::mem::size_of_val(&limits) as u32,
1067 )
1068 } == 0
1069 {
1070 return Err(SupervisionError::PlatformOperation {
1071 operation: "configure Windows Job Object containment",
1072 source: io::Error::last_os_error(),
1073 });
1074 }
1075 Ok(job)
1076 }
1077
1078 pub(crate) fn assign(&self, child: &Child) -> Result<(), SupervisionError> {
1079 use std::os::windows::io::AsRawHandle;
1080 use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject;
1081
1082 if unsafe { AssignProcessToJobObject(self.0, child.as_raw_handle().cast()) } == 0 {
1085 return Err(SupervisionError::PlatformOperation {
1086 operation: "assign the suspended command to its Windows Job Object",
1087 source: io::Error::last_os_error(),
1088 });
1089 }
1090 Ok(())
1091 }
1092
1093 fn terminate(&self) {
1094 use windows_sys::Win32::System::JobObjects::TerminateJobObject;
1095 let _ = unsafe { TerminateJobObject(self.0, 1) };
1098 }
1099}
1100
1101#[cfg(windows)]
1102impl Drop for JobHandle {
1103 fn drop(&mut self) {
1104 use windows_sys::Win32::Foundation::CloseHandle;
1105 let _ = unsafe { CloseHandle(self.0) };
1108 }
1109}
1110
1111#[cfg(windows)]
1112fn resume_suspended_process(pid: u32) -> Result<(), SupervisionError> {
1113 use windows_sys::Win32::{
1114 Foundation::{CloseHandle, INVALID_HANDLE_VALUE},
1115 System::{
1116 Diagnostics::ToolHelp::{
1117 CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First,
1118 Thread32Next,
1119 },
1120 Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME},
1121 },
1122 };
1123
1124 let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
1128 if snapshot == INVALID_HANDLE_VALUE {
1129 return Err(SupervisionError::PlatformOperation {
1130 operation: "enumerate the suspended command threads",
1131 source: io::Error::last_os_error(),
1132 });
1133 }
1134 struct Snapshot(windows_sys::Win32::Foundation::HANDLE);
1135 impl Drop for Snapshot {
1136 fn drop(&mut self) {
1137 let _ = unsafe { CloseHandle(self.0) };
1138 }
1139 }
1140 let _snapshot = Snapshot(snapshot);
1141 let mut entry = THREADENTRY32 {
1142 dwSize: std::mem::size_of::<THREADENTRY32>() as u32,
1143 ..Default::default()
1144 };
1145 if unsafe { Thread32First(snapshot, &raw mut entry) } == 0 {
1146 return Err(SupervisionError::PlatformOperation {
1147 operation: "read the suspended command thread snapshot",
1148 source: io::Error::last_os_error(),
1149 });
1150 }
1151 loop {
1152 if entry.th32OwnerProcessID == pid {
1153 let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
1154 if thread.is_null() {
1155 return Err(SupervisionError::PlatformOperation {
1156 operation: "open the suspended command's primary thread",
1157 source: io::Error::last_os_error(),
1158 });
1159 }
1160 let resumed = unsafe { ResumeThread(thread) };
1163 let resume_error = (resumed == u32::MAX).then(io::Error::last_os_error);
1164 let _ = unsafe { CloseHandle(thread) };
1165 if let Some(source) = resume_error {
1166 return Err(SupervisionError::PlatformOperation {
1167 operation: "resume the contained command",
1168 source,
1169 });
1170 }
1171 return Ok(());
1172 }
1173 entry.dwSize = std::mem::size_of::<THREADENTRY32>() as u32;
1174 if unsafe { Thread32Next(snapshot, &raw mut entry) } == 0 {
1175 break;
1176 }
1177 }
1178 Err(SupervisionError::PlatformOperation {
1179 operation: "locate the suspended command's primary thread",
1180 source: io::Error::new(io::ErrorKind::NotFound, "process thread was absent"),
1181 })
1182}
1183
1184#[cfg(windows)]
1185fn forward_windows_control(child: &Child) {
1186 use windows_sys::Win32::System::Console::{CTRL_BREAK_EVENT, GenerateConsoleCtrlEvent};
1187 let _ = unsafe { GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, child.id()) };
1191}
1192
1193#[cfg(unix)]
1198struct ParentDeathGuard {
1199 _writer: OwnedFd,
1200}
1201
1202#[cfg(unix)]
1203fn parent_death_pipe() -> Result<(OwnedFd, OwnedFd), SupervisionError> {
1204 let mut descriptors = [-1_i32; 2];
1205 if unsafe { libc::pipe(descriptors.as_mut_ptr()) } != 0 {
1207 return Err(SupervisionError::PlatformOperation {
1208 operation: "create parent-death supervision pipe",
1209 source: io::Error::last_os_error(),
1210 });
1211 }
1212 let read = unsafe { OwnedFd::from_raw_fd(descriptors[0]) };
1214 let write = unsafe { OwnedFd::from_raw_fd(descriptors[1]) };
1216 for descriptor in [read.as_raw_fd(), write.as_raw_fd()] {
1217 if unsafe { libc::fcntl(descriptor, libc::F_SETFD, libc::FD_CLOEXEC) } != 0 {
1219 return Err(SupervisionError::PlatformOperation {
1220 operation: "protect parent-death supervision pipe across exec",
1221 source: io::Error::last_os_error(),
1222 });
1223 }
1224 }
1225 Ok((read, write))
1226}
1227
1228#[cfg(unix)]
1229fn spawn_contained(
1230 command: &mut Command,
1231 program: &OsString,
1232 watchdog_program: Option<&Path>,
1233) -> Result<(Child, Option<ParentDeathGuard>), SupervisionError> {
1234 let Some(watchdog_program) = watchdog_program else {
1235 command.process_group(0);
1236 let child = command.spawn().map_err(|source| SupervisionError::Spawn {
1237 program: program.clone(),
1238 source,
1239 })?;
1240 return Ok((child, None));
1241 };
1242 let (read, write) = parent_death_pipe()?;
1243 let (ready_read, ready_write) = parent_death_pipe()?;
1244 let read_descriptor = read.as_raw_fd();
1245 let write_descriptor = write.as_raw_fd();
1246 let ready_read_descriptor = ready_read.as_raw_fd();
1247 let ready_write_descriptor = ready_write.as_raw_fd();
1248 let watchdog_program = std::ffi::CString::new(watchdog_program.as_os_str().as_bytes())
1249 .map_err(|_| SupervisionError::PlatformOperation {
1250 operation: "encode parent-death watchdog executable",
1251 source: io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"),
1252 })?;
1253 let watchdog_argument =
1254 std::ffi::CString::new("__watch-process-group").expect("static CString");
1255 unsafe {
1259 command.pre_exec(move || {
1260 if libc::setpgid(0, 0) != 0 {
1261 return Err(io::Error::last_os_error());
1262 }
1263 let _ = libc::close(write_descriptor);
1264 let watchdog = libc::fork();
1265 if watchdog < 0 {
1266 return Err(io::Error::last_os_error());
1267 }
1268 if watchdog == 0 {
1269 let _ = libc::close(ready_read_descriptor);
1270 if libc::dup2(read_descriptor, 0) < 0 || libc::dup2(ready_write_descriptor, 3) < 0 {
1271 libc::_exit(125);
1272 }
1273 for descriptor in [read_descriptor, ready_write_descriptor, 1, 2] {
1274 if descriptor != 0 && descriptor != 3 {
1275 let _ = libc::close(descriptor);
1276 }
1277 }
1278 let arguments = [
1279 watchdog_program.as_ptr(),
1280 watchdog_argument.as_ptr(),
1281 std::ptr::null(),
1282 ];
1283 libc::execv(watchdog_program.as_ptr(), arguments.as_ptr());
1284 libc::_exit(125);
1285 }
1286 let _ = libc::close(read_descriptor);
1287 let _ = libc::close(ready_write_descriptor);
1288 let mut ready = 0_u8;
1289 loop {
1290 let received = libc::read(ready_read_descriptor, (&raw mut ready).cast(), 1);
1291 if received == 1 && ready == 1 {
1292 break;
1293 }
1294 if received < 0 && io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
1295 continue;
1296 }
1297 return Err(io::Error::other(
1298 "parent-death watchdog failed before command exec",
1299 ));
1300 }
1301 let _ = libc::close(ready_read_descriptor);
1302 Ok(())
1303 });
1304 }
1305 let child = command.spawn().map_err(|source| SupervisionError::Spawn {
1306 program: program.clone(),
1307 source,
1308 })?;
1309 drop(read);
1310 drop(ready_read);
1311 drop(ready_write);
1312 Ok((child, Some(ParentDeathGuard { _writer: write })))
1313}
1314
1315#[cfg(unix)]
1316pub fn watch_parent_process_group() -> io::Result<()> {
1317 let process_group = unsafe { libc::getppid() };
1320 if process_group <= 1 {
1321 return Err(io::Error::other(
1322 "parent-death watchdog has no target process",
1323 ));
1324 }
1325 if unsafe { libc::setsid() } < 0 {
1327 return Err(io::Error::last_os_error());
1328 }
1329 let descriptor_root = if Path::new("/proc/self/fd").is_dir() {
1330 Path::new("/proc/self/fd")
1331 } else {
1332 Path::new("/dev/fd")
1333 };
1334 let descriptors = fs::read_dir(descriptor_root)?
1335 .filter_map(Result::ok)
1336 .filter_map(|entry| entry.file_name().to_string_lossy().parse::<i32>().ok())
1337 .filter(|descriptor| !matches!(*descriptor, 0 | 3))
1338 .collect::<Vec<_>>();
1339 for descriptor in descriptors {
1340 let _ = unsafe { libc::close(descriptor) };
1343 }
1344 let ready = [1_u8];
1345 if unsafe { libc::write(3, ready.as_ptr().cast(), ready.len()) } != 1 {
1347 return Err(io::Error::last_os_error());
1348 }
1349 let _ = unsafe { libc::close(3) };
1350 let mut buffer = [0_u8; 1];
1351 loop {
1352 let read = unsafe { libc::read(0, buffer.as_mut_ptr().cast(), buffer.len()) };
1355 if read == 0 {
1356 break;
1357 }
1358 if read < 0 {
1359 if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
1360 continue;
1361 }
1362 break;
1363 }
1364 }
1365 let _ = unsafe { libc::kill(-process_group, libc::SIGKILL) };
1366 Ok(())
1367}
1368
1369#[cfg(not(unix))]
1370pub fn watch_parent_process_group() -> io::Result<()> {
1371 Err(io::Error::new(
1372 io::ErrorKind::Unsupported,
1373 "the POSIX parent-death watchdog is unavailable",
1374 ))
1375}
1376
1377#[cfg(unix)]
1387#[derive(Default)]
1388struct Keyboard {
1389 terminal: Option<fs::File>,
1390 lent: bool,
1391}
1392
1393#[cfg(unix)]
1394impl Keyboard {
1395 fn stopped(child: &Child) -> Option<i32> {
1397 unsafe {
1400 let mut info: libc::siginfo_t = std::mem::zeroed();
1401 if libc::waitid(
1402 libc::P_PID,
1403 child.id() as libc::id_t,
1404 &mut info,
1405 libc::WSTOPPED | libc::WNOHANG,
1406 ) != 0
1407 || info.si_code != libc::CLD_STOPPED
1408 {
1409 return None;
1410 }
1411 Some(info.si_status())
1412 }
1413 }
1414
1415 fn give(&mut self, group: libc::pid_t, ours_to_give: bool) -> bool {
1418 use std::os::fd::AsRawFd;
1419 if self.terminal.is_none() {
1420 self.terminal = fs::OpenOptions::new()
1421 .read(true)
1422 .write(true)
1423 .open("/dev/tty")
1424 .ok();
1425 }
1426 let Some(terminal) = &self.terminal else {
1427 return false;
1428 };
1429 unsafe {
1433 if ours_to_give && libc::tcgetpgrp(terminal.as_raw_fd()) != libc::getpgrp() {
1434 return false;
1435 }
1436 let mut held: libc::sigset_t = std::mem::zeroed();
1437 let mut before: libc::sigset_t = std::mem::zeroed();
1438 libc::sigemptyset(&mut held);
1439 libc::sigaddset(&mut held, libc::SIGTTOU);
1440 libc::pthread_sigmask(libc::SIG_BLOCK, &held, &mut before);
1441 let given = libc::tcsetpgrp(terminal.as_raw_fd(), group) == 0;
1442 libc::pthread_sigmask(libc::SIG_SETMASK, &before, std::ptr::null_mut());
1443 given
1444 }
1445 }
1446
1447 fn command_stopped(&mut self, child: &mut Child, signal: i32) -> bool {
1449 let group = child.id() as libc::pid_t;
1450 match signal {
1451 libc::SIGTTIN | libc::SIGTTOU if !self.lent => {
1452 if !self.give(group, true) {
1453 return false;
1456 }
1457 self.lent = true;
1458 }
1459 libc::SIGTSTP if self.lent => {
1463 self.take_back();
1464 unsafe { libc::raise(libc::SIGSTOP) };
1466 self.lent = self.give(group, true);
1467 }
1468 _ => return false,
1469 }
1470 signal_process_group(child, libc::SIGCONT);
1471 true
1472 }
1473
1474 fn take_back(&mut self) {
1475 if std::mem::take(&mut self.lent) {
1476 let ours = unsafe { libc::getpgrp() };
1478 self.give(ours, false);
1479 }
1480 }
1481}
1482
1483#[cfg(unix)]
1484impl Drop for Keyboard {
1485 fn drop(&mut self) {
1486 self.take_back();
1487 }
1488}
1489
1490#[cfg(unix)]
1491fn signal_process_group(child: &mut Child, signal: i32) {
1492 let pid = child.id() as i32;
1493 let group_result = unsafe { libc::kill(-pid, signal) };
1497 if group_result != 0 {
1498 let _ = unsafe { libc::kill(pid, signal) };
1500 }
1501}
1502
1503#[cfg(unix)]
1504fn exit_parts(status: ExitStatus) -> (Option<i32>, Option<i32>) {
1505 use std::os::unix::process::ExitStatusExt;
1506 (status.code(), status.signal())
1507}
1508
1509#[cfg(not(unix))]
1510fn exit_parts(status: ExitStatus) -> (Option<i32>, Option<i32>) {
1511 (status.code(), None)
1512}
1513
1514fn write_line(writer: &mut dyn Write, line: &str) -> std::io::Result<()> {
1520 let mut bytes = String::with_capacity(line.len() + 1);
1521 bytes.push_str(line);
1522 bytes.push('\n');
1523 writer.write_all(bytes.as_bytes())?;
1524 writer.flush()
1525}
1526
1527fn write_diagnostic(child: &Child, started: Instant, writer: &mut dyn Write) {
1528 let tree = descendant_process_tree(child.id());
1529 let diagnostic = format_process_diagnostic(child.id(), started.elapsed(), &tree);
1530 let verbose = std::env::var("SUPERCOV_VERBOSE")
1531 .or_else(|_| std::env::var("SUPERCOV_DEBUG"))
1532 .is_ok_and(|value| matches!(value.as_str(), "1" | "true" | "yes"));
1533 let diagnostic = if verbose {
1534 diagnostic.as_str()
1535 } else {
1536 diagnostic.lines().next().unwrap_or(diagnostic.as_str())
1537 };
1538 let _ = writeln!(writer, "{}", diagnostic).and_then(|_| writer.flush());
1539}
1540
1541fn validate_options(options: SupervisionOptions) -> Result<(), SupervisionError> {
1542 if options.diagnostic_interval.is_zero() || options.termination_grace.is_zero() {
1543 return Err(SupervisionError::InvalidMilliseconds {
1544 name: "process supervision interval".into(),
1545 });
1546 }
1547 if options.timeout.is_some_and(|timeout| timeout.is_zero()) {
1548 return Err(SupervisionError::InvalidMilliseconds {
1549 name: "SUPERCOV_COMMAND_TIMEOUT_MS".into(),
1550 });
1551 }
1552 Ok(())
1553}
1554
1555fn read_pipe(mut pipe: impl Read) -> io::Result<Vec<u8>> {
1556 let mut bytes = Vec::new();
1557 pipe.read_to_end(&mut bytes)?;
1558 Ok(bytes)
1559}
1560
1561fn captured_bytes(
1562 reader: thread::JoinHandle<io::Result<Vec<u8>>>,
1563 stream: &'static str,
1564) -> Result<Vec<u8>, SupervisionError> {
1565 reader
1566 .join()
1567 .map_err(|_| SupervisionError::PlatformOperation {
1568 operation: "join captured process output reader",
1569 source: io::Error::other(format!("{stream} reader panicked")),
1570 })?
1571 .map_err(|source| SupervisionError::PlatformOperation {
1572 operation: "read captured process output",
1573 source,
1574 })
1575}
1576
1577#[cfg(unix)]
1578pub struct ProcessSupervisor {
1579 signals: SignalFlags,
1580 watchdog_program: Option<PathBuf>,
1581}
1582
1583#[cfg(unix)]
1584impl ProcessSupervisor {
1585 pub fn new() -> Result<Self, SupervisionError> {
1586 Ok(Self {
1587 signals: SignalFlags::install()?,
1588 watchdog_program: None,
1589 })
1590 }
1591
1592 pub fn new_crash_safe(watchdog_program: &Path) -> Result<Self, SupervisionError> {
1593 let watchdog_program = fs::canonicalize(watchdog_program).map_err(|source| {
1594 SupervisionError::PlatformOperation {
1595 operation: "resolve parent-death watchdog executable",
1596 source,
1597 }
1598 })?;
1599 if !fs::metadata(&watchdog_program).is_ok_and(|metadata| metadata.is_file()) {
1600 return Err(SupervisionError::PlatformOperation {
1601 operation: "validate parent-death watchdog executable",
1602 source: io::Error::new(io::ErrorKind::InvalidInput, "expected a regular file"),
1603 });
1604 }
1605 Ok(Self {
1606 signals: SignalFlags::install()?,
1607 watchdog_program: Some(watchdog_program),
1608 })
1609 }
1610
1611 pub fn supervise(
1612 &self,
1613 spec: &CommandSpec,
1614 options: SupervisionOptions,
1615 writer: &mut dyn Write,
1616 ) -> Result<SupervisedResult, SupervisionError> {
1617 validate_options(options)?;
1618 if let Some(signal) = self.signals.received() {
1619 return Ok(SupervisedResult {
1620 status: None,
1621 signal: Some(signal.raw()),
1622 timed_out: false,
1623 interrupted_signal: Some(signal),
1624 });
1625 }
1626 let mut command = spec.command()?;
1627 let targets = Relays::request(&mut command, spec.console.as_ref());
1628 let (mut child, _parent_death_guard) = spawn_contained(
1629 &mut command,
1630 &spec.program,
1631 self.watchdog_program.as_deref(),
1632 )?;
1633 drop(command);
1636 let relays = Relays::start(&mut child, targets, spec.console.as_ref());
1637 let result = self.monitor(&mut child, options, writer);
1638 relays.finish();
1639 result
1640 }
1641
1642 pub fn supervise_captured(
1643 &self,
1644 spec: &CommandSpec,
1645 options: SupervisionOptions,
1646 writer: &mut dyn Write,
1647 ) -> Result<SupervisedOutput, SupervisionError> {
1648 validate_options(options)?;
1649 if spec.captured_output.is_some() {
1650 return Err(SupervisionError::PlatformOperation {
1651 operation: "configure separate captured process output",
1652 source: io::Error::new(
1653 io::ErrorKind::InvalidInput,
1654 "merged and separate capture cannot be requested together",
1655 ),
1656 });
1657 }
1658 if let Some(signal) = self.signals.received() {
1659 return Ok(SupervisedOutput {
1660 result: SupervisedResult {
1661 status: None,
1662 signal: Some(signal.raw()),
1663 timed_out: false,
1664 interrupted_signal: Some(signal),
1665 },
1666 stdout: Vec::new(),
1667 stderr: Vec::new(),
1668 });
1669 }
1670 let mut command = spec.command()?;
1671 command.stdout(Stdio::piped()).stderr(Stdio::piped());
1672 let (mut child, parent_death_guard) = spawn_contained(
1673 &mut command,
1674 &spec.program,
1675 self.watchdog_program.as_deref(),
1676 )?;
1677 let stdout = child.stdout.take().expect("piped stdout");
1678 let stderr = child.stderr.take().expect("piped stderr");
1679 let stdout_reader = thread::spawn(move || read_pipe(stdout));
1680 let stderr_reader = thread::spawn(move || read_pipe(stderr));
1681 let result = self.monitor(&mut child, options, writer);
1682 drop(parent_death_guard);
1685 let stdout = captured_bytes(stdout_reader, "stdout")?;
1686 let stderr = captured_bytes(stderr_reader, "stderr")?;
1687 Ok(SupervisedOutput {
1688 result: result?,
1689 stdout,
1690 stderr,
1691 })
1692 }
1693
1694 fn monitor(
1695 &self,
1696 child: &mut Child,
1697 options: SupervisionOptions,
1698 writer: &mut dyn Write,
1699 ) -> Result<SupervisedResult, SupervisionError> {
1700 let started = Instant::now();
1701 let mut next_diagnostic = started + options.diagnostic_interval;
1702 let timeout_at = options.timeout.map(|timeout| started + timeout);
1703 let mut termination: Option<(Instant, Option<ForwardedSignal>)> = None;
1704 let mut timed_out = false;
1705 let mut interrupted_signal = None;
1706 let mut escalated = false;
1707 let mut keyboard = Keyboard::default();
1708
1709 loop {
1710 let status = match child.try_wait() {
1711 Ok(status) => status,
1712 Err(error) => {
1713 signal_process_group(child, libc::SIGKILL);
1714 let _ = child.wait();
1715 return Err(SupervisionError::Wait(error));
1716 }
1717 };
1718 if let Some(status) = status {
1719 let (status, signal) = exit_parts(status);
1720 if keyboard.lent
1724 && interrupted_signal.is_none()
1725 && !timed_out
1726 && (signal == Some(libc::SIGINT) || status == Some(128 + libc::SIGINT))
1727 {
1728 interrupted_signal = Some(ForwardedSignal::Sigint);
1729 }
1730 keyboard.take_back();
1731 return Ok(SupervisedResult {
1732 status,
1733 signal,
1734 timed_out,
1735 interrupted_signal,
1736 });
1737 }
1738 if termination.is_none()
1739 && let Some(signal) = Keyboard::stopped(child)
1740 {
1741 keyboard.command_stopped(child, signal);
1742 }
1743 let now = Instant::now();
1744 if termination.is_none()
1745 && let Some(signal) = self.signals.received()
1746 {
1747 interrupted_signal = Some(signal);
1748 signal_process_group(child, signal.raw());
1749 termination = Some((now, Some(signal)));
1750 }
1751 if termination.is_none() && timeout_at.is_some_and(|deadline| now >= deadline) {
1752 timed_out = true;
1753 let _ = write_line(
1754 writer,
1755 &format!(
1756 "[supercov] command exceeded SUPERCOV_COMMAND_TIMEOUT_MS={}; terminating process group",
1757 options.timeout.expect("timeout deadline").as_millis()
1758 ),
1759 );
1760 signal_process_group(child, libc::SIGTERM);
1761 termination = Some((now, None));
1762 write_diagnostic(child, started, writer);
1763 }
1764 if now >= next_diagnostic && !timed_out {
1765 write_diagnostic(child, started, writer);
1766 while next_diagnostic <= now {
1767 next_diagnostic += options.diagnostic_interval;
1768 }
1769 }
1770 if !escalated
1771 && termination.is_some_and(|(terminated_at, _)| {
1772 now.duration_since(terminated_at) >= options.termination_grace
1773 })
1774 {
1775 signal_process_group(child, libc::SIGKILL);
1776 escalated = true;
1777 }
1778 thread::sleep(POLL_INTERVAL);
1779 }
1780 }
1781}
1782
1783#[cfg(windows)]
1784pub struct ProcessSupervisor {
1785 signals: SignalFlags,
1786 job: JobHandle,
1787}
1788
1789#[cfg(windows)]
1790impl ProcessSupervisor {
1791 pub fn new() -> Result<Self, SupervisionError> {
1792 Ok(Self {
1793 signals: SignalFlags::install()?,
1794 job: JobHandle::new()?,
1795 })
1796 }
1797
1798 pub fn new_crash_safe(_watchdog_program: &Path) -> Result<Self, SupervisionError> {
1799 Self::new()
1800 }
1801
1802 pub fn supervise(
1803 &self,
1804 spec: &CommandSpec,
1805 options: SupervisionOptions,
1806 writer: &mut dyn Write,
1807 ) -> Result<SupervisedResult, SupervisionError> {
1808 validate_options(options)?;
1809 if let Some(signal) = self.signals.received() {
1810 return Ok(SupervisedResult {
1811 status: None,
1812 signal: None,
1813 timed_out: false,
1814 interrupted_signal: Some(signal),
1815 });
1816 }
1817 let mut command = spec.command()?;
1818 let targets = Relays::request(&mut command, spec.console.as_ref());
1819 let mut child = command.spawn().map_err(|source| SupervisionError::Spawn {
1820 program: spec.program.clone(),
1821 source,
1822 })?;
1823 if let Err(error) = self.job.assign(&child) {
1824 let _ = child.kill();
1825 let _ = child.wait();
1826 return Err(error);
1827 }
1828 if let Err(error) = resume_suspended_process(child.id()) {
1829 self.job.terminate();
1830 let _ = child.wait();
1831 return Err(error);
1832 }
1833 drop(command);
1836 let relays = Relays::start(&mut child, targets, spec.console.as_ref());
1837 let result = self.monitor(&mut child, options, writer);
1838 relays.finish();
1839 result
1840 }
1841
1842 pub fn supervise_captured(
1843 &self,
1844 spec: &CommandSpec,
1845 options: SupervisionOptions,
1846 writer: &mut dyn Write,
1847 ) -> Result<SupervisedOutput, SupervisionError> {
1848 validate_options(options)?;
1849 if spec.captured_output.is_some() {
1850 return Err(SupervisionError::PlatformOperation {
1851 operation: "configure separate captured process output",
1852 source: io::Error::new(
1853 io::ErrorKind::InvalidInput,
1854 "merged and separate capture cannot be requested together",
1855 ),
1856 });
1857 }
1858 if let Some(signal) = self.signals.received() {
1859 return Ok(SupervisedOutput {
1860 result: SupervisedResult {
1861 status: None,
1862 signal: None,
1863 timed_out: false,
1864 interrupted_signal: Some(signal),
1865 },
1866 stdout: Vec::new(),
1867 stderr: Vec::new(),
1868 });
1869 }
1870 let mut command = spec.command()?;
1871 command.stdout(Stdio::piped()).stderr(Stdio::piped());
1872 let mut child = command.spawn().map_err(|source| SupervisionError::Spawn {
1873 program: spec.program.clone(),
1874 source,
1875 })?;
1876 if let Err(error) = self.job.assign(&child) {
1877 let _ = child.kill();
1878 let _ = child.wait();
1879 return Err(error);
1880 }
1881 if let Err(error) = resume_suspended_process(child.id()) {
1882 self.job.terminate();
1883 let _ = child.wait();
1884 return Err(error);
1885 }
1886 let stdout = child.stdout.take().expect("piped stdout");
1887 let stderr = child.stderr.take().expect("piped stderr");
1888 let stdout_reader = thread::spawn(move || read_pipe(stdout));
1889 let stderr_reader = thread::spawn(move || read_pipe(stderr));
1890 let result = self.monitor(&mut child, options, writer);
1891 let stdout = captured_bytes(stdout_reader, "stdout")?;
1892 let stderr = captured_bytes(stderr_reader, "stderr")?;
1893 Ok(SupervisedOutput {
1894 result: result?,
1895 stdout,
1896 stderr,
1897 })
1898 }
1899
1900 fn monitor(
1901 &self,
1902 child: &mut Child,
1903 options: SupervisionOptions,
1904 writer: &mut dyn Write,
1905 ) -> Result<SupervisedResult, SupervisionError> {
1906 let started = Instant::now();
1907 let mut next_diagnostic = started + options.diagnostic_interval;
1908 let timeout_at = options.timeout.map(|timeout| started + timeout);
1909 let mut termination: Option<Instant> = None;
1910 let mut timed_out = false;
1911 let mut interrupted_signal = None;
1912 let mut escalated = false;
1913
1914 loop {
1915 let status = match child.try_wait() {
1916 Ok(status) => status,
1917 Err(error) => {
1918 self.job.terminate();
1919 let _ = child.wait();
1920 return Err(SupervisionError::Wait(error));
1921 }
1922 };
1923 if let Some(status) = status {
1924 let (status, signal) = exit_parts(status);
1925 return Ok(SupervisedResult {
1926 status,
1927 signal,
1928 timed_out,
1929 interrupted_signal,
1930 });
1931 }
1932 let now = Instant::now();
1933 if termination.is_none()
1934 && let Some(signal) = self.signals.received()
1935 {
1936 interrupted_signal = Some(signal);
1937 forward_windows_control(child);
1938 termination = Some(now);
1939 }
1940 if termination.is_none() && timeout_at.is_some_and(|deadline| now >= deadline) {
1941 timed_out = true;
1942 let _ = write_line(
1943 writer,
1944 &format!(
1945 "[supercov] command exceeded SUPERCOV_COMMAND_TIMEOUT_MS={}; terminating process group",
1946 options.timeout.expect("timeout deadline").as_millis()
1947 ),
1948 );
1949 forward_windows_control(child);
1950 termination = Some(now);
1951 write_diagnostic(child, started, writer);
1952 }
1953 if now >= next_diagnostic && !timed_out {
1954 write_diagnostic(child, started, writer);
1955 while next_diagnostic <= now {
1956 next_diagnostic += options.diagnostic_interval;
1957 }
1958 }
1959 if !escalated
1960 && termination.is_some_and(|terminated_at| {
1961 now.duration_since(terminated_at) >= options.termination_grace
1962 })
1963 {
1964 self.job.terminate();
1965 escalated = true;
1966 }
1967 thread::sleep(POLL_INTERVAL);
1968 }
1969 }
1970}
1971
1972#[cfg(not(any(unix, windows)))]
1973pub struct ProcessSupervisor;
1974
1975#[cfg(not(any(unix, windows)))]
1976impl ProcessSupervisor {
1977 pub fn new() -> Result<Self, SupervisionError> {
1978 Err(SupervisionError::UnsupportedPlatform(
1979 "this target has no process-tree containment implementation",
1980 ))
1981 }
1982
1983 pub fn new_crash_safe(_watchdog_program: &Path) -> Result<Self, SupervisionError> {
1984 Self::new()
1985 }
1986
1987 pub fn supervise(
1988 &self,
1989 _spec: &CommandSpec,
1990 _options: SupervisionOptions,
1991 _writer: &mut dyn Write,
1992 ) -> Result<SupervisedResult, SupervisionError> {
1993 Err(SupervisionError::UnsupportedPlatform(
1994 "this target has no process-tree containment implementation",
1995 ))
1996 }
1997
1998 pub fn supervise_captured(
1999 &self,
2000 _spec: &CommandSpec,
2001 _options: SupervisionOptions,
2002 _writer: &mut dyn Write,
2003 ) -> Result<SupervisedOutput, SupervisionError> {
2004 Err(SupervisionError::UnsupportedPlatform(
2005 "this target has no process-tree containment implementation",
2006 ))
2007 }
2008}
2009
2010pub fn supervise_command(
2011 spec: &CommandSpec,
2012 options: SupervisionOptions,
2013 writer: &mut dyn Write,
2014) -> Result<SupervisedResult, SupervisionError> {
2015 ProcessSupervisor::new()?.supervise(spec, options, writer)
2016}
2017
2018pub fn supervise_captured_command(
2019 spec: &CommandSpec,
2020 options: SupervisionOptions,
2021 writer: &mut dyn Write,
2022) -> Result<SupervisedOutput, SupervisionError> {
2023 ProcessSupervisor::new()?.supervise_captured(spec, options, writer)
2024}
2025
2026#[cfg(test)]
2027mod tests {
2028 use super::*;
2029
2030 #[derive(Clone, Default)]
2032 struct Kept(std::sync::Arc<Mutex<Vec<u8>>>);
2033
2034 impl Write for Kept {
2035 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
2036 self.0.lock().unwrap().extend_from_slice(bytes);
2037 Ok(bytes.len())
2038 }
2039
2040 fn flush(&mut self) -> io::Result<()> {
2041 Ok(())
2042 }
2043 }
2044
2045 #[test]
2046 fn what_a_command_prints_names_the_project() {
2047 let names = ConsoleNames {
2051 places: vec![(
2052 "/home/me/shop/.supercov/workspaces/workspace/shop".into(),
2053 "/home/me/shop".into(),
2054 )],
2055 up_to_the_project: Some("../../../../".into()),
2056 };
2057 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";
2058 let kept = Kept::default();
2059 let ended = relay(
2060 io::Cursor::new(printed.as_bytes().to_vec()),
2061 kept.clone(),
2062 names,
2063 );
2064 ended
2065 .recv_timeout(Duration::from_secs(10))
2066 .expect("the relay ends with its input");
2067 assert_eq!(
2068 String::from_utf8(kept.0.lock().unwrap().clone()).unwrap(),
2069 "# 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"
2070 );
2071 }
2072
2073 #[cfg(unix)]
2074 #[test]
2075 fn a_command_on_a_terminal_still_writes_to_one_and_names_the_project() {
2076 use std::os::fd::{AsRawFd, FromRawFd};
2077 let (mut screen, mut users) = (-1, -1);
2079 let (mut screen, users) = unsafe {
2081 assert_eq!(
2082 libc::openpty(
2083 &mut screen,
2084 &mut users,
2085 std::ptr::null_mut(),
2086 std::ptr::null_mut::<libc::termios>(),
2087 std::ptr::null_mut::<libc::winsize>(),
2088 ),
2089 0
2090 );
2091 let size = libc::winsize {
2092 ws_row: 40,
2093 ws_col: 132,
2094 ws_xpixel: 0,
2095 ws_ypixel: 0,
2096 };
2097 libc::ioctl(users, libc::TIOCSWINSZ, &size);
2098 (
2099 std::fs::File::from_raw_fd(screen),
2100 std::fs::File::from_raw_fd(users),
2101 )
2102 };
2103 let (terminal, theirs) = Terminal::like(users).expect("a terminal like the user's");
2104 let names = ConsoleNames {
2105 places: vec![(
2106 "/home/me/shop/.supercov/workspaces/workspace/shop".into(),
2107 "/home/me/shop".into(),
2108 )],
2109 up_to_the_project: None,
2110 };
2111 let mut command = Command::new("sh");
2114 command
2115 .args([
2116 "-c",
2117 "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? '",
2118 ])
2119 .stdin(Stdio::null())
2120 .stdout(theirs.try_clone().unwrap())
2121 .stderr(theirs);
2122 let mut child = command.spawn().unwrap();
2123 drop(command);
2124 let ended = relay_terminal(terminal, names);
2125 assert!(child.wait().unwrap().success());
2126 ended
2127 .recv_timeout(Duration::from_secs(10))
2128 .expect("the relay ends when the command has gone");
2129 unsafe {
2131 let flags = libc::fcntl(screen.as_raw_fd(), libc::F_GETFL);
2132 libc::fcntl(screen.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK);
2133 }
2134 let mut shown = Vec::new();
2135 let mut buffer = [0_u8; 4096];
2136 while let Ok(read) = screen.read(&mut buffer) {
2137 if read == 0 {
2138 break;
2139 }
2140 shown.extend_from_slice(&buffer[..read]);
2141 }
2142 assert_eq!(
2144 String::from_utf8(shown).unwrap(),
2145 "a terminal 132 wide\r\nsee /home/me/shop/target/report\r\nInstall? "
2146 );
2147 }
2148
2149 fn scratch_directory(label: &str) -> PathBuf {
2150 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2151 let root = std::env::temp_dir().join(format!(
2152 "supercov-{label}-{}-{}-{}",
2153 std::process::id(),
2154 std::time::SystemTime::now()
2155 .duration_since(std::time::UNIX_EPOCH)
2156 .unwrap()
2157 .as_nanos(),
2158 COUNTER.fetch_add(1, Ordering::Relaxed)
2159 ));
2160 std::fs::create_dir(&root).unwrap();
2161 root
2162 }
2163
2164 #[test]
2165 fn resolves_programs_the_way_the_windows_shell_does() {
2166 let root = scratch_directory("program-resolution");
2167 std::fs::create_dir(root.join("sub")).unwrap();
2168 for name in ["tool.cmd", "tool.CMD", "named.exe", "plain", "sub/tool.cmd"] {
2172 std::fs::write(root.join(name), "").unwrap();
2173 }
2174 let path = Some(std::env::join_paths([root.join("elsewhere"), root.clone()]).unwrap());
2175 let pathext = Some(OsString::from(".exe;.cmd"));
2176 let resolve = |program: &str| {
2177 resolve_program_with(OsStr::new(program), &root, path.clone(), pathext.clone())
2178 };
2179
2180 assert_eq!(resolve("tool"), root.join("tool.cmd").into_os_string());
2181 assert_eq!(resolve("named"), root.join("named.exe").into_os_string());
2182 assert_eq!(
2183 resolve("named.exe"),
2184 root.join("named.exe").into_os_string()
2185 );
2186 assert_eq!(resolve("plain"), OsString::from("plain"));
2188 assert_eq!(resolve("missing"), OsString::from("missing"));
2189 assert_eq!(
2191 resolve("sub/tool"),
2192 root.join("sub/tool.cmd").into_os_string()
2193 );
2194 assert_eq!(
2195 resolve("./sub/tool"),
2196 root.join("./sub/tool.cmd").into_os_string()
2197 );
2198 let absolute = root.join("named.exe");
2199 assert_eq!(
2200 resolve_program_with(
2201 absolute.as_os_str(),
2202 Path::new("/nowhere"),
2203 None,
2204 pathext.clone()
2205 ),
2206 absolute.clone().into_os_string()
2207 );
2208 assert_eq!(
2210 resolve_program_with(OsStr::new("tool"), &root, path.clone(), None),
2211 root.join("tool.CMD").into_os_string()
2212 );
2213 std::fs::remove_dir_all(root).unwrap();
2214 }
2215
2216 #[cfg(windows)]
2217 #[test]
2218 fn starts_a_batch_shim_found_through_pathext() {
2219 let root = scratch_directory("batch-shim");
2220 std::fs::write(root.join("shim.cmd"), "@echo off\r\nexit /b 3\r\n").unwrap();
2221 let mut environment = vec![
2222 (OsString::from("Path"), root.clone().into_os_string()),
2223 (
2224 OsString::from("PATHEXT"),
2225 OsString::from(".COM;.EXE;.BAT;.CMD"),
2226 ),
2227 ];
2228 if let Some(system_root) = std::env::var_os("SystemRoot") {
2229 environment.push((OsString::from("SystemRoot"), system_root));
2230 }
2231 let spec = CommandSpec {
2232 program: "shim".into(),
2233 arguments: vec!["ignored".into()],
2234 cwd: std::env::current_dir().unwrap(),
2235 environment: Some(environment),
2236 captured_output: None,
2237 console: None,
2238 };
2239 let mut diagnostics = Vec::new();
2240 let result =
2241 supervise_command(&spec, SupervisionOptions::default(), &mut diagnostics).unwrap();
2242 assert_eq!(result.exit_code(), 3);
2243 std::fs::remove_dir_all(root).unwrap();
2244 }
2245
2246 #[test]
2247 fn parses_only_positive_integer_milliseconds() {
2248 assert_eq!(positive_milliseconds(None, "VALUE").unwrap(), None);
2249 assert_eq!(
2250 positive_milliseconds(Some("50"), "VALUE").unwrap(),
2251 Some(Duration::from_millis(50))
2252 );
2253 for value in ["0", "-1", "1.5", "NaN", " 1"] {
2254 assert!(positive_milliseconds(Some(value), "VALUE").is_err());
2255 }
2256 }
2257
2258 #[test]
2259 fn diagnostic_format_is_sanitized_and_reference_compatible() {
2260 let output = format_process_diagnostic(
2261 20,
2262 Duration::from_millis(61_000),
2263 &[ProcessSnapshot {
2264 pid: 20,
2265 parent_pid: 10,
2266 executable: "node".into(),
2267 state: Some("S".into()),
2268 cpu_tenths: Some(13),
2269 }],
2270 );
2271 assert_eq!(
2272 output,
2273 "[supercov] command still running after 1m01s\n pid=20 ppid=10 exe=node state=S cpu=1.3s"
2274 );
2275 assert!(!output.contains("argv"));
2276 }
2277
2278 #[cfg(unix)]
2279 #[test]
2280 fn returns_the_child_status_without_a_default_timeout() {
2281 let root = std::env::current_dir().unwrap();
2282 let spec = CommandSpec {
2283 program: "/bin/sh".into(),
2284 arguments: vec!["-c".into(), "exit 7".into()],
2285 cwd: root,
2286 environment: None,
2287 captured_output: None,
2288 console: None,
2289 };
2290 let mut diagnostics = Vec::new();
2291 let result =
2292 supervise_command(&spec, SupervisionOptions::default(), &mut diagnostics).unwrap();
2293 assert_eq!(result.exit_code(), 7);
2294 assert!(!result.timed_out);
2295 assert!(diagnostics.is_empty());
2296 }
2297
2298 #[cfg(unix)]
2299 #[test]
2300 fn captures_stdout_and_stderr_separately_without_losing_status() {
2301 let spec = CommandSpec {
2302 program: "/bin/sh".into(),
2303 arguments: vec![
2304 "-c".into(),
2305 "printf stdout-value; printf stderr-value >&2; exit 9".into(),
2306 ],
2307 cwd: std::env::current_dir().unwrap(),
2308 environment: None,
2309 captured_output: None,
2310 console: None,
2311 };
2312 let mut diagnostics = Vec::new();
2313 let output =
2314 supervise_captured_command(&spec, SupervisionOptions::default(), &mut diagnostics)
2315 .unwrap();
2316 assert_eq!(output.result.exit_code(), 9);
2317 assert_eq!(output.stdout, b"stdout-value");
2318 assert_eq!(output.stderr, b"stderr-value");
2319 assert!(diagnostics.is_empty());
2320 }
2321
2322 #[cfg(windows)]
2323 #[test]
2324 fn returns_the_windows_child_status_without_a_default_timeout() {
2325 let spec = CommandSpec {
2326 program: "cmd.exe".into(),
2327 arguments: vec!["/D".into(), "/S".into(), "/C".into(), "exit /b 7".into()],
2328 cwd: std::env::current_dir().unwrap(),
2329 environment: None,
2330 captured_output: None,
2331 console: None,
2332 };
2333 let mut diagnostics = Vec::new();
2334 let result =
2335 supervise_command(&spec, SupervisionOptions::default(), &mut diagnostics).unwrap();
2336 assert_eq!(result.exit_code(), 7);
2337 assert!(!result.timed_out);
2338 assert!(diagnostics.is_empty());
2339 }
2340
2341 #[cfg(unix)]
2342 #[test]
2343 fn explicit_timeout_reports_and_returns_124() {
2344 let root = std::env::current_dir().unwrap();
2345 let spec = CommandSpec {
2346 program: "/bin/sh".into(),
2347 arguments: vec!["-c".into(), "while :; do sleep 1; done".into()],
2348 cwd: root,
2349 environment: None,
2350 captured_output: None,
2351 console: None,
2352 };
2353 let mut diagnostics = Vec::new();
2354 let result = supervise_command(
2355 &spec,
2356 SupervisionOptions {
2357 diagnostic_interval: Duration::from_millis(20),
2358 timeout: Some(Duration::from_millis(70)),
2359 termination_grace: Duration::from_millis(50),
2360 },
2361 &mut diagnostics,
2362 )
2363 .unwrap();
2364 let diagnostics = String::from_utf8(diagnostics).unwrap();
2365 assert_eq!(result.exit_code(), COMMAND_TIMEOUT_EXIT_CODE);
2366 assert!(result.timed_out);
2367 assert!(diagnostics.contains("command still running after"));
2368 assert!(diagnostics.contains("SUPERCOV_COMMAND_TIMEOUT_MS=70"));
2369 }
2370
2371 #[cfg(windows)]
2372 #[test]
2373 fn timeout_terminates_the_complete_windows_job() {
2374 use std::{
2375 fs,
2376 time::{SystemTime, UNIX_EPOCH},
2377 };
2378
2379 let unique = SystemTime::now()
2380 .duration_since(UNIX_EPOCH)
2381 .unwrap()
2382 .as_nanos();
2383 let root = std::env::temp_dir().join(format!(
2384 "supercov-windows-job-{}-{unique}",
2385 std::process::id()
2386 ));
2387 fs::create_dir_all(&root).unwrap();
2388 struct RemoveOnDrop(PathBuf);
2389 impl Drop for RemoveOnDrop {
2390 fn drop(&mut self) {
2391 let _ = fs::remove_dir_all(&self.0);
2392 }
2393 }
2394 let _cleanup = RemoveOnDrop(root.clone());
2395 let ready = root.join("descendant-ready");
2396 let marker = root.join("descendant-survived");
2397 let mut environment = std::env::vars_os().collect::<Vec<_>>();
2398 environment.extend([
2399 ("SUPERCOV_WINDOWS_PARENT_HELPER".into(), "1".into()),
2400 ("SUPERCOV_WINDOWS_READY".into(), ready.as_os_str().into()),
2401 ("SUPERCOV_WINDOWS_MARKER".into(), marker.as_os_str().into()),
2402 ]);
2403 let spec = CommandSpec {
2404 program: std::env::current_exe().unwrap().into_os_string(),
2405 arguments: vec![
2406 "--ignored".into(),
2407 "windows_timeout_parent_helper".into(),
2408 "--nocapture".into(),
2409 ],
2410 cwd: root,
2411 environment: Some(environment),
2412 captured_output: None,
2413 console: None,
2414 };
2415 let mut diagnostics = Vec::new();
2416 let result = supervise_command(
2417 &spec,
2418 SupervisionOptions {
2419 diagnostic_interval: Duration::from_secs(60),
2420 timeout: Some(Duration::from_millis(750)),
2421 termination_grace: Duration::from_millis(50),
2422 },
2423 &mut diagnostics,
2424 )
2425 .unwrap();
2426
2427 assert!(result.timed_out);
2428 assert_eq!(result.exit_code(), COMMAND_TIMEOUT_EXIT_CODE);
2429 assert!(
2430 ready.exists(),
2431 "the helper did not prove that its descendant started before timeout"
2432 );
2433 thread::sleep(Duration::from_millis(1_700));
2434 assert!(
2435 !marker.exists(),
2436 "a descendant escaped the Windows Job Object after timeout"
2437 );
2438 assert!(
2439 String::from_utf8(diagnostics)
2440 .unwrap()
2441 .contains("terminating process group")
2442 );
2443 }
2444
2445 #[cfg(windows)]
2446 #[test]
2447 #[ignore = "subprocess helper for timeout_terminates_the_complete_windows_job"]
2448 fn windows_timeout_parent_helper() {
2449 use std::fs;
2450
2451 if std::env::var_os("SUPERCOV_WINDOWS_PARENT_HELPER").is_none() {
2452 return;
2453 }
2454 let mut child = Command::new(std::env::current_exe().unwrap())
2455 .args(["--ignored", "windows_timeout_marker_helper", "--nocapture"])
2456 .stdin(Stdio::null())
2457 .stdout(Stdio::null())
2458 .stderr(Stdio::null())
2459 .spawn()
2460 .unwrap();
2461 fs::write(
2462 std::env::var_os("SUPERCOV_WINDOWS_READY").unwrap(),
2463 child.id().to_string(),
2464 )
2465 .unwrap();
2466 child.wait().unwrap();
2467 }
2468
2469 #[cfg(windows)]
2470 #[test]
2471 #[ignore = "subprocess helper for timeout_terminates_the_complete_windows_job"]
2472 fn windows_timeout_marker_helper() {
2473 use std::fs;
2474
2475 if std::env::var_os("SUPERCOV_WINDOWS_PARENT_HELPER").is_none() {
2476 return;
2477 }
2478 thread::sleep(Duration::from_millis(1_500));
2479 fs::write(
2480 std::env::var_os("SUPERCOV_WINDOWS_MARKER").unwrap(),
2481 b"escaped",
2482 )
2483 .unwrap();
2484 }
2485
2486 #[cfg(unix)]
2487 #[test]
2488 fn diagnostic_write_failures_never_change_the_child_result() {
2489 struct BrokenWriter;
2490 impl Write for BrokenWriter {
2491 fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
2492 Err(io::Error::new(
2493 io::ErrorKind::BrokenPipe,
2494 "closed diagnostic stream",
2495 ))
2496 }
2497
2498 fn flush(&mut self) -> io::Result<()> {
2499 Ok(())
2500 }
2501 }
2502
2503 let spec = CommandSpec {
2504 program: "/bin/sh".into(),
2505 arguments: vec!["-c".into(), "sleep 0.05; exit 0".into()],
2506 cwd: std::env::current_dir().unwrap(),
2507 environment: None,
2508 captured_output: None,
2509 console: None,
2510 };
2511 let result = supervise_command(
2512 &spec,
2513 SupervisionOptions {
2514 diagnostic_interval: Duration::from_millis(10),
2515 timeout: None,
2516 termination_grace: Duration::from_millis(50),
2517 },
2518 &mut BrokenWriter,
2519 )
2520 .unwrap();
2521 assert_eq!(result.exit_code(), 0);
2522 }
2523}