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