Skip to content
Select theme

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:

Terminal window
command -v timeout || echo "no timeout command"
no timeout command

The 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.

The obvious version spawns the child and waits. Watch what the parent costs:

exit status: 0 after 5s

The 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.

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:

signal: 9 (SIGKILL) after 1s

The same five-second child, cut off at one second. The loop has three parts:

  1. Poll. try_wait answers “is it done yet?” without blocking. If it returns a status, the child finished under the deadline and the loop is over.
  2. Check the clock. deadline is an Instant — start plus one second — and Instant::now() >= deadline is the whole timeout test. Instant is monotonic, so it cannot jump backward mid-loop the way a wall-clock read could.
  3. 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 to 1.

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.

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:

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:

killed child 76171; parent stays alive and never waits
ps: 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:

dropped: nobody will ever kill it or wait for it

The program is gone; the child is not. Give it a second and look:

Terminal window
ps -ax -o pid,ppid,stat,command | grep '[s]leep 60'
76414 1 S sleep 60

Still 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.