4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

シェルスクリプトでシェル関数・外部コマンドのタイムアウトを行う関数の実装

Last updated at Posted at 2020-09-27

指定された時間でタイムアウトさせるコマンドとして 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

killwait で出力される可能性がある無視して良いエラーメッセージの出力を抑制しています。

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 作る予定のシェルスクリプト用関数を集めたリポジトリを作る予定です。

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?