Last active
May 31, 2025 18:27
-
-
Save jcttrll/d9f92a4d3eea1290fbc61f388047e5af to your computer and use it in GitHub Desktop.
Pure Bash implementation of dual-signal timeout function; unlike /usr/bin/timeout from CoreUtils, command can be another Bash function
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Usage: timeout [ OPTIONS ] CMD [ ARG... ] | |
# | |
# Runs CMD and waits for it to terminate, signalling it with a primary signal after a timeout, and optionally signalling | |
# it with a secondary signal after an additional timeout. | |
# | |
# Termination of any subprocesses launched by CMD is not guaranteed: it depends on the signals sent to CMD and the | |
# behavior of CMD (such as propagating signals to subprocesses). CMD may be made the process group leader, with signals | |
# correspondingly sent to all processes in the group, with the -g option. Even in this case, a signal causing CMD to | |
# terminate may not cause a CMD subprocess to terminate, if the two processes handle signals differently. timeout only | |
# waits for CMD to terminate, not its subprocesses. If CMD terminates before the primary signal timeout, neither | |
# primary nor secondary signals will be sent to lingering subprocesses. If CMD terminates in response to the primary | |
# signal before the secondary signal timeout, the secondary signal will not be sent to lingering subprocesses. If the | |
# signal provided with -g is a number less than or equal to zero, no final attempt is made to terminate lingering | |
# subprocesses in the process group; otherwise, the signal provided is unconditionally sent to all processes in the | |
# process group immediately after CMD terminates. | |
# | |
# Unlike the timeout utility from coreutils, CMD may be another Bash function. The function will be run in a subshell, | |
# with the usual impacts to the environment. The Bash subshell process is actually CMD in this case, and is the | |
# recipient of signals. The difference between Bash's default handling of signals on behalf of the function and signal | |
# handling in any subprocesses launched by the function may be problematic; consider setting signal traps in the | |
# function if this is an issue. | |
# | |
# Options: | |
# | |
# -t <SECONDS>: primary timeout (seconds, with optional decimal fraction) (default: 60) | |
# -s <SIGNAL>: signal (name or number, from kill -l) sent to command if primary timeout reached (default: SIGTERM) | |
# -T <SECONDS>: secondary timeout (seconds, with optional decimal fraction) (default: empty - no secondary timeout) | |
# -S <SIGNAL>: signal (name or number, from kill -l) sent to command if secondary timeout reached (default: SIGKILL) | |
# -c <NUMBER>: status code returned on timeout (0-255) (default: 255) | |
# -g <SIGNAL>: run CMD as process group leader, sending primary and secondary signals to process group; if SIGNAL | |
# greater than 0, send SIGNAL (name or number, from kill -l) to process group after CMD terminates | |
# --: end-of-options indicator (useful only in unlikely case where CMD starts with -) | |
# | |
# Return status code: 254 if mangled options encountered; 255 (or alternate value set by -c) if primary timeout reached; | |
# otherwise, propagated from CMD. | |
# | |
timeout() { | |
local primaryTimeoutSeconds=60 | |
local primarySignal=SIGTERM | |
local secondaryTimeoutSeconds | |
local secondarySignal=SIGKILL | |
local processGroup=0 | |
local processGroupSignal | |
local timeoutCode=255 | |
local knownSignals | |
while [[ $# -gt 0 ]]; do | |
case "$1" in | |
-t*) | |
if [[ "${#1}" -gt 2 ]]; then primaryTimeoutSeconds=${1:2}; else shift; primaryTimeoutSeconds=$1; fi | |
[[ -z "$(read -r -t "$primaryTimeoutSeconds" </dev/null 2>&1 || true)" ]] || return 254 | |
;; | |
-s*) | |
if [[ "${#1}" -gt 2 ]]; then primarySignal=${1:2}; else shift; primarySignal=$1; fi | |
primarySignal=${primarySignal^^} | |
[[ -n "$knownSignals" ]] || knownSignals=" $(kill -l) " | |
[[ "$primarySignal" =~ ^[[:graph:]]+$ && | |
"$knownSignals" =~ [[:space:]](SIG)?"$primarySignal"[[:space:]\)] ]] || return 254 | |
;; | |
-T*) | |
if [[ "${#1}" -gt 2 ]]; then secondaryTimeoutSeconds=${1:2}; else shift; secondaryTimeoutSeconds=$1; fi | |
[[ -z "$(read -r -t "$secondaryTimeoutSeconds" </dev/null 2>&1 || true)" ]] || return 254 | |
;; | |
-S*) | |
if [[ "${#1}" -gt 2 ]]; then secondarySignal=${1:2}; else shift; secondarySignal=$1; fi | |
secondarySignal=${secondarySignal^^} | |
[[ -n "$knownSignals" ]] || knownSignals=" $(kill -l) " | |
[[ "$secondarySignal" =~ ^[[:graph:]]+$ && | |
"$knownSignals" =~ [[:space:]](SIG)?"$secondarySignal"[[:space:]\)] ]] || return 254 | |
;; | |
-c*) | |
if [[ "${#1}" -gt 2 ]]; then timeoutCode=${1:2}; else shift; timeoutCode=$1; fi | |
[[ "$timeoutCode" =~ ^[0-9]{1,3}$ && "$timeoutCode" -le 255 ]] || return 254 | |
;; | |
-g*) | |
processGroup=1 | |
if [[ "${#1}" -gt 2 ]]; then processGroupSignal=${1:2}; else shift; processGroupSignal=$1; fi | |
if [[ "$processGroupSignal" =~ ^-?[0-9]+$ && "$processGroupSignal" -le 0 ]]; then | |
processGroupSignal='' | |
else | |
processGroupSignal=${processGroupSignal^^} | |
[[ -n "$knownSignals" ]] || knownSignals=" $(kill -l) " | |
[[ "$processGroupSignal" =~ ^[[:graph:]]+$ && | |
"$knownSignals" =~ [[:space:]](SIG)?"$processGroupSignal"[[:space:]\)] ]] || return 254 | |
fi | |
;; | |
--) | |
shift | |
break | |
;; | |
-*) | |
return 254 | |
;; | |
*) | |
break | |
;; | |
esac | |
shift | |
done | |
[[ $# -gt 0 ]] || return 254 | |
local stdin stdout stderr | |
exec {stdin}<&0 {stdout}>&1 {stderr}>&2 | |
# shellcheck disable=SC2034 ## Bash "manages" this variable, including unsetting it automatically, making it useless | |
local controller_PID | |
local controllerPid | |
local -a controller | |
coproc controller ( | |
set +e | |
if (( processGroup )); then | |
set -m | |
trap 'kill -s "$primarySignal" -"$pid"' SIGUSR1 | |
trap 'kill -s "$secondarySignal" -"$pid"' SIGUSR2 | |
else | |
trap 'kill -s "$primarySignal" "$pid"' SIGUSR1 | |
trap 'kill -s "$secondarySignal" "$pid"' SIGUSR2 | |
fi | |
"$@" <&"$stdin" >&"$stdout" 2>&"$stderr" {stdin}<&- {stdout}>&- {stderr}>&- & | |
pid=$! | |
exec {stdin}<&- {stdout}>&- {stderr}>&- | |
# Recall that receipt of trapped signals causes Bash wait to return prematurely, with special status code | |
wait "$pid" # Ignore SIGUSR1, if received | |
wait "$pid" # Ignore SIGUSR2, if received | |
wait "$pid" # Obtain real status code | |
statusCode=$? | |
[[ -z "$processGroupSignal" ]] || kill -s "$processGroupSignal" -"$pid" | |
echo # Release any read waiting on controller | |
exit "$statusCode" | |
) 2>/dev/null </dev/null | |
controllerPid=$! | |
exec {stdin}<&- {stdout}>&- {stderr}>&- | |
{ | |
local _discard | |
if read -r -t "$primaryTimeoutSeconds" -u "${controller[0]}" _discard; then | |
wait "$controllerPid" | |
return $? | |
fi | |
kill -s USR1 "$controllerPid" || true | |
if [[ -n "$secondaryTimeoutSeconds" ]]; then | |
if read -r -t "$secondaryTimeoutSeconds" -u "${controller[0]}" _discard; then | |
wait "$controllerPid" || true | |
return "$timeoutCode" | |
fi | |
kill -s USR2 "$controllerPid" || true | |
fi | |
wait "$controllerPid" || true | |
return "$timeoutCode" | |
} >/dev/null 2>&1 | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment