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

指定された時間でタイムアウトさせるコマンドとして `timeout` コマンドがあります。しかしすべての環境でインストールされているとは限らず、macOS では Homebrew などで GNU coreutils を別途インストールしなければ使えません。また `timeout` コマンドは外部コマンドであるためシェル関数には使えません。そこで同等の機能を持つシェル関数を実装しました。POSIX 準拠であるため bash 以外でも使用可能です。全ての POSIX シェルで動作しているのを一応確認していますが、細かいタイミングに依存していたり、シェルのバグがあったりするため見逃しがあるかもしれません。微妙なバランスで動いているコードなので時々修正入れると思います。

## 実装

この記事で「メイン処理（`process`関数）」と呼んでいるものが長くかかるかもしれない処理でタイムアウトで打ち切られる可能性がある処理です。`timeout` 関数の仕様は `timeout` コマンドの挙動を参考にしています。メイン処理がタイムアウトされずに終了した場合 `timeout` 関数はメイン処理の戻り値を返します。タイムアウトした場合、メイン処理は TERM シグナルで停止させ終了ステータスとして 124 を返します。KILL シグナルで停止させたい場合はコメントの方に入れ替えてください。その場合は 139 を返します。

```sh
#!/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
```

## コードの説明

少し分かりづらい箇所の説明や注意点です。

```sh
( for i in 1 2; do sleep 0 & wait $!; done; shift; "$@" ) &
```

この `for` ループはダミー処理を行っています。メイン処理関数がすぐに終わってしまう場合、後続の `wait "$2" &&:` で zsh （おそらく4系まで）がエラーになるために入れています。処理内容から考えれば 3 回ほどループすれば十分だと思うのですが、環境依存するはずなので場合によっては増やす必要があるかもしれません。

```sh
{
  ...
} 2>/dev/null
```

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

```sh
set -- "$1" $!
```

グローバル変数を使用したくないので位置パラメータを使っています。（`local` は POSIX 準拠ではないので使えないシェルがあります。）


```sh
( sleep "$1"; kill -s TERM "$2" ||:; exit 124 ) &
```

ここがタイムアウト処理を行っている部分です。バックグラウンドプロセスで数秒待ってから TERM シグナルで `kill` しています。タイムアウトとなったとき、zsh（おそらく4系まで） ではタイミングによってこのプロセスの戻り値が使われることがあるので戻しています。

```sh
wait "$2" &&:
```

ここでメイン処理が完了するか、タイムアウトで `kill` されるまで待機しています。


```sh
kill -s KILL $! || set -- 124
```

タイムアウトを行うプロセスを `kill` します。トラップされないようにあえて KILL シグナルを使っています。`kill` できない場合はタイムアウトしたことを意味し 124 を戻り値として設定します。タイムアウトを行うプロセスを `kill` できた場合は、通常は処理がタイムアウトせずに終わっていることを意味しますが、タイミングによってはタイムアウトしているのに `kill` できてしまう場合もあります。その場合の終了ステータスはシグナルで `kill` されたことを示す値です。

```sh
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 を指定した場合、そのプロセスだけが終了するのを待つはずなのですが、省略した場合と同じく全てのプロセスの終了を待ってしまっているようです。それに対応のため正しく動作するのかより不安になるコードがこちらです。（動作確認はしているのですが・・・）

```sh
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` 関数で再定義しています。

```sh
{
  ( for i in 1 2 3; do sleep 0 & wait $!; done; shift; "$@" 2>&3 ) & :
} 3>&2 2>/dev/null
```

ファイルディスクリプタ 3 を使用しているのは [posh](https://salsa.debian.org/clint/posh) 0.6.13 と [loksh](https://github.com/dimkr/loksh) のバグ（6.7.3で修正されました）と [osh](https://github.com/oilshell/oil) 対策です。posh と loksh では `echo &` のようにバックグラウンドプロセスを起動すると `internal error: j_set_async: bad nzombie (0)` というエラーが出力されます。osh では `[%1] Started PID 9023` のようなログがデフォルトで出力されます。シェル関数内のエラーはファイルディスクリプタ 3 を経由することでそのまま出力され、それ以外の無視して良いエラーメッセージのみ出力を抑制しています。行末のコロンはこれがないと ksh で終了ステータスが 0 になってしまうので入れています。

```sh
( while signal 0 "$2"; do :; done; signal KILL "$3" ) &
```

この部分が BusyBox 1.32.0 のバグ対策でもう一つバックグラウンドプロセスを使って、メイン処理が終了していたらタイムアウト処理のプロセスも停止させます。ビジーウェイトを使っているためCPUに負荷がかかります。POSIX 準拠ではありませんが使えるなら `sleep 0.1` などを挟んだほうが良いかもしれません。

```sh
case $4 in (143 | 208 | 271 | 399) return 124; esac
```

増えている 208 は [bosh](http://schilytools.sourceforge.net/bosh.html) の対策です。TERM シグナルで停止させた場合、通常は 143 になるはずなのですが、タイミングによっては 208 になることがあります。

## さいごに

実はもう一つシェル関数専用でサブシェルを使わない実装も作ろうとしていたのですが落ち葉拾いで力尽きました。タイムアウトプロセスからシグナル送ってそのシグナルを受け取っていればメイン処理のループを打ち切るようにすればできると思うのですが・・・

なお、このコードのライセンスは MIT ライセンスとします。近いうちにこれらを含め私が作った or 作る予定のシェルスクリプト用関数を集めたリポジトリを作る予定です。