Skip to content

Instantly share code, notes, and snippets.

@jcttrll
Last active May 31, 2025 18:27
Show Gist options
  • Save jcttrll/d9f92a4d3eea1290fbc61f388047e5af to your computer and use it in GitHub Desktop.
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
# 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