使用超时执行 shell 函数

为什么会有用

timeout 10s echo "foo bar" # foo bar

但这个不会

function echoFooBar {
echo "foo bar"
}


echoFooBar # foo bar


timeout 10s echoFooBar # timeout: failed to run command `echoFooBar': No such file or directory

我该怎么做呢?

138540 次浏览

timeout is a command - so it is executing in a subprocess of your bash shell. Therefore it has no access to your functions defined in your current shell.

The command timeout is given is executed as a subprocess of timeout - a grand-child process of your shell.

You might be confused because echo is both a shell built-in and a separate command.

What you can do is put your function in it's own script file, chmod it to be executable, then execute it with timeout.

Alternatively fork, executing your function in a sub-shell - and in the original process, monitor the progress, killing the subprocess if it takes too long.

if you just want to add timeout as an additional option for the entire existing script, you can make it test for the timeout-option, and then make it call it self recursively without that option.

example.sh:

#!/bin/bash
if [ "$1" == "-t" ]; then
timeout 1m $0 $2
else
#the original script
echo $1
sleep 2m
echo YAWN...
fi

running this script without timeout:

$./example.sh -other_option # -other_option
# YAWN...

running it with a one minute timeout:

$./example.sh -t -other_option # -other_option

There's an inline alternative also launching a subprocess of bash shell:


timeout 10s bash <<EOT
function echoFooBar {
echo foo
}


echoFooBar
sleep 20
EOT

You can create a function which would allow you to do the same as timeout but also for other functions:

function run_cmd {
cmd="$1"; timeout="$2";
grep -qP '^\d+$' <<< $timeout || timeout=10


(
eval "$cmd" &
child=$!
trap -- "" SIGTERM
(
sleep $timeout
kill $child 2> /dev/null
) &
wait $child
)
}

And could run as below:

run_cmd "echoFooBar" 10

Note: The solution came from one of my questions: Elegant solution to implement timeout for bash commands and functions

function foo(){
for i in {1..100};
do
echo $i;
sleep 1;
done;
}


cat <( foo ) # Will work
timeout 3 cat <( foo ) # Will Work
timeout 3 cat <( foo ) | sort # Wont work, As sort will fail
cat <( timeout 3 cat <( foo ) ) | sort -r # Will Work

As Douglas Leeder said you need a separate process for timeout to signal to. Workaround by exporting function to subshells and running subshell manually.

export -f echoFooBar
timeout 10s bash -c echoFooBar

This function uses only builtins

  • Maybe consider evaling "$*" instead of running $@ directly depending on your needs

  • It starts a job with the command string specified after the first arg that is the timeout value and monitors the job pid

  • It checks every 1 seconds, bash supports timeouts down to 0.01 so that can be tweaked

  • Also if your script needs stdin, read should rely on a dedicated fd (exec {tofd}<> <(:))

  • Also you might want to tweak the kill signal (the one inside the loop) which is default to -15, you might want -9

## forking is evil
timeout() {
to=$1; shift
$@ & local wp=$! start=0
while kill -0 $wp; do
read -t 1
start=$((start+1))
if [ $start -ge $to ]; then
kill $wp && break
fi
done
}

Putting my comment to Tiago Lopo's answer into more readable form:

I think it's more readable to impose a timeout on the most recent subshell, this way we don't need to eval a string and the whole script can be highlighted as shell by your favourite editor. I simply put the commands after the subshell with eval has spawned into a shell-function (tested with zsh, but should work with bash):

timeout_child () {
trap -- "" SIGTERM
child=$!
timeout=$1
(
sleep $timeout
kill $child
) &
wait $child
}

Example usage:

( while true; do echo -n .; sleep 0.1; done) & timeout_child 2

And this way it also works with a shell function (if it runs in the background):

 print_dots () {
while true
do
sleep 0.1
echo -n .
done
}




> print_dots & timeout_child 2
[1] 21725
[3] 21727
...................[1]    21725 terminated  print_dots
[3]  + 21727 done       ( sleep $timeout; kill $child; )

I have a slight modification of @Tiago Lopo's answer that can handle commands with multiple arguments. I've also tested TauPan's solution, but it does not work if you use it multiple times in a script, while Tiago's does.

function timeout_cmd {
local arr
local cmd
local timeout


arr=( "$@" )


# timeout: first arg
# cmd: the other args
timeout="${arr[0]}"
cmd=( "${arr[@]:1}" )


(
eval "${cmd[@]}" &
child=$!


echo "child: $child"
trap -- "" SIGTERM
(
sleep "$timeout"
kill "$child" 2> /dev/null
) &
wait "$child"
)
}

Here's a fully functional script thant you can use to test the function above:

$ ./test_timeout.sh -h
Usage:
test_timeout.sh [-n] [-r REPEAT] [-s SLEEP_TIME] [-t TIMEOUT]
test_timeout.sh -h


Test timeout_cmd function.


Options:
-n              Dry run, do not actually sleep.
-r REPEAT       Reapeat everything multiple times [default: 1].
-s SLEEP_TIME   Sleep for SLEEP_TIME seconds [default: 5].
-t TIMEOUT      Timeout after TIMEOUT seconds [default: no timeout].

For example you cnal launch like this:

$ ./test_timeout.sh -r 2 -s 5 -t 3
Try no: 1
- Set timeout to: 3
child: 2540
-> retval: 143
-> The command timed out
Try no: 2
- Set timeout to: 3
child: 2593
-> retval: 143
-> The command timed out
Done!
#!/usr/bin/env bash


#shellcheck disable=SC2128
SOURCED=false && [ "$0" = "$BASH_SOURCE" ] || SOURCED=true


if ! $SOURCED; then
set -euo pipefail
IFS=$'\n\t'
fi


#################### helpers
function check_posint() {
local re='^[0-9]+$'
local mynum="$1"
local option="$2"


if ! [[ "$mynum" =~ $re ]] ; then
(echo -n "Error in option '$option': " >&2)
(echo "must be a positive integer, got $mynum." >&2)
exit 1
fi


if ! [ "$mynum" -gt 0 ] ; then
(echo "Error in option '$option': must be positive, got $mynum." >&2)
exit 1
fi
}
#################### end: helpers


#################### usage
function short_usage() {
(>&2 echo \
"Usage:
test_timeout.sh [-n] [-r REPEAT] [-s SLEEP_TIME] [-t TIMEOUT]
test_timeout.sh -h"
)
}


function usage() {
(>&2 short_usage )
(>&2 echo \
"
Test timeout_cmd function.


Options:
-n              Dry run, do not actually sleep.
-r REPEAT       Reapeat everything multiple times [default: 1].
-s SLEEP_TIME   Sleep for SLEEP_TIME seconds [default: 5].
-t TIMEOUT      Timeout after TIMEOUT seconds [default: no timeout].
")
}
#################### end: usage


help_flag=false
dryrun_flag=false
SLEEP_TIME=5
TIMEOUT=-1
REPEAT=1


while getopts ":hnr:s:t:" opt; do
case $opt in
h)
help_flag=true
;;
n)
dryrun_flag=true
;;
r)
check_posint "$OPTARG" '-r'


REPEAT="$OPTARG"
;;
s)
check_posint "$OPTARG" '-s'


SLEEP_TIME="$OPTARG"
;;
t)
check_posint "$OPTARG" '-t'


TIMEOUT="$OPTARG"
;;
\?)
(>&2 echo "Error. Invalid option: -$OPTARG.")
(>&2 echo "Try -h to get help")
short_usage
exit 1
;;
:)
(>&2 echo "Error.Option -$OPTARG requires an argument.")
(>&2 echo "Try -h to get help")
short_usage
exit 1
;;
esac
done


if $help_flag; then
usage
exit 0
fi


#################### utils
if $dryrun_flag; then
function wrap_run() {
( echo -en "[dry run]\\t" )
( echo "$@" )
}
else
function wrap_run() { "$@"; }
fi


# Execute a shell function with timeout
# https://stackoverflow.com/a/24416732/2377454
function timeout_cmd {
local arr
local cmd
local timeout


arr=( "$@" )


# timeout: first arg
# cmd: the other args
timeout="${arr[0]}"
cmd=( "${arr[@]:1}" )


(
eval "${cmd[@]}" &
child=$!


echo "child: $child"
trap -- "" SIGTERM
(
sleep "$timeout"
kill "$child" 2> /dev/null
) &
wait "$child"
)
}
####################


function sleep_func() {
local secs
local waitsec


waitsec=1
secs=$(($1))
while [ "$secs" -gt 0 ]; do
echo -ne "$secs\033[0K\r"
sleep "$waitsec"
secs=$((secs-waitsec))
done


}


command=("wrap_run" \
"sleep_func" "${SLEEP_TIME}"
)


for i in $(seq 1 "$REPEAT"); do
echo "Try no: $i"


if [ "$TIMEOUT" -gt 0 ]; then
echo "  - Set timeout to: $TIMEOUT"
set +e
timeout_cmd "$TIMEOUT" "${command[@]}"
retval="$?"
set -e


echo "    -> retval: $retval"
# check if (retval % 128) == SIGTERM (== 15)
if [[ "$((retval % 128))" -eq 15 ]]; then
echo "    -> The command timed out"
fi
else
echo "  - No timeout"
"${command[@]}"
retval="$?"
fi
done


echo "Done!"


exit 0

This one liner will exit your Bash session after 10s

$ TMOUT=10 && echo "foo bar"

This small modification to TauPan's answer adds some useful protection. If the child process that is being waited for has already exited before the sleep $timeout completes. The kill command attempts to kill a process that no longer exists. This is probably harmless, but there is no absolute guarantee that the same PID has not been re-assigned. To obviate this, a quick check is done to test that the child PID exists and that its parent is the shell it was forked from. Also trying to kill a non-existent process generates errors which if not suppressed can easily fill up logs.

I also used a more aggressive kill -9. This is the only way to kill a process that is blocking not on the shell command but instead from the file system eg. read < named_pipe.
A consequence of this is that the kill -9 $child command send its kill signal asynchronously to the process and hence generates a message into the calling shell. This can be suppressed by re-directing the wait $child > /dev/null 2>&1. With obvious consequences for debugging.

#!/bin/bash


function child_timeout () {
child=$!
timeout=$1
(
#trap -- "" SIGINT


sleep $timeout
if [ $(ps -o pid= -o comm= --ppid $$ | grep -o $child) ]; then
kill -9 $child
fi
) &
wait $child > /dev/null 2>&1


}




( tail -f /dev/null ) & child_timeout 10