指定された時間でタイムアウトさせるコマンドとして timeoutコマンドがあります。しかしすべての環境でインストールされているとは限らず、macOS では Homebrew などで GNU coreutils を別途インストールしなければ使えません。また timeoutコマンドは外部コマンドであるためシェル関数には使えません。そこで同等の機能を持つシェル関数を実装しました。POSIX 準拠であるため bash 以外でも使用可能です。全ての POSIX シェルで動作しているのを一応確認していますが、細かいタイミングに依存していたり、シェルのバグがあったりするため見逃しがあるかもしれません。
実装
この記事で「メイン処理(process関数)」と呼んでいるものが長くかかるかもしれない処理でタイムアウトで打ち切られる可能性がある処理です。timeout関数の仕様は timeoutコマンドの挙動を参考にしています。メイン処理がタイムアウトされずに終了した場合 timeout関数はメイン処理の戻り値を返します。タイムアウトした場合、メイン処理は TERM シグナルで停止させ終了ステータスとして 124 を返します。KILL シグナルで停止させたい場合はコメントの方に入れ替えてください。その場合は 139 を返します。
#!/bin/shset-eutimeout(){{(for i in 1 2;do sleep 0 & wait$!;done;shift;"$@" 2>&3 )&
set--"$1""$!"(sleep"$1";kill-s TERM "$2"||:;return 124 )& # To stop with TERM# ( sleep "$1"; kill -s KILL "$2" ||:; return 137 ) & # To stop with KILLwait"$2"&&:
set--$?kill-s KILL $!||set-- 124 # To stop with TERM# kill -s KILL $! || set -- 137 # To stop with KILLwait$!||:
} 3>&2 2>/dev/null
case$1in(143 | 271 | 399)return 124;esac# To stop with TERM# case $1 in (265 | 393) return 137; esac # To stop with KILLreturn"$1"}# 以下動作確認用duration=3 # タイムアウト時間file="/tmp"# メイン処理関数はこのパスがあるかを無限ループでチェックするret=10 # メイン処理関数からの戻り値# メイン処理
process(){until[-e"$file"];do
sleep 0
done
return"$ret"}echo start
start=$(date +%s)if timeout"$duration" process;then
echo"done"else
ex=$?case$exin
124 | 137)echo"timeout $ex";;*)echo"done ($ex)";;esacfi
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 KILLfiコードの説明
少し分かりづらい箇所の説明や注意点です。
(for i in 1 2;do sleep 0 & wait$!;done;shift;"$@" 2>&3 )&
この forループはダミー処理を行っています。メイン処理関数がすぐに終わってしまう場合、後続の wait "$2" &&:で zsh (おそらく4系まで)がエラーになるために入れています。処理内容から考えれば2回ループすれば十分だと思うのですが、環境依存するはずなので場合によっては増やす必要があるかもしれません。
{(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 を経由することでそのまま出力され、それ以外の無視して良いエラーメッセージのみ出力を抑制しています。killや waitで出力される可能性があるエラーも問題ないので抑制しています。
set--"$1"$!グローバル変数を使用したくないので位置パラメータを使っています。(localは POSIX 準拠ではないので使えないシェルがあります。)
(sleep"$1";kill-s TERM "$2"||:;return 124 )&
ここがタイムアウト処理を行っている部分です。バックグラウンドプロセスで数秒待ってから TERM シグナルで killしています。タイムアウトとなったとき、zsh(おそらく4系まで) ではタイミングによってこのプロセスの戻り値が使われることがあるので戻しています。
wait"$2"&&:
ここでメイン処理が完了するか、タイムアウトで killされるまで待機しています。
kill-s KILL $!||set-- 124
タイムアウトを行うプロセスを killします。killできない場合はタイムアウトしたことを意味し 124 を戻り値として設定します。タイムアウトを行うプロセスを killできた場合は、通常は処理がタイムアウトせずに終わっていることを意味しますが、タイミングによってはタイムアウトしているのに killできてしまう場合もあります。その場合の終了ステータスはシグナルで killされたことを示す値です。
case$1in(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 )&
set--"$1""$!"(sleep"$1"; signal TERM "$2"||:;return 124 )& # To stop with TERM# ( sleep "$1"; signal KILL "$2" ||:; return 137 ) & # To stop with KILLset--"$@""$!"(while signal 0 "$2";do :;done; signal KILL "$3")&
wait"$2"&&:
set--"$@"$?wait"$3"$!||:
} 3>&2 2>/dev/null
case$4in(143 | 208 | 271 | 399)return 124;esac# To stop with TERM# case $4 in (265 | 393) return 137; esac # To stop with KILLreturn"$4"}まず signal関数ですが、古いシェルでは kill -s signalによる指定ができないものがあります。その場合に kill -signalを使うようにしています。 kill -l 1はかなり局所的ですが posh 0.8.5 あたりでビルトインの killコマンド(というかシグナル周り)が壊れているため、外部コマンドの killを呼び出すように kill関数で再定義しています。
(while signal 0 "$2";do :;done; signal KILL "$3")&
この部分が BusyBox 1.32.0 のバグ対策でもう一つバックグラウンドプロセスを使って、メイン処理が終了していたらタイムアウト処理のプロセスも停止させます。ビジーウェイトを使っているためCPUに負荷がかかります。POSIX 準拠ではありませんが使えるなら sleep 0.1などを挟んだほうが良いかもしれません。
case$4in(143 | 208 | 271 | 399)return 124;esac増えている 208 は boshの対策です。TERM シグナルで停止させた場合、通常は 143 になるはずなのですが、タイミングによっては 208 になることがあります。
さいごに
実はもう一つシェル関数専用でサブシェルを使わない実装も作ろうとしていたのですが落ち葉拾いで力尽きました。タイムアウトプロセスからシグナル送ってそのシグナルを受け取っていればメイン処理のループを打ち切るようにすればできると思うのですが・・・