use std::io;
use std::process::{Child, ExitStatus};
use std::time::{Duration, Instant};
const POLL_INTERVAL: Duration = Duration::from_millis(10);
pub(super) fn wait_timeout(child: &mut Child, timeout: Duration) -> io::Result<Option<ExitStatus>> {
let Some(deadline) = Instant::now().checked_add(timeout) else {
return child.wait().map(Some);
};
match ExitNotifier::new(child) {
Some(notifier) => wait_until(child, deadline, |remaining| notifier.wait(remaining)),
None => wait_polling(child, deadline),
}
}
fn wait_polling(child: &mut Child, deadline: Instant) -> io::Result<Option<ExitStatus>> {
wait_until(child, deadline, |remaining| {
std::thread::sleep(remaining.min(POLL_INTERVAL));
Ok(())
})
}
fn wait_until(
child: &mut Child,
deadline: Instant,
mut block: impl FnMut(Duration) -> io::Result<()>,
) -> io::Result<Option<ExitStatus>> {
loop {
if let Some(status) = child.try_wait()? {
return Ok(Some(status));
}
let now = Instant::now();
if now >= deadline {
return Ok(None);
}
block(deadline - now)?;
}
}
#[cfg(any(target_os = "linux", target_os = "android"))]
use linux::ExitNotifier;
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
use kqueue::ExitNotifier;
#[cfg(windows)]
use windows::ExitNotifier;
#[cfg(not(any(
target_os = "linux",
target_os = "android",
target_os = "macos",
target_os = "freebsd",
windows
)))]
use fallback::ExitNotifier;
#[cfg(any(target_os = "linux", target_os = "android", windows))]
fn timeout_millis_ceil(remaining: Duration) -> u128 {
remaining.as_nanos().div_ceil(1_000_000)
}
#[cfg(any(target_os = "linux", target_os = "android"))]
mod linux {
use std::io;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use std::process::Child;
use std::time::Duration;
pub(super) struct ExitNotifier {
pidfd: OwnedFd,
}
impl ExitNotifier {
pub(super) fn new(child: &Child) -> Option<Self> {
let pid = libc::pid_t::try_from(child.id()).ok()?;
let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) };
if fd < 0 {
return None;
}
Some(Self {
pidfd: unsafe { OwnedFd::from_raw_fd(fd as libc::c_int) },
})
}
pub(super) fn wait(&self, remaining: Duration) -> io::Result<()> {
let mut fds = libc::pollfd {
fd: self.pidfd.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
let millis = libc::c_int::try_from(super::timeout_millis_ceil(remaining)).unwrap_or(libc::c_int::MAX);
if unsafe { libc::poll(&raw mut fds, 1, millis) } < 0 {
let err = io::Error::last_os_error();
if err.kind() != io::ErrorKind::Interrupted {
return Err(err);
}
}
Ok(())
}
}
}
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
mod kqueue {
use std::cell::Cell;
use std::io;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use std::process::Child;
use std::time::Duration;
const REAP_INTERVAL: Duration = Duration::from_millis(1);
pub(super) struct ExitNotifier {
kq: OwnedFd,
exit_reported: Cell<bool>,
}
impl ExitNotifier {
pub(super) fn new(child: &Child) -> Option<Self> {
let raw = unsafe { libc::kqueue() };
if raw < 0 {
return None;
}
let kq = unsafe { OwnedFd::from_raw_fd(raw) };
let mut change: libc::kevent = unsafe { std::mem::zeroed() };
change.ident = child.id() as _;
change.filter = libc::EVFILT_PROC;
change.flags = libc::EV_ADD | libc::EV_ONESHOT;
change.fflags = libc::NOTE_EXIT;
let registered = unsafe {
libc::kevent(
kq.as_raw_fd(),
&raw const change,
1,
std::ptr::null_mut(),
0,
std::ptr::null(),
)
};
(registered == 0).then_some(Self {
kq,
exit_reported: Cell::new(false),
})
}
pub(super) fn wait(&self, remaining: Duration) -> io::Result<()> {
if self.exit_reported.get() {
std::thread::sleep(remaining.min(REAP_INTERVAL));
return Ok(());
}
let timeout = libc::timespec {
tv_sec: libc::time_t::try_from(remaining.as_secs()).unwrap_or(libc::time_t::MAX),
tv_nsec: remaining.subsec_nanos() as _,
};
let mut event: libc::kevent = unsafe { std::mem::zeroed() };
let received = unsafe {
libc::kevent(
self.kq.as_raw_fd(),
std::ptr::null(),
0,
&raw mut event,
1,
&raw const timeout,
)
};
if received > 0 {
self.exit_reported.set(true);
} else if received < 0 {
let err = io::Error::last_os_error();
if err.kind() != io::ErrorKind::Interrupted {
return Err(err);
}
}
Ok(())
}
}
}
#[cfg(windows)]
mod windows {
use std::io;
use std::os::windows::io::AsRawHandle;
use std::process::Child;
use std::time::Duration;
use windows_sys::Win32::Foundation::{WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT};
use windows_sys::Win32::System::Threading::{INFINITE, WaitForSingleObject};
pub(super) struct ExitNotifier {
handle: windows_sys::Win32::Foundation::HANDLE,
}
impl ExitNotifier {
#[expect(
clippy::unnecessary_wraps,
reason = "shares the signature of the other platforms' notifiers, which can fail"
)]
pub(super) fn new(child: &Child) -> Option<Self> {
Some(Self {
handle: child.as_raw_handle(),
})
}
pub(super) fn wait(&self, remaining: Duration) -> io::Result<()> {
let millis =
u32::try_from(super::timeout_millis_ceil(remaining)).map_or(INFINITE - 1, |ms| ms.min(INFINITE - 1));
match unsafe { WaitForSingleObject(self.handle, millis) } {
WAIT_OBJECT_0 | WAIT_TIMEOUT => Ok(()),
WAIT_FAILED => Err(io::Error::last_os_error()),
other => Err(io::Error::other(format!("unexpected wait result {other}"))),
}
}
}
}
#[cfg(not(any(
target_os = "linux",
target_os = "android",
target_os = "macos",
target_os = "freebsd",
windows
)))]
mod fallback {
use std::io;
use std::process::Child;
use std::time::Duration;
pub(super) enum ExitNotifier {}
impl ExitNotifier {
pub(super) fn new(_child: &Child) -> Option<Self> {
None
}
pub(super) fn wait(&self, _remaining: Duration) -> io::Result<()> {
match *self {}
}
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::process::{Command, Stdio};
fn spawn(script: &str) -> Child {
Command::new("sh")
.args(["-c", script])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("sh should spawn")
}
#[test]
#[cfg(any(
target_os = "linux",
target_os = "android",
target_os = "macos",
target_os = "freebsd"
))]
fn test_platform_has_an_exit_notifier() {
let mut child = spawn("exec sleep 30");
let notifier = ExitNotifier::new(&child);
let expected = platform_supports_notifier(&child);
child.kill().unwrap();
child.wait().unwrap();
if expected {
assert!(notifier.is_some(), "no exit notifier on this platform");
}
}
#[cfg(any(target_os = "linux", target_os = "android"))]
fn platform_supports_notifier(child: &Child) -> bool {
let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, child.id() as libc::pid_t, 0) };
if fd < 0 {
return false;
}
unsafe { libc::close(fd as libc::c_int) };
true
}
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
fn platform_supports_notifier(_child: &Child) -> bool {
true
}
#[test]
fn test_returns_the_exit_status_of_a_child_that_exits() {
let mut child = spawn("exit 7");
let status = wait_timeout(&mut child, Duration::from_secs(30)).unwrap();
assert_eq!(status.and_then(|s| s.code()), Some(7));
}
#[test]
fn test_returns_none_and_leaves_the_child_running_at_the_timeout() {
let mut child = spawn("exec sleep 30");
let started = Instant::now();
let status = wait_timeout(&mut child, Duration::from_millis(100)).unwrap();
let elapsed = started.elapsed();
assert!(status.is_none(), "expected a timeout, got {status:?}");
assert!(
elapsed >= Duration::from_millis(100),
"returned early after {elapsed:?}"
);
assert!(child.try_wait().unwrap().is_none());
child.kill().unwrap();
child.wait().unwrap();
}
#[test]
fn test_collects_a_child_that_exited_before_the_wait_began() {
let mut child = spawn("exit 3");
std::thread::sleep(Duration::from_millis(200));
let status = wait_timeout(&mut child, Duration::from_secs(30)).unwrap();
assert_eq!(status.and_then(|s| s.code()), Some(3));
}
#[test]
fn test_a_child_exiting_mid_wait_ends_the_wait_before_the_timeout() {
let mut child = spawn("sleep 0.2; exit 5");
let started = Instant::now();
let status = wait_timeout(&mut child, Duration::from_secs(30)).unwrap();
assert_eq!(status.and_then(|s| s.code()), Some(5));
assert!(
started.elapsed() < Duration::from_secs(20),
"waited {:?}",
started.elapsed()
);
}
#[test]
fn test_every_quick_exit_is_collected_without_waiting_for_the_deadline() {
for run in 0..1000 {
let mut child = spawn("exit 0");
let started = Instant::now();
let status = wait_timeout(&mut child, Duration::from_secs(30)).unwrap();
assert_eq!(status.and_then(|s| s.code()), Some(0));
let waited = started.elapsed();
assert!(waited < Duration::from_secs(10), "run {run} waited {waited:?}");
}
}
#[test]
fn test_an_unrepresentable_timeout_waits_for_exit() {
let mut child = spawn("exit 4");
let status = wait_timeout(&mut child, Duration::MAX).unwrap();
assert_eq!(status.and_then(|s| s.code()), Some(4));
}
#[test]
fn test_polling_fallback_returns_status_and_times_out() {
let mut exits = spawn("exit 6");
let deadline = Instant::now() + Duration::from_secs(30);
assert_eq!(
wait_polling(&mut exits, deadline).unwrap().and_then(|s| s.code()),
Some(6)
);
let mut hangs = spawn("exec sleep 30");
let deadline = Instant::now() + Duration::from_millis(50);
assert!(wait_polling(&mut hangs, deadline).unwrap().is_none());
hangs.kill().unwrap();
hangs.wait().unwrap();
}
}