Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ feat_os_unix_musl = [
"feat_require_unix_utmpx",
]
# "feat_os_windows" == set of utilities which can be built/run on modern windows platforms
feat_os_windows = ["feat_Tier1", "stdbuf"]
feat_os_windows = ["feat_Tier1", "stdbuf", "timeout"]
## (secondary platforms) feature sets
# "feat_os_unix_gnueabihf" == set of utilities which can be built/run on the "arm-unknown-linux-gnueabihf" target (ARMv6 Linux [hardfloat])
feat_os_unix_gnueabihf = [
Expand Down
2 changes: 1 addition & 1 deletion src/uu/timeout/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ doctest = false

[dependencies]
clap = { workspace = true }
libc = { workspace = true }
uucore = { workspace = true, features = ["parser", "process", "signals"] }
fluent = { workspace = true }

[target.'cfg(unix)'.dependencies]
libc = { workspace = true }
rustix = { workspace = true, features = ["process"] }

[[bin]]
Expand Down
69 changes: 65 additions & 4 deletions src/uu/timeout/benches/timeout_bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,23 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.

#[cfg(unix)]
use divan::{Bencher, black_box};
#[cfg(unix)]
use uu_timeout::uumain;
#[cfg(unix)]
use uucore::benchmark::run_util_function;

/// First-arg marker that re-runs this bench binary as its own child command,
/// a portable `sleep`/`true` stand-in (`cargo bench` builds no other binary
/// and Windows lacks both in `PATH`).
const CHILD_MARKER: &str = "__timeout_bench_child";

#[cfg(windows)]
fn self_exe() -> String {
std::env::current_exe()
.unwrap()
.to_string_lossy()
.into_owned()
}

/// Benchmark the fast path where the command exits immediately.
#[cfg(unix)]
#[divan::bench]
Expand All @@ -19,6 +29,19 @@ fn timeout_quick_exit(bencher: Bencher) {
});
}

/// Benchmark the fast path where the command exits immediately.
#[cfg(windows)]
#[divan::bench]
fn timeout_quick_exit(bencher: Bencher) {
let exe = self_exe();
bencher.bench(|| {
black_box(run_util_function(
uumain,
&["0.02", &exe, CHILD_MARKER, "0"],
));
});
}

/// Benchmark a command that runs longer than the threshold and receives the default signal.
#[cfg(unix)]
#[divan::bench]
Expand All @@ -28,7 +51,45 @@ fn timeout_enforced(bencher: Bencher) {
});
}

/// Benchmark a command that runs longer than the threshold: timer expiry,
/// job-object tree kill and exit-code plumbing.
#[cfg(windows)]
#[divan::bench]
fn timeout_enforced(bencher: Bencher) {
let exe = self_exe();
bencher.bench(|| {
black_box(run_util_function(
uumain,
&["0.02", &exe, CHILD_MARKER, "0.2"],
));
});
}

/// Track timeout-firing latency across small durations; a regression from the
/// 100 ns waitable timer to a coarser wait (the 15.6 ms scheduler tick, or
/// polling) shows up as a step change across the arguments.
#[cfg(windows)]
#[divan::bench(args = ["0.001", "0.005", "0.02"])]
fn timer_expiry_latency(bencher: Bencher, duration: &str) {
let exe = self_exe();
bencher.bench(|| {
black_box(run_util_function(
uumain,
&[duration, &exe, CHILD_MARKER, "5"],
));
});
}

fn main() {
#[cfg(unix)]
// When re-invoked with CHILD_MARKER, act as a tiny `sleep` replacement.
let mut args = std::env::args().skip(1);
if args.next().as_deref() == Some(CHILD_MARKER) {
if let Some(secs) = args.next().and_then(|s| s.parse::<f64>().ok()) {
if secs > 0.0 {
std::thread::sleep(std::time::Duration::from_secs_f64(secs));
}
}
return;
}
divan::main();
}
19 changes: 19 additions & 0 deletions src/uu/timeout/src/platform/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// This file is part of the uutils coreutils package.
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.

//! Platform-specific pieces of `timeout`: process-group / job setup, signal
//! sending and signal-forwarding state. The shared control flow in
//! `timeout.rs` only talks to the facade functions re-exported here, which
//! both submodules provide with identical signatures.

#[cfg(unix)]
mod unix;
#[cfg(unix)]
pub(crate) use unix::*;

#[cfg(windows)]
mod windows;
#[cfg(windows)]
pub(crate) use windows::*;
158 changes: 158 additions & 0 deletions src/uu/timeout/src/platform/unix.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// This file is part of the uutils coreutils package.
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.

// spell-checker:ignore (ToDO) sigstr setpgid sigchld getpid TTIN TTOU

use std::os::unix::process::CommandExt;
use std::os::unix::process::ExitStatusExt;
use std::process::Child;
use std::sync::atomic;

use rustix::process::{Pid, Signal, getpid, kill_process, setpgid};
use uucore::process::ChildExt;
use uucore::signals::{install_signal_handler, signal_by_name_or_value};

/// Install SIGCHLD handler to ensure waiting for child works even if parent ignored SIGCHLD.
fn install_sigchld() {
extern "C" fn chld(_: libc::c_int) {}
let _ = install_signal_handler(Signal::as_raw(Signal::CHILD), chld);
}

/// Install signal handlers for termination signals.
fn install_signal_handlers(term_signal: usize) {
extern "C" fn handle_signal(sig: libc::c_int) {
crate::SIGNALED.store(true, atomic::Ordering::Relaxed);
crate::RECEIVED_SIGNAL.store(sig, atomic::Ordering::Relaxed);
}

let sigpipe_ignored = uucore::signals::sigpipe_was_ignored();

for sig in [
Signal::ALARM,
Signal::INT,
Signal::QUIT,
Signal::HUP,
Signal::TERM,
Signal::PIPE,
Signal::USR1,
Signal::USR2,
] {
if sig == Signal::PIPE && sigpipe_ignored {
continue; // Skip SIGPIPE if it was ignored by parent
}
let _ = install_signal_handler(Signal::as_raw(sig), handle_signal);
}

if let Some(sig) = signal_from_raw(term_signal as i32) {
let _ = install_signal_handler(Signal::as_raw(sig), handle_signal);
}
}

fn signal_from_raw(sig: i32) -> Option<Signal> {
if sig <= 0 {
return None;
}
// Fast path: standard named signals (SIGHUP, SIGTERM, SIGKILL, etc.)
if let Some(s) = Signal::from_named_raw(sig) {
return Some(s);
}
// Slow path: realtime signals (SIGRTMIN..=SIGRTMAX).
#[cfg(target_os = "linux")]
{
let rtmin = libc::SIGRTMIN();
let rtmax = libc::SIGRTMAX();
if sig >= rtmin && sig <= rtmax {
return Some(unsafe { Signal::from_raw_unchecked(sig) });
}
}

None
}

/// Configure our own process group, the child's spawn attributes and the
/// signal handlers, right before the child is spawned.
pub(crate) fn prepare(cmd_builder: &mut std::process::Command, foreground: bool, signal: usize) {
if !foreground {
let _ = setpgid(Pid::from_raw(0), Pid::from_raw(0));
}

{
#[cfg(target_os = "linux")]
let death_sig = signal_from_raw(signal as i32);
let sigpipe_was_ignored = uucore::signals::sigpipe_was_ignored();
let stdin_was_closed = uucore::signals::stdin_was_closed();

unsafe {
cmd_builder.pre_exec(move || {
// Reset terminal signals to default
let _ = libc::signal(Signal::as_raw(Signal::TTIN), libc::SIG_DFL);
let _ = libc::signal(Signal::as_raw(Signal::TTOU), libc::SIG_DFL);
// Preserve SIGPIPE ignore status if parent had it ignored
if sigpipe_was_ignored {
let _ = libc::signal(Signal::as_raw(Signal::PIPE), libc::SIG_IGN);
}
// If stdin was closed before Rust reopened it as /dev/null, close it in child
if stdin_was_closed {
libc::close(libc::STDIN_FILENO);
}
#[cfg(target_os = "linux")]
let _ = rustix::process::set_parent_process_death_signal(death_sig);
Ok(())
});
}
}

install_sigchld();
install_signal_handlers(signal);
}

/// Nothing to do after spawning on unix.
pub(crate) fn post_spawn(_child: &Child, _foreground: bool) {}

pub(crate) fn send_signal(process: &mut Child, signal: usize, foreground: bool) {
// NOTE: GNU timeout doesn't check for errors of signal.
// The subprocess might have exited just after the timeout.
let _ = process.send_signal(signal);
if signal == 0 || foreground {
return;
}
let _ = process.send_signal_group(signal);
let kill_signal = signal_by_name_or_value("KILL").unwrap();
let continued_signal = signal_by_name_or_value("CONT").unwrap();
if signal != kill_signal && signal != continued_signal {
let _ = process.send_signal(continued_signal);
let _ = process.send_signal_group(continued_signal);
}
}

/// The termination signal received by timeout itself while waiting, if any.
pub(crate) fn external_signal() -> Option<usize> {
let received_sig = crate::RECEIVED_SIGNAL.load(atomic::Ordering::Relaxed);
(received_sig > 0 && received_sig != libc::SIGALRM).then_some(received_sig as usize)
}

/// The signal the child was terminated by, if it was terminated by a signal.
pub(crate) fn status_signal(status: std::process::ExitStatus) -> Option<i32> {
status.signal()
}

pub(crate) fn preserve_signal_info(signal: libc::c_int) -> libc::c_int {
// This is needed because timeout is expected to preserve the exit
// status of its child. It is not the case that utilities have a
// single simple exit code, that's an illusion some shells
// provide. Instead exit status is really two numbers:
//
// - An exit code if the program ran to completion
//
// - A signal number if the program was terminated by a signal
//
// The easiest way to preserve the latter seems to be to kill
// ourselves with whatever signal our child exited with, which is
// what the following is intended to accomplish.
if let Some(sig) = signal_from_raw(signal) {
let _ = kill_process(getpid(), sig);
}
signal
}
101 changes: 101 additions & 0 deletions src/uu/timeout/src/platform/windows.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// This file is part of the uutils coreutils package.
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.

// spell-checker:ignore catchable forwardable

//! Windows implementation of `timeout`'s platform facade, built on the signal
//! emulation in [`uucore::process`] (which maps POSIX signal numbers to
//! Windows primitives). Non-foreground mode spawns the child in a new console
//! process group tracked by a Job Object, so a lethal signal kills the whole
//! tree and `INT`/`QUIT` arrive as a catchable `CTRL_BREAK_EVENT`.

use std::process::Child;
use std::sync::Mutex;

use uucore::process::{
Job, configure_process_group, enable_ctrl_forwarding, last_ctrl_signal,
send_signal_to_console_group, send_signal_to_process, send_signal_to_tree,
};

const SIGNAL_INT: usize = 2;
const SIGNAL_QUIT: usize = 3;

/// The job object tracking the child's process tree (non-foreground mode
/// only; `None` also when job assignment failed and we degraded to
/// per-process signalling). Replaced on every spawn so that repeated
/// in-process invocations of `uumain` (benchmarks, fuzzing) never signal a
/// previous run's job.
static JOB: Mutex<Option<Job>> = Mutex::new(None);

/// Configure the child's spawn attributes and console-event forwarding, right
/// before the child is spawned.
pub(crate) fn prepare(cmd_builder: &mut std::process::Command, foreground: bool, _signal: usize) {
// Record Ctrl-C/Ctrl-Break/console-close as forwardable signal numbers
// instead of dying to them (the unix code installs signal handlers here).
let _ = enable_ctrl_forwarding();

if !foreground {
// The analog of unix setpgid(0, 0): the child leads its own console
// group, so the console's Ctrl-C no longer reaches it directly and
// CTRL_BREAK can target exactly its group.
configure_process_group(cmd_builder);
}
}

/// Put the freshly spawned child in a job so a lethal signal can terminate
/// its entire process tree. Degrades silently to per-process signalling when
/// jobs are unavailable (mirroring the ignored `setpgid` result on unix).
///
/// A child that spawns a grandchild before the assignment completes escapes
/// the job; the window is sub-millisecond and unix has the analogous escape
/// (a child can leave the process group via setpgid).
pub(crate) fn post_spawn(child: &Child, foreground: bool) {
if foreground {
return;
}
let job = Job::new().ok().filter(|job| job.assign(child).is_ok());
*JOB.lock().unwrap() = job;
}

pub(crate) fn send_signal(process: &mut Child, signal: usize, foreground: bool) {
// GNU timeout ignores signal-send errors (the child may have just exited),
// hence the discarded results below.
if foreground {
if matches!(signal, SIGNAL_INT | SIGNAL_QUIT) && last_ctrl_signal() == Some(signal) {
// A foreground child shares our console group: the console already
// delivered this externally received event to it, and re-sending
// cannot be targeted without hitting ourselves.
return;
}
let _ = send_signal_to_process(process, signal);
} else if matches!(signal, SIGNAL_INT | SIGNAL_QUIT) {
// Deliverable as a catchable CTRL_BREAK to the child's group; without
// a console, fall back to termination so the signal is never lost.
if send_signal_to_console_group(process.id(), signal).is_err() {
let _ = send_signal_to_tree(process, JOB.lock().unwrap().as_ref(), signal);
}
} else {
let _ = send_signal_to_tree(process, JOB.lock().unwrap().as_ref(), signal);
}
}

/// The signal number of the console event received by timeout itself while
/// waiting, if any.
pub(crate) fn external_signal() -> Option<usize> {
last_ctrl_signal()
}

/// Windows exit statuses carry no signal information: terminated children
/// report their forced `128 + signal` value through `status.code()` instead.
pub(crate) fn status_signal(_status: std::process::ExitStatus) -> Option<i32> {
None
}

/// No two-channel exit status exists on Windows; the exit code already
/// carries the signal information (`128 + signal`), so there is nothing to
/// preserve by re-signalling ourselves like the unix implementation does.
pub(crate) fn preserve_signal_info(signal: i32) -> i32 {
signal
}
Loading
Loading