指定された時間でタイムアウトさせるコマンドとして timeout
コマンドがあります。しかしすべての環境でインストールされているとは限らず、macOS では Homebrew などで GNU coreutils を別途インストールしなければ使えません。また timeout
コマンドは外部コマンドであるためシェル関数には使えません。そこで同等の機能を持つシェル関数を実装しました。POSIX 準拠であるため bash 以外でも使用可能です。全ての POSIX シェルで動作しているのを一応確認していますが、細かいタイミングに依存していたり、シェルのバグがあったりするため見逃しがあるかもしれません。微妙なバランスで動いているコードなので時々修正入れると思います。
実装
この記事で「メイン処理(process
関数)」と呼んでいるものが長くかかるかもしれない処理でタイムアウトで打ち切られる可能性がある処理です。timeout
関数の仕様は timeout
コマンドの挙動を参考にしています。メイン処理がタイムアウトされずに終了した場合 timeout
関数はメイン処理の戻り値を返します。タイムアウトした場合、メイン処理は TERM シグナルで停止させ終了ステータスとして 124 を返します。KILL シグナルで停止させたい場合はコメントの方に入れ替えてください。その場合は 139 を返します。
#!/bin/sh
set -eu
timeout() {
( for i in 1 2; do sleep 0 & wait $!; done; shift; "$@" ) &
{
set -- "$1" $!
( sleep "$1"; kill -s TERM "$2" ||:; exit 124 ) & # To stop with TERM
# ( sleep "$1"; kill -s KILL "$2" ||:; exit 137 ) & # To stop with KILL
wait "$2" &&:
set -- $?
kill -s KILL $! || set -- 124 # To stop with TERM
# kill -s KILL $! || set -- 137 # To stop with KILL
wait $! ||:
case $1 in (143 | 271 | 399) return 124; esac # To stop with TERM
# case $1 in (265 | 393) return 137; esac # To stop with KILL
return "$1"
} 2>/dev/null
}
# 以下動作確認用
duration=3 # タイムアウト時間
file="/tmp" # メイン処理関数はこのパスがあるかを無限ループでチェックする
ret=10 # メイン処理関数からの戻り値
# メイン処理
process() {
echo stdout
echo stderr >&2
until [ -e "$file" ]; do
sleep 0
done
return "$ret"
}
echo start
start=$(date +%s)
if timeout "$duration" process; then
echo "done"
else
ex=$?
case $ex in
124 | 137) echo "timeout $ex" ;;
*) echo "done ($ex)" ;;
esac
fi
end=$(date +%s)
if [ -e "$file" ]; then
# no timeout
[ "$ex" -eq "$ret" ] && [ $(($end - $start)) -le "$duration" ]
else
# timeout
[ "$ex" -eq 124 ] && [ $(($end - $start)) -ge "$duration" ] # To stop with TERM
# [ "$ex" -eq 137 ] && [ $(($end - $start)) -ge "$duration" ] # To stop with KILL
fi
コードの説明
少し分かりづらい箇所の説明や注意点です。
( for i in 1 2; do sleep 0 & wait $!; done; shift; "$@" ) &
この for
ループはダミー処理を行っています。メイン処理関数がすぐに終わってしまう場合、後続の wait "$2" &&:
で zsh (おそらく4系まで)がエラーになるために入れています。処理内容から考えれば 3 回ほどループすれば十分だと思うのですが、環境依存するはずなので場合によっては増やす必要があるかもしれません。
{
...
} 2>/dev/null
kill
や wait
で出力される可能性がある無視して良いエラーメッセージの出力を抑制しています。
set -- "$1" $!
グローバル変数を使用したくないので位置パラメータを使っています。(local
は POSIX 準拠ではないので使えないシェルがあります。)
( sleep "$1"; kill -s TERM "$2" ||:; exit 124 ) &
ここがタイムアウト処理を行っている部分です。バックグラウンドプロセスで数秒待ってから TERM シグナルで kill
しています。タイムアウトとなったとき、zsh(おそらく4系まで) ではタイミングによってこのプロセスの戻り値が使われることがあるので戻しています。
wait "$2" &&:
ここでメイン処理が完了するか、タイムアウトで kill
されるまで待機しています。
kill -s KILL $! || set -- 124
タイムアウトを行うプロセスを kill
します。トラップされないようにあえて KILL シグナルを使っています。kill
できない場合はタイムアウトしたことを意味し 124 を戻り値として設定します。タイムアウトを行うプロセスを kill
できた場合は、通常は処理がタイムアウトせずに終わっていることを意味しますが、タイミングによってはタイムアウトしているのに kill
できてしまう場合もあります。その場合の終了ステータスはシグナルで kill
されたことを示す値です。
case $1 in (143 | 271 | 399) return 124; esac
前のコードで「タイムアウトしているのに kill
できてしまう場合」は終了ステータスに TERM (シグナル番号 15)で停止したときの値が入っています。この値はシェルによって異なり、多くのシェルでは 128+15 ですが、ksh では 256+15、yash では 384+15 です。
落ち葉拾い
前述の timeout
関数で(最低限のワークアラウンドも入れていますし)殆ど動くので特に理由がなければそちらを使うのをおすすめします。ここからは前述のコードで対応できないシェルのためのコードです。必要ない方は読まなくて良いです。
対応できないシェルは古いシェルなので切り捨てて良い・・・と言いたかったのですが、現時点で最新の BusyBox 1.32.0 で正しく処理されないバグが判明しました。wait
で PID を指定した場合、そのプロセスだけが終了するのを待つはずなのですが、省略した場合と同じく全てのプロセスの終了を待ってしまっているようです。それに対応のため正しく動作するのかより不安になるコードがこちらです。(動作確認はしているのですが・・・)
signal() { kill -"$1" "$2"; }
if kill -s 0 $$ 2>/dev/null; then
signal() { kill -s "$1" "$2"; }
if [ "$(kill -l 1)" = '1' ]; then
kill() { env kill "$@"; }
fi
fi
timeout() {
{
( for i in 1 2 3; do sleep 0 & wait $!; done; shift; "$@" 2>&3 ) & :
} 3>&2 2>/dev/null
{
set -- "$1" $!
( sleep "$1"; signal TERM "$2" ||:; exit 124 ) & # To stop with TERM
# ( sleep "$1"; signal KILL "$2" ||:; exit 137 ) & # To stop with KILL
set -- "$@" $!
( while signal 0 "$2"; do :; done; signal KILL "$3" ) &
wait "$2" &&:
set -- "$@" $?
wait "$3" $! ||:
case $4 in (143 | 208 | 271 | 399) return 124; esac # To stop with TERM
# case $4 in (265 | 393) return 137; esac # To stop with KILL
return "$4"
} 2>/dev/null
}
まず signal
関数ですが、古いシェルでは kill -s signal
による指定ができないものがあります。その場合に kill -signal
を使うようにしています。 kill -l 1
はかなり局所的ですが posh 0.8.5 あたりでビルトインの kill
コマンド(というかシグナル周り)が壊れているため、外部コマンドの kill
を呼び出すように kill
関数で再定義しています。
{
( for i in 1 2 3; do sleep 0 & wait $!; done; shift; "$@" 2>&3 ) & :
} 3>&2 2>/dev/null
ファイルディスクリプタ 3 を使用しているのは posh 0.6.13 と loksh のバグ(6.7.3で修正されました)と osh 対策です。posh と loksh では echo &
のようにバックグラウンドプロセスを起動すると internal error: j_set_async: bad nzombie (0)
というエラーが出力されます。osh では [%1] Started PID 9023
のようなログがデフォルトで出力されます。シェル関数内のエラーはファイルディスクリプタ 3 を経由することでそのまま出力され、それ以外の無視して良いエラーメッセージのみ出力を抑制しています。行末のコロンはこれがないと ksh で終了ステータスが 0 になってしまうので入れています。
( while signal 0 "$2"; do :; done; signal KILL "$3" ) &
この部分が BusyBox 1.32.0 のバグ対策でもう一つバックグラウンドプロセスを使って、メイン処理が終了していたらタイムアウト処理のプロセスも停止させます。ビジーウェイトを使っているためCPUに負荷がかかります。POSIX 準拠ではありませんが使えるなら sleep 0.1
などを挟んだほうが良いかもしれません。
case $4 in (143 | 208 | 271 | 399) return 124; esac
増えている 208 は bosh の対策です。TERM シグナルで停止させた場合、通常は 143 になるはずなのですが、タイミングによっては 208 になることがあります。
さいごに
実はもう一つシェル関数専用でサブシェルを使わない実装も作ろうとしていたのですが落ち葉拾いで力尽きました。タイムアウトプロセスからシグナル送ってそのシグナルを受け取っていればメイン処理のループを打ち切るようにすればできると思うのですが・・・
なお、このコードのライセンスは MIT ライセンスとします。近いうちにこれらを含め私が作った or 作る予定のシェルスクリプト用関数を集めたリポジトリを作る予定です。