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, 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 .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 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 let mut action = unsafe { std::mem::zeroed::<libc::sigaction>() };
909 action.sa_sigaction = record_signal as *const () as usize;
910 unsafe { libc::sigemptyset(&mut action.sa_mask) };
912 action.sa_flags = 0;
913 let mut old = unsafe { std::mem::zeroed::<libc::sigaction>() };
915 if unsafe { libc::sigaction(signal, &action, &mut old) } != 0 {
918 for (installed, old) in previous.iter().rev() {
919 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 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 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 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 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#[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 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 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 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 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 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 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 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 let _ = unsafe { GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, child.id()) };
1202}
1203
1204#[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 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 let read = unsafe { OwnedFd::from_raw_fd(descriptors[0]) };
1225 let write = unsafe { OwnedFd::from_raw_fd(descriptors[1]) };
1227 for descriptor in [read.as_raw_fd(), write.as_raw_fd()] {
1228 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 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 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 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 let _ = unsafe { libc::close(descriptor) };
1354 }
1355 let ready = [1_u8];
1356 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 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#[cfg(unix)]
1398#[derive(Default)]
1399struct Keyboard {
1400 terminal: Option<fs::File>,
1401 lent: bool,
1402}
1403
1404#[cfg(unix)]
1405impl Keyboard {
1406 fn stopped(child: &Child) -> Option<i32> {
1408 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 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 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 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 return false;
1467 }
1468 self.lent = true;
1469 }
1470 libc::SIGTSTP if self.lent => {
1474 self.take_back();
1475 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 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 let group_result = unsafe { libc::kill(-pid, signal) };
1508 if group_result != 0 {
1509 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
1525fn 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 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 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 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 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 #[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 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 let (mut screen, mut users) = (-1, -1);
2090 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 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 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 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 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 assert_eq!(resolve("plain"), OsString::from("plain"));
2199 assert_eq!(resolve("missing"), OsString::from("missing"));
2200 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 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}