How to run a command with a timeout
Short answer: Child::wait has no timeout parameter — once called, it blocks for as long as the child runs. The standard-library pattern is a poll loop: try_wait() returns Ok(None) while the child is still running and Ok(Some(status)) once it has exited, so check it on a thread::sleep interval against an Instant deadline. When the deadline passes, child.kill() forces the child out — SIGKILL on Unix, which the child cannot catch — and a final wait() reaps it. kill() alone leaves a zombie behind; drop() alone leaves the child running.
Everything below was compiled and run with rustc 1.99.0, edition 2024, on macOS, and behaves the same on Linux. The child in every example is sleep, so the timings are exact: a five-second child really blocks for five seconds.
The shell has a tool for this — some shells
Section titled “The shell has a tool for this — some shells”Linux distributions ship GNU coreutils’ timeout, which kills a command after a deadline. macOS does not ship it:
command -v timeout || echo "no timeout command"no timeout commandThe pipe article worked around that absence with a perl alarm watchdog — a second process, outside the program, whose only job was to kill on a timer. That works once, in a terminal. Inside a program it is the wrong shape: the watchdog would need to be spawned per child, told which pid to signal, and trusted to fire. The watchdog belongs in the parent, next to the wait it is racing against.
wait() has no deadline
Section titled “wait() has no deadline”The obvious version spawns the child and waits. Watch what the parent costs:
use std::process::Command;use std::time::Instant;
fn main() { let start = Instant::now(); let mut child = Command::new("sleep") .arg("5") .spawn() .expect("failed to spawn sleep");
let status = child.wait().expect("failed to wait"); println!("{} after {}s", status, start.elapsed().as_secs());}exit status: 0 after 5sThe child ran for five seconds, and so did the parent. Nothing in the call says “but give up after one” — wait takes no Duration, blocks until the child exits, and a hung child hangs the parent. This is the same shape as the pipe deadlock: a blocking call with no escape hatch.
Poll try_wait against a deadline
Section titled “Poll try_wait against a deadline”The escape hatch is try_wait(). It never blocks: it checks whether the child has exited, returns Ok(Some(status)) if so, and Ok(None) if the child is still running — and in the Some case it reaps the child, exactly as wait would. A timeout is a loop around it:
use std::process::Command;use std::time::{Duration, Instant};
fn main() { let start = Instant::now(); let mut child = Command::new("sleep") .arg("5") .spawn() .expect("failed to spawn sleep");
let deadline = start + Duration::from_secs(1);
let status = loop { match child.try_wait().expect("failed to poll the child") { Some(status) => break status, None => { if Instant::now() >= deadline { child.kill().expect("failed to kill the child"); break child.wait().expect("failed to wait after kill"); } std::thread::sleep(Duration::from_millis(50)); } } };
println!("{} after {}s", status, start.elapsed().as_secs());}signal: 9 (SIGKILL) after 1sThe same five-second child, cut off at one second. The loop has three parts:
- Poll.
try_waitanswers “is it done yet?” without blocking. If it returns a status, the child finished under the deadline and the loop is over. - Check the clock.
deadlineis anInstant—startplus one second — andInstant::now() >= deadlineis the whole timeout test.Instantis monotonic, so it cannot jump backward mid-loop the way a wall-clock read could. - Sleep. Fifty milliseconds between polls. The interval is a trade: a longer one wastes less CPU on a child that runs for minutes, but the kill lands later — with 50 ms, the child dies somewhere between 1.00 s and 1.05 s, which the
as_secs()in the output truncates to1.
The status tells you the child did not cooperate: signal: 9 (SIGKILL) means the kernel terminated it, not that it exited on its own. kill() sends SIGKILL on Unix — a signal the child cannot catch, block, or ignore. There is no cleanup: temp files stay, locks stay held, sockets stay half-closed. std offers no way to send a gentler signal, so if the child needs to shut down gracefully, a raw kill() is not enough — handling signals, sent or received, is a problem of its own.
If writing the loop by hand does not appeal, the wait-timeout crate wraps exactly this poll-then-kill pattern behind a wait_timeout method on its ChildExt trait.
Killing the child properly
Section titled “Killing the child properly”kill() is one line, but a killed child still has an afterlife, and the parent decides which one. A dead child’s process entry — its exit status, what the kernel keeps for the parent to collect — disappears only when the parent reaps it with wait(). Three endings, two of them mistakes.
The correct ending: kill, then wait. wait() after a kill returns immediately with the signal status:
use std::process::Command;
fn main() { let mut child = Command::new("sleep") .arg("60") .spawn() .expect("failed to spawn sleep");
child.kill().expect("failed to kill the child"); println!("after kill: {}", child.wait().expect("failed to wait"));
println!("kill again: {:?}", child.kill());}after kill: signal: 9 (SIGKILL)kill again: Ok(())The second line is the surprise: killing a child that has already been waited for is a no-op that returns Ok(()), not an error. std deliberately refuses to send a second signal — once wait() has returned, the pid is free and the kernel may already have handed it to a different process, so another kill() could land on a bystander.
Kill without wait leaves the first mistake. The child is dead, but nobody collected its status, so it stays in the process table as a zombie:
use std::process::Command;use std::thread;use std::time::Duration;
fn main() { let mut child = Command::new("sleep") .arg("60") .spawn() .expect("failed to spawn sleep"); let pid = child.id(); child.kill().expect("failed to kill the child"); println!("killed child {pid}; parent stays alive and never waits");
thread::sleep(Duration::from_secs(1));
let ps = Command::new("ps") .args(["-ax", "-o", "pid,ppid,stat,command"]) .output() .expect("failed to run ps"); let want = pid.to_string(); for line in String::from_utf8_lossy(&ps.stdout).lines() { if line.split_whitespace().next() == Some(want.as_str()) { println!("ps: {}", line.trim()); } }
let status = child.wait().expect("failed to reap the child"); println!("finally reaped: {}", status);}killed child 76171; parent stays alive and never waitsps: 76171 76106 Z <defunct>finally reaped: signal: 9 (SIGKILL)The pid in the first line and the first column of the ps line are the same child (the pids will differ on another run). While the parent stayed alive without waiting, ps reported it with stat Z and the command replaced by <defunct>: dead, but its entry held open until wait() collected it and the last line closed the book. A zombie holds a slot in the kernel’s process table and nothing else — it consumes no memory or CPU — but a server that times out children in a loop and never reaps them accumulates entries for as long as it runs. The poll loop above already gets this right: its try_wait and the wait after the kill are the reaping.
Drop without kill is the second mistake. Dropping a Child neither kills the child nor reaps it — the child just keeps running, unwatched:
use std::process::Command;
fn main() { let child = Command::new("sleep") .arg("60") .spawn() .expect("failed to spawn sleep"); drop(child); println!("dropped: nobody will ever kill it or wait for it");}dropped: nobody will ever kill it or wait for itThe program is gone; the child is not. Give it a second and look:
ps -ax -o pid,ppid,stat,command | grep '[s]leep 60'76414 1 S sleep 60Still running, still spending its sixty seconds — and its ppid is now 1. When the original parent exited, the kernel reparented the orphan to launchd (init or systemd on Linux), and no Child handle exists anymore to kill or reap it. Your pid will differ. If the parent had stayed alive and merely never called wait, the child would have become a zombie instead once it finished — either way, dropping the handle does not end the child.
Related
Section titled “Related”- How to pipe data between processes — the other blocking-
waittrap: waiting on a child whose pipe nobody drains - How to run an external command and read its output —
.output()is awaittoo, and it has no deadline either - Processes, environment and signals — the rest of the chapter