GNU timeout: Bound Process Runtime Without Losing Failure Meaning
Use GNU timeout with TERM, kill-after, process groups, foreground mode, and documented statuses so a deadline actually constrains the intended work.
An external command that hangs can stall a shell script, a CI job, or a maintenance window indefinitely. GNU Coreutils timeout runs a command for a bounded duration and sends a signal when that duration expires. It is valuable for probes, bounded subprocesses, and defensive automation, but it is not a magical process-tree reaper. The signal, process-group behavior, escalation delay, and exit-status contract all determine whether the deadline protects the work you intended.
This article covers the GNU Coreutils implementation. BSD and other Unix systems may provide a utility with different options and statuses. Check timeout --version and the local manual before relying on --kill-after, --foreground, or any GNU-specific option in a portable script.
A timeout sends a signal; it does not reverse a command
The basic form is timeout DURATION COMMAND [ARG...]. If the command is still running when the duration expires, GNU timeout sends TERM by default. Many programs handle TERM by stopping cleanly, flushing state, and exiting. Others ignore it, block it, or need more time than the service-level deadline allows. To bound that cleanup interval, set --kill-after:
timeout --signal=TERM --kill-after=5s 90s \
/usr/local/libexec/check-index --read-only
The command receives TERM after 90 seconds. If it has not exited five seconds after that signal, timeout sends KILL. The second interval starts when the first signal is sent, not when the command starts. A process killed with KILL cannot run cleanup handlers. If an operation needs graceful rollback, it must respond to the initial signal itself; escalation only places an upper bound on how long the wrapper waits.
A timeout does not undo database writes, remote requests, file replacement, or other side effects that happened before cancellation. Put work behind a transaction, write to a temporary destination and validate before publishing, or make a retry idempotent. Never treat “the client command timed out” as proof that a remote server did not receive or complete the request.
Process groups determine who receives the deadline signal
GNU timeout normally runs the command in a separate process group and targets the managed process group when signaling. This helps cancel a command that starts ordinary child processes. But children can change process groups, daemonize, or otherwise escape the group, and a wrapper process may change signal handling. A deadline should therefore be paired with a service manager or cgroup when complete descendant supervision is a hard requirement.
--foreground changes the process model: it avoids creating a separate background process group so an interactive command can use the foreground terminal and receive terminal-generated signals directly. In foreground mode, child processes of the command are not timed out. That makes it inappropriate as a shortcut for “keep the entire process tree under this deadline.” Use it when the terminal behavior is the requirement and explicitly account for children.
Do not confuse a shell pipeline with one command. In:
timeout 30s producer | consumer
the shell applies timeout to producer; consumer is outside the timeout wrapper and can continue waiting or processing after the producer exits. If the entire pipeline must share a deadline, invoke a fixed shell script under timeout:
timeout --signal=TERM --kill-after=3s 30s \
/bin/sh -c 'producer | consumer'
That fixed shell text is reasonable when it is part of the script and contains no interpolated untrusted data. If the inner shell is Bash and upstream pipeline failure must matter, enable Bash’s pipefail inside the fixed program or check each component explicitly. A timeout around a shell does not automatically define the pipeline’s success policy.
Status codes carry more than “success or timed out”
Without --preserve-status, GNU timeout normally returns 124 when the duration expires, even if the managed command later handles TERM and exits. It returns 125 when timeout itself fails, 126 when the command is found but cannot be invoked, and 127 when the command cannot be found. Status 137 indicates that the command or timeout received KILL; those cases cannot always be distinguished from the code alone. If the command finishes before the deadline, its exit status is returned.
--preserve-status asks timeout to return the managed command’s status when it times out rather than the special 124 timeout status. That is useful only when the command’s own termination status gives the caller a clear result. It can hide the fact that the deadline fired if the program converts a TERM signal into an ordinary status. Choose one policy and document it.
There is an ambiguity if the managed command itself exits 124: the caller may be unable to distinguish that from GNU timeout’s timeout indication using status alone. When that distinction is operationally important, use a wrapper that records a separate timeout marker or emits a structured completion record, and keep the marker in a protected path. Avoid parsing localized human-readable stderr as a control protocol.
Capture status immediately in shell code:
if timeout --signal=TERM --kill-after=4s 45s \
/usr/local/bin/health-check --endpoint local; then
printf '%s\n' 'health check passed'
else
status=$?
case $status in
124) printf '%s\n' 'health check exceeded its deadline' >&2 ;;
125) printf '%s\n' 'timeout utility failed' >&2 ;;
126|127) printf 'health-check could not run (status %s)\n' "$status" >&2 ;;
137) printf '%s\n' 'command or timeout was killed with SIGKILL' >&2 ;;
*) printf 'health check failed (status %s)\n' "$status" >&2 ;;
esac
exit "$status"
fi
The if form captures a failure without depending on set -e behavior in every shell context. If a later log command, cleanup function, or tee runs before $? is saved, it can overwrite the status you meant to report.
Durations and command selection need explicit validation
GNU duration syntax supports a number with an optional suffix such as seconds, minutes, hours, or days. A duration of zero means the timer is not active. Parse configuration before invoking timeout; reject missing, negative, malformed, or implausibly large values according to the application contract. Never let untrusted input shift arguments so it can become the command or an option.
The command must be an executable utility, not a shell special builtin. timeout 5s cd /tmp cannot change the current shell’s directory, and timeout 5s read value cannot operate on the caller’s shell state. To bound a shell program, run a fixed script file or pass a reviewed static script to a specific shell. Avoid timeout "$duration" sh -c "$user_text": that combines a process deadline with a remote-code-execution-style shell injection boundary.
Use absolute paths for commands in security-sensitive automation. A modified PATH can cause a different program to run than the one an operator reviewed. Validate the executable’s ownership and permissions if an untrusted user could modify its location. timeout is a wrapper, not an authorization mechanism.
Terminal behavior and signals are workload-specific
The default signal is TERM, but a command may have a domain-specific cancellation signal. GNU timeout --signal=INT can send INT, for example, but the behavior depends on the command’s signal handlers and foreground process group. SIGKILL cannot be caught. SIGSTOP is not a termination request and can leave a process permanently stopped until another signal resumes or kills it; do not use it as a timeout action.
For interactive commands, --foreground can preserve terminal access and allow Ctrl-C to reach the command naturally. The tradeoff is that child processes are not covered by the timeout. If the command’s descendants need supervision, run it under a process supervisor designed to track the full unit, not an interactive mode chosen only because output looked strange.
When a script itself receives a signal, decide how its cleanup interacts with the timeout wrapper. A shell trap may run on TERM, but long or blocked cleanup can exceed the --kill-after grace period. Keep cleanup idempotent, short, and safe if interrupted. Do not delete a destination or release a lock until the operation’s state is known; a cleanup trap that assumes normal completion can make a timeout more destructive than the original hang.
Idempotency matters more than aggressive retries
Timeouts often feed retry logic. If a client is killed while waiting for a response, the server may still have completed the request. Retrying a read-only health check is generally safer than retrying a payment, deployment, migration, or create operation. Use a server-supported idempotency key or query operation state before retrying non-idempotent actions.
Separate connect timeout, total operation deadline, and retry budget. A wrapper deadline may terminate the client before its own protocol-level timeout runs. For a layered request, budget the maximum connect time, response time, retry/backoff duration, and cleanup inside the outer deadline. Otherwise the retry policy can promise more time than the process is allowed to use.
Do not run timeout around a command whose child escapes supervision and then launch another copy on the assumption that the first is gone. Check service state or process ownership after a timeout. If exact cancellation matters, run the command in a cgroup, transient systemd unit, container, or job system with a documented cleanup lifecycle and monitor the whole unit.
Test the result under controlled conditions
Test the normal completion path, a command that exits nonzero before the deadline, a command that handles TERM, and a command that ignores TERM so --kill-after must escalate. Verify the exact status observed by the caller and whether descendants remain. Use disposable commands and a temporary process tree; do not test signal behavior against a production daemon.
Test a pipeline separately from a single executable and test interactive mode only with a terminal. Log the configured deadline and whether it was reached, but do not log credentials or full command arguments if they contain secrets. Record the Coreutils version because portability and option availability vary.
Finally, define the meaning of each outcome to the scheduler. A timeout may be retryable, may indicate a degraded service, or may require manual investigation. It should not be collapsed into a generic success or a blind retry. When you choose the signal, process-group model, escalation delay, and status mapping deliberately, GNU timeout becomes a useful deadline primitive rather than a false promise that every child has disappeared.
Related:
- Shell Signal Handling: trap, Cleanup, and Process Groups
- How to Run Parallel Shell Jobs and Collect Every Exit Status
Sources: