シェルスクリプトのset -eを罠を避けて使う方法


注意 この記事は内容に満足できなかったので新たに書き直しました。この記事の内容が間違っているわけではありませんが、まずは「シェルスクリプトのset -eを正しく使ってエラー処理を楽にしよう!」を参照して下さい。

2021-08-14 追記 この記事を書いた当時 bash の挙動を正しく理解していなかったので補足訂正します。bash はコマンド置換の中に set -e (errexit) の効果が継承されません。この動作は POSIX 非準拠です。bash 2.05.0 以上であれば set -o posix もしくは POSIX モード(sh)で起動することによって POSIX に準拠した動作となります。また bash 4.4 以上であれば shopt -s inherit_errexit でこの問題だけを個別に POSIX に準拠させることができます。本来は記事を訂正すべきですが、文章のつじつまを合わせるのが大変なのと訂正したことがわかりづらくなるのでここで補足いたします。記事を読む場合は注意してください。また同様の内容となりますがこちらでも回答しているので参照してください。

はじめに

この記事はこちらのまとめ「シェルスクリプトの set -e は罠いっぱい」の話に終止符を打つべく作成した記事です。前から書きたかったのですがうまくまとめきれずに放置してました。なぜ重い腰を上げたかについてはつい最近これに関連する自作ツールのバグを修正したからです。その話は雑談に近いですが事例にもなってるので後半の「番外編 この記事を書こうと思い立ったきっかけ」としてまとめています。

1. 概要

set -e は他の言語の例外と似た機能で正しく使うと毎回終了ステータスをチェックする必要がなくなりシェルスクリプトをシンプルにすることが出来ます。しかし正しく使わないと毎回終了ステータスをチェックすることになり set -e を使う意味がなくなります。(例外を備えた言語ですべての箇所で catch するようなものです。)set -e を使わない場合は、毎回終了ステータスをチェックする必要があるので「set -e を使用しない」と「正しくない使い方」と同等のコードになります。

set -e の仕様上の注意点はシェル関数を条件文と組み合わせて呼び出すと、シェル関数内で set -e の効果が無効になるという点だけです。(この場合は set -e が使われてないのと同じようにシェル関数を書けばいいだけなので set -e を使うのをやめる必要はありません。)その他の注意点は実は set -e とは関係ない話でシェルスクリプトで正しくエラーチェックをするなら set -e を使わずとも知っておく必要があります。set -e と関係ない話は「2. 終了ステータスが破棄される件」で set -e 特有の注意点は「3. set -eの注意点」で解説しています。

set -e のもう一つの注意点は特定の場合に一部のシェルで異なる動きをするということです。これはおそらくシェルのバグです。発生条件は「コマンド置換を使って変数に代入する」+「条件文と組み合わせる」場合です。詳細は「3. set -eの注意点」の後半で解説しています。

set -e の注意点はシェル関数にあてはまるものです。つまり外部コマンド・ビルトインコマンドのみを使用しておりシェル関数を使っていない場合や、シェル関数の終了ステータスを明示的に参照せずにサブルーチン的な使い方をしているだけであれば set -e は安心して使うことが出来ます。

2. 終了ステータスが破棄される件

これらの問題は set -e とは無関係です。無関係なのでこの話を先に潰してしておきます。

これらの話は set -e をしているのにエラーでスクリプトが終了しないので set -e の問題だとしばしば勘違いされているようです。この問題は正しくは終了ステータスが破棄される(= 終了ステータスを調べる方法がなく他のコマンドの結果で上書きされる)という問題です。終了ステータスが破棄されるのは set -e に限った話ではありません。自分でチェック(例 cmd || exit $? を使う)したほうが良いと set -eを使うのをやめた所で問題は何も解決していません。"終了ステータスが破棄されることが原因で" set -e で終了できない場合はcmd || exit $?でも終了できませんし、cmd || exit $?で終了できる場合はset -eでも終了できます。

終了ステータスが破棄されるパターンは2つあります。set -e とは関係ないので、以下のパターンの説明では set -e を使いません。また関連パターンとして「サブシェルで exit しても終了しない」という件も解説します。

  • パターン1 コマンドの出力を他のコマンドの引数に使用すると終了ステータスは無視される
  • パターン2 コマンドをパイプラインの入力として使用すると終了ステータスは無視される
  • 関連パターン サブシェルで exit しても終了しない

パターン1

「コマンドの出力を他のコマンドの引数に使用すると終了ステータスは無視される」

コマンド(シェル関数含む)の出力を他のコマンド(シェル関数含む)の引数に使用するというのはコマンド置換($())を使用する以下のようなコードのことです。

foo() { echo foo; return 1; }

# fooシェル関数の結果を echo コマンドの引数に使用している
echo "$(foo)" # foo の 結果は破棄される
echo $? # => 0、echo の結果で上書きされる

foo シェル関数の終了ステータス 1 が破棄されてしまうので set -e で停止しないのは当然です。この書き方では set -e を使わずとも foo シェル関数の終了ステータスを取得することはできないので、echo "$(foo)" || exit $? で終了させることもできません。

注意点としては以下の書き方も、他のコマンドの引数に使用しているということです。

foo() { echo foo; return 1; }
echo "$(foo)" # これは echo コマンド

export ret=$(foo) # これは export コマンド
echo $? # => 0

func() {
  # local は POSIX 準拠ではないので一部のシェルには実装されてないので注意
  local ret=$(foo) # これは local コマンド
  echo $? # => 0
}
func

他の言語からすると localexport はキーワードだと勘違いしてしまうでしょうが、シェルスクリプトとしてはどちらもコマンド扱いです。foo シェル関数で終了ステータスが 1 になったとしても local または export の代入によって終了ステータスは 0 で上書きされます。

なお余談ですが上記の書き方には実は問題があり、シェルによって挙動が異なります。

foo() { echo "foo bar=baz"; }
printf '%s : ' $(foo) # 全てのシェルで以下の2行が出力される
# => foo
# => bar=baz

export ret=$(foo)
echo "$ret" # => "foo" または "foo bar=baz"
echo "$bar" # => "baz" または ""

func() {
  local ret=$(foo)
  # local ret=foo bar=baz として扱われる
  echo "$ret" # => "foo" または "foo bar=baz"
  echo "$bar" # => "baz" または ""
}
func

bash、zsh、ksh88、ksh93 では ret="foo bar=baz" ですが、dash、yash、posh では ret="foo"bar="baz" となります。printf コマンドの挙動からすると dash 等の方が一貫性があると感じるのですが、bash 等の方が正しいようです。(参考 https://github.com/koalaman/shellcheck/wiki/SC2155 )この問題を回避するには、export "ret=$(foo)"export ret="$(foo)" とダブルクォートでくくることですが、もう一つは変数に代入することです。(話も終了ステータスの話に戻ります。)

他のコマンドの引数にすると終了ステータスが破棄されてしまう問題は、一旦変数に入れてから使用することで解決します。例えば次のようにします。

foo() { echo "foo bar=baz"; return 1; }

# fooシェル関数の結果を ret 変数に代入しています
ret=$(foo) # 変数に入れる場合はダブルクォートは必須ではありません
echo "$? $ret" # => "1 foo bar=baz" 全てのシェルで同じ挙動です

# 当然ながら以下の書き方でも終了ステータスは保持されます
(foo)  # =>" foo bar=baz"
echo $? # => 1

変数への代入はコマンド(シェル関数)の呼び出しではないので、この場合は終了ステータスは保持されます。終了ステータスが保持されるので set -e を使用している場合は ret=$(foo) の行で止まります。

パターン2

「コマンドをパイプラインの入力として使用すると終了ステータスは無視される」

パイプラインの入力(| の左側)に使うと終了ステータスは無視されます。パイプラインで繋いだ一連のコマンドの最終的な終了ステータスはパイプラインの最後のコマンドのものになります。この問題に対処するために作られた非 POSIX の拡張が set -o pipefail です。pipefail を有効にするといずれかのコマンド・シェル関数でエラーがになったら全体の終了ステータスもエラーになります。set -o pipefail は bash、zsh、ksh、mksh、yash で使用可能です。

foo() { echo foo; return 1; }

foo | cat
echo $? # => 0

set -o pipefail
foo | cat
echo $? # => 1

また以下のようにパイプラインの最後で exit している場合は注意が必要です。シェルによって動きが異なります。

bar() { cat; exit 1; }

# ksh と zsh ではサブシェルが使われないため終了する
# bash では shopt -s lastpipe を使用すると終了するようになる
echo test | bar # => test
echo $? # => 1

exit

POSIX によるとパイプラインの全てのコマンドはサブシェルで実行されるが、拡張として一部または全てのコマンドを現在のシェルで実行して良いとなっているようなのでどちらも正しい動作です。またこの内容からするとパイプラインの最後以外でも exit した時に終了する場合もありえると思います。(そういう実装が存在するのかは知りませんが)

each command of a multi-command pipeline is in a subshell environment; as an extension, however, any or all commands in a pipeline may be executed in the current environment.

関連パターン

「サブシェルで exit しても終了しない」

この話は終了ステータスが破棄されるのではなくサブシェルの中で exit をしても終了しないというだけの話ですが、スクリプトが終了しないという点では似てる話なので解説します。

終了しないパターン(サブシェルが使われるパターン)とは以下のようなものです。exitしてるのだからスクリプトは終了するのでは?と思うかもしれませんが、これらのパターンでは終了しません。

foo() { echo foo; exit 1; }

(foo)
echo $? # => 1

ret=$(foo)
echo $? # => 1

foo | cat
echo $? # => 0

# { foo; } これは終了する

echo end # ここまで実行される
exit

正確な表現ではありませんがサブシェル = 子プロセスのようなものなので、子プロセスが内部で exit しようが親プロセスは関知しないということです。ただし終了ステータスは保持されるので、サブシェルがエラー終了かつ set -e していればその時点で呼び出し元は連鎖的に終了します。つまり上記の例で言えば foo シェル関数の exit 1 で直接終了するのではなく(set -e をしていれば)ret 変数への代入時に set -e の効果で終了するいうことです。

この章のまとめ

「終了ステータスは無視される件」だけでも把握するのは大変かもしれませんが、繰り返しますがこれらの話は set -e とは直接関係ありません。正しくエラーチェックをするのであれば知っておかなければいけないことです。set -e を使わずとも同じ話なので、これをもって set -e を使わない理由にはなりません。

3. set -eの注意点

set -e の注意点は以下の3つです。

  • 注意点1 コマンド・シェル関数と条件文を組み合わせると set -e でスクリプトが終了しない
  • 注意点2 シェル関数と条件文を組み合わせるとシェル関数内では set -e は無効になる
  • 注意点3 シェル関数と代入と条件文を組み合わせたときのバグ

これらの話、特に 1. と 2. は別の話なのでごっちゃにしないようにして下さい。1.は当然の仕様であり、set -eの仕様として注意しなければいけないのは 2. だけです。3. も重要な話ですがこれは(おそらく)シェルのバグです。

注意点1

「コマンド・シェル関数と条件文を組み合わせると set -e でスクリプトが終了しない」

ここでいう条件文とは if, &&, || や 条件付きループの while, until のことを指しています。終了ステータスを判定したいから条件文と組み合わせるわけで、終了しないのは当然の仕様だと思います。(スクリプトが終了してしまえば判定のしようがありませんよね?)終了ステータスが破棄される(= 終了ステータスを知ることが出来ない)とは違ってスクリプトが終了しないだけなので、終了ステータスを参照して条件分岐を行うことができます。

set -e
foo() { false; }

# foo # これは当然終了する

if foo; then # 終了しない
  echo true
else
  echo false # 終了ステータスを判定し、この行が実行される
fi

foo && true # 終了しない(true は実行されない)
foo && : # 同じ意味
echo $? # => 1 終了ステータスは true が実行されないのでそのまま残っている

# true && foo # これは終了する(foo の実行結果が 1 だから)

# 参考 エラーを無視する正しい方法
# 終了しないかつ、エラーの時に終了ステータスを0にする
foo || true # または foo || :
echo $? # => 0

注意点2

「シェル関数と条件文を組み合わせるとシェル関数内では set -e は無効になる」

シェル関数の呼び出し元で set -e が無効になるのではなく、シェル関数の中set -e が無効になります。シェル関数の中で set -e しても再び有効にすることはできません。また直接呼び出したシェル関数だけでなくその内部で呼び出しているシェル関数も全て含まれます。(ちなみにシェル関数でないものはそもそもシェルスクリプトではないか外部プロセスなので当然 set -e の話は関係ありません。)

set -e
foo() { echo foo1; false; echo foo2; bar; }
bar() { false; echo bar; }

# 条件文と組み合わせてないので、foo1 だけが表示され
# foo シェル関数の false でスクリプトが終了する
foo 

# 条件分と組み合わせているので false で止まらず foo1, foo2, bar と表示される
if foo; then :; fi 

# 同様
foo && true

# 同様
(foo) && true # サブシェルに入れても関係なし

この件に関し「やっぱりややこしいから set -e を使わない」と思うのであればちょっと待ってください。set -e を消したらそれで解決するでしょうか? set -e を消しても false で止まらないのは同じことです。この問題を解決するなら false の代わりに return 1 (または exit 1)を使うことになるはずで return 1 を使うのであれば set -e はそのままでかまいません。この問題の解決方法(の一つ)は set -e を使わないことではなく return を使うことです。

注意点3

「シェル関数と代入と条件文を組み合わせたときのバグ」

シェル関数と代入と条件文を組み合わせた場合に一部のシェルは挙動が異なります。(おそらくバグです。)以下のコードは set -e を使用していますが、シェル関数を条件文と組み合わせているため「注意点2」に従って、シェル関数の中で set -e が無効となり foo シェル関数は中断しないのが正しい動きのはずです。実際 ksh88, ksh93, bash, zsh では中断されません。しかし、bash 3以前のPOSIXモード, dash, mksh, pdksh, posh, loksh(OpenBSD ksh の Linux 移植版)では foo シェル関数は中断してしまいます。(代入と条件文とを組み合わせていない場合には当てはまりません。)

set -e
foo() { false; echo foo; }

if ret=$(foo); then
  # 本来は false で止まらないのでこちらが正しい
  echo "true: $? $ret" # => true: 0: foo
else
  echo "false: $? $ret"
fi

ret=$(foo) &&:
if [ $? -eq 0 ]; then
  # 本来は false で止まらないのでこちらが正しい
  echo "true: $? $ret" # => true: 0: foo
else
  echo "false: $? $ret"
fi

echo end

(先に言っておくと上記の例では falseexit 1 に置き換えたとしてもサブシェルを使っているためスクリプトは終了しません。)

バグがあるシェルも含めて全てのシェルで同じ動きをさせるには以下のようなコードにする必要があります。

false でスクリプトを中断させない場合

本来あるべき正しい仕様です。set -e が自動で無効にならないのですから(サブシェルの中で)手動で無効にします。ただし、単純に set +e するのは正確ではありません。$- で取得できる現在のシェルオプションの値が異なります。set +e をしてしまうと $- の中の (-e を意味する)e まで消えてしまいます。そこで &&: を使って set -e を無効にします。この場合は $- の値はそのままです。と言いたい所ですが bash では e が消えてしまっています。そこでサブシェルの中で set -e をした後 &&: を使って set -e を無効にするという一見よくわからないことをします。

もっとも $- を参照することは少ないと思うので必ずしもここまでする必要はありません。通常は手抜き版で十分ですし、場合によっては set +e をした方が都合が良い場合もあると思います。より本来の動きに近づけるためにはこうするという例です。

set -e
foo() { false; echo foo; }

# 手抜き版は ret=$(set +e; foo) や ret=$(foo &&:)
if ret=$(set -e; foo &&:); then 
  # 全てのシェルでこちらになる
  echo "true: $? $ret" # => true: 0: foo
else
  echo "false: $? $ret"
fi

ret=$(set -e; foo &&:) &&:
if [ $? -eq 0 ]; then
  # 全てのシェルでこちらになる
  echo "true: $? $ret" # => true: 0: foo
else
  echo "false: $? $ret"
fi

false でスクリプトを中断させる場合

反対に false でスクリプトを中断させる方法です。POSIX の仕様とは異なりますが、関数内で処理を中断できるので、これはこれで使い道があります。実装方法は全体としては set -e を使いますが必要な箇所で一旦 set +e に戻します。そしてサブシェルの中で set -e を使用します。条件文とは組み合わせていないのでサブシェル自体は false で中断しますが、呼び出し元では set +e に変更しているのでシェルスクリプト自体は終了しません。

set -e
foo() { echo foo1; false; echo foo2; }

set +e
ret=$(set -e; foo)
ex=$?
set -e
if [ "$ex" -eq 0 ]; then
  echo "true: $ex: $ret"
else
  # 全てのシェルでこちらになる
  echo "false: $ex: $ret"
fi

補足 set -eを使うと一部のコマンドで意図せず終了する

set -e を使うと let, grep, diff などの一部のコマンドで意図せず終了するという話があったので、で少し余談になりますが補足します。これは単にコマンドの仕様を把握してないだけなので全く別の話です。例えば let は代入する値が 0 だと終了ステータスが 1 になります。grep は行が見つからなかったときに終了ステータスは 1 になります。diff は違いがあった時に終了ステータスが 1 になります。そのため set -e をしているとその時に終了してしまいます。だからとって set -e をしないほうが良いということにはなりません。なぜならset -e をしないで済ませるということは単にエラーチェックをサボっていることを意味しているからです。grep, diff で本当にエラーが発生したとき停止しないそのスクリプトは安全な動きをするのでしょうか?エラーチェックをちゃんとやっているならばコマンドの終了ステータスを正しく取り扱っているはずで set -e で意図せず終了してしまうことなどないはずです。(ちなみに let を使うよりは、POSIX 準拠の算術式展開 $(( )) を使いましょう。代入する値が 0 でも終了ステータスが 1 になったりしません。)

4. set -eとうまく付き合う方法

シェル関数と条件文を組み合わせて使うと set -e が無効になるのはわかったけどじゃあどうすれば良いのか?となると思います。そこで set -e とうまく付き合うための方法をまとめます。一般的なシェルスクリプトであればこの程度のルールを守れば十分だと思います。

わざわざ自分でエラーチェックをしない

せっかく set -e を使ってるのに自分でチェック( cmd || exit $? 等)していたら set -e をする意味がありません。set -e でシンプルになるのですから自分でチェックするのはやめましょう。

# 自分でエラーチェックをしなければシンプル
set -e
cmd() {
  foo # foo がエラーなら止まります
  bar # bar がエラーなら止まります
}
cmd
# 自分で(部分的に)チェックしたために動作不良
set -e
cmd() {
  foo # foo がエラーでも止まりません
  bar # bar がエラーでも止まりません
  # 注意 set -e をやめても foo, bar で止まらないのは変わらない
}
cmd || exit $? # barの終了ステータスが残っているため一応止まる
# 全部チェックすると set -e してないのと同じことになるので意味がない
set -e
cmd() {
  foo || exit $? # または foo || return $?
  bar || exit $? # または bar || return $?
}
cmd || exit $?

終了ステータスを戻り値として使用するシェル関数は set -e を前提としない

終了ステータスを戻り値として使用するシェル関数というのは例えば引数が数字であるかをチェックする is_number みたいな関数です。

set -e

is_number() {
  # trコマンドがエラーになったらシェルスクリプトは停止するだろう?
  ret=$(echo "$1" | tr -d "[:digit:]") # 数字を削除する
  [ "$ret" = "" ] # 空文字 = なら全部数字
}

if is_number "$1"; then
  echo "number"
else
  echo "not number"
fi

このような例で tr コマンドが存在しないなどでエラーが起きたらシェルスクリプトは止まるだろうと思っても、条件文により set -e は無効になっているため終了しません。(この例では常に number が表示されるでしょう。)set -e を前提としないというより出来ないと言ったほうが正確ですね。

これを回避する一番良い方法は、シェル関数でエラーになるようなことをしなければいいのです。例えば is_number 関数であればシェルスクリプトだけで実装できます。set -e で止まることを期待するコードがないので当然うまく動きます。

set -e

is_number() {
  # 本当は case を使ったほうが楽だけど上のコードと処理内容を合わせています。
  ret=${1//[0-9]/} # 数字を削除する。面倒なのでPOSIX準拠ではありません。
  [ "$ret" = "" ] # 空文字 = なら全部数字
}

if is_number "$1"; then
  echo "number"
else
  echo "not number"
fi

もう一つのやり方は set -eを使用していないつもりでシェル関数を書くことです。勘違いしてはいけないのはシェルスクリプト全体は set -e を使用していいということです。set -eに依存できないのは該当のシェル関数のみです。その他の箇所では依然として set -e の恩恵をうけられます。

set -e

is_number() {
  ret=$(echo "$1" | tr -d "[:digit:]") || return 1
  [ "$ret" = "" ] # 空文字 = なら全部数字
}

if is_number "$1"; then
  echo "number"
else
  echo "not number"
fi

終了ステータスを戻り値として使うのは避ける

結局の所 set -e がシェル関数の中で無効になってしまうのは条件文と組み合わせるからです。条件文と組み合わせたい場合というのは終了ステータスを判定する場合です。先程の is_number は終了ステータスを成功・エラーの意味ではなく判定結果の戻り値として使ったために注意が必要になりました。しかし終了ステータスを成功・エラーという意味に限定すればそのような注意は必要なくなります。その場合は戻り値は標準出力、または変数で返します。(標準出力で戻り値を返す場合は、一旦変数に代入してから参照するということを忘れないでください。)

is_number の例であればエラーが起きないようにすれば良いのですが、仮に標準出力または変数で返す場合はこのようになります。

# 標準出力を使った例
set -e

is_number() {
  ret=$(echo "$1" | tr -d "[:digit:]") # エラーが起きればここで止まる
  [ "$ret" = "" ] && echo "ok"
}

ret=$(is_number "$1") # サブシェルなのでここで一旦エラーを受け取るが連鎖的に止まる
if [ "$ret" = "ok" ]; then
  echo "number"
else
  echo "not number"
fi
# 変数を使った例
set -e

is_number() {
  ret=$(echo "$1" | tr -d "[:digit:]") # エラーが起きればここで止まる
  [ "$ret" = "" ] && ret="ok" || ret=""
}

is_number "$1"
if [ "$ret" = "ok" ]; then
  echo "number"
else
  echo "not number"
fi

# 本筋から外れるのでここでは省略しますが、どの変数に返すのか
# 分かりづらいので is_number ret "$1" のように返す変数の名前を
# 指定できるようにしたほうが良いです。ヒント eval を使います。

番外編 この記事を書こうと思い立ったきっかけ

ここからは雑談に近いので詳しく読まなくていいです。興味がある人だけどうぞ。事の始まりはとあるシェルのための正しくないワークアラウンドの修正でした。ソースコードを細かく説明しても埒が明かないので要領を得ないかもしれませんが何をやったかだけをを紹介します。

正しくないワークアラウンド

正しくないワークアラウンドとはこのコードです。シェルが bosh または pbosh の場合だけ上のコードを、それ以外は下のコードを実行しています。ですが本来はやりたかったのはほとんどのシェルで上のコードを使用することで、一部のバグがあるシェルでのみ下のワークアラウンドのコードを使用したかったのです、その時は問題が解決できず、これで動くからとお茶を濁していました。

if [ "${SHELLSPEC_SHELL_TYPE#p}" = "bosh" ]; then
  shellspec_evaluation_run_subshell() {
    ( set "$1"; shift; shellspec_evaluation_run_data "$@" )
  }
else
  # Workaround for #40 in contrib/bugs.sh
  # ( ... ) not return exit status
  shellspec_evaluation_run_subshell() {
    #shellcheck disable=SC2034
    SHELLSPEC_DUMMY=$( set "$1"; shift; shellspec_evaluation_run_data "$@" )
  }
fi

bash 4.1 - 4.3系 のバグ

(下のワークアラウンドを使わないといけない)一部のシェルにあるバグというのは次のようなもので、サブシェルからの終了ステータスが保持されてないというバグです。

set +e
func() { set -e; return 123; } # 補足 func 関数内で set -e による中断は機能します
(func)
echo $? # => 123
# ↑ ここで $? が 123 ではなく 1 になるシェルがある

# dummy=$(func) # ワークアラウンド
echo $? # => 123

このバグが存在するシェルは、bash の 4.1.5 (2009年頃), 4.2.0, 4.2.37, 4.3.30 などです。3.2.39 以前 や 4.4.12 (2016年頃)以降 では発生しません (4.0 は未確認)。この問題はただのサブシェルの () 代わりに下のワークアラウンドのコード、コマンド置換(dummy=$())を使うことで対応可能です。ということでバグがあるこれらのシェルでのみワークアラウンドのコードを使うようにしました。そうすると別のバグが発生しました。

mksh, posh のバグ

そのバグによって上のコード(本来使用したかったコード)が正しく動かないシェルは、mksh 39, 40 と Debian 6 の pdksh と posh 0.8.5以降 です。posh は(失礼ながら)あまり使われてないのとその他にもバグがあるのでまたかという感じですが、気になるのは mksh 39, 40 です。(mksh 35 では発生しません。)mksh は pdksh のフォークで、Debian では 6.0 (2011年頃) で pdksh 5.2.14 から mksh 39 への移行が行われています。また Debian 6 の pdksh は 本物の pdksh ではなく lksh (mkshの一種)の pdksh 互換ビルド版らしいです。この2つは同様の問題だと考えられます。posh も pdksh を元に機能を最小限に削った所から始まったフォークらしいですが、うーん?時代的にどうなんでしょうか。本物の pdksh では発生しないんですよね。元のコードが似ているから同様のバグを入れてしまったとか?

バグの原因は(調べるのに疲れたので)細かい発生条件は調べてないですが、少なくとも次のような対応が必要でした。

shellspec_output_if SKIP || {
・・・
# この中使っている関数から、上記のワークアラウンドのコードが呼び出されている
・・・
}if ! shellspec_output_if SKIP; then
・・・
# この中使っている関数から、上記のワークアラウンドのコードが呼び出されている
・・・
fi

つまり呼び出し元で || を使っていたのがまずく if であれば問題なかったということです。(ただし発生条件はこれだけではないようです。)なおこれらのシェルでは、set -e しているのにシェル関数の中のエラー(false 等)で止まらない という形のバグとして現れます。

bash 4.3系のバグ

この修正で問題が解決したかと思いきや、bash の 4.1 系と 4.2 系では解決しましたが。4.3 系はワークアラウンドのコードだけでは不十分でした。4.3 系で問題を解決するには加えて以下の修正が必要でした。なにをしているのかと言うと shellspec_import_ 関数に切り出していた処理を shellspec_import_deep 関数に埋め込んだだけです。なぜこれで解決したのか全くわかりません。

shellspec_import_deep() {
  shellspec_import_ "${1%%:*}" "$2" && return 0 # && の代わりに if にしても解決せず
  case $1 in    
    *:*) shellspec_import_deep "${1#*:}" "$2" ;;    
    *) shellspec_error "Import failed, '$2' not found" ;;   
  esac  
}   

shellspec_import_() {   
  if [ -e "$1/$2.sh" ]; then    
    # shellcheck disable=SC1090
    . "$1/$2.sh"
    return 0
  fi
  if [ -e "$1/$2/$2.sh" ]; then
    # shellcheck disable=SC1090
    . "$1/$2/$2.sh"
    return 0
  fi
  return 1
}

↓

shellspec_import_deep() {
  if [ -e "${1%%:*}/$2.sh" ]; then
    # shellcheck disable=SC1090
      . "${1%%:*}/$2.sh"
      return 0
  fi
  if [ -e "${1%%:*}/$2/$2.sh" ]; then
    # shellcheck disable=SC1090
    . "${1%%:*}/$2/$2.sh"
    return 0
  fi
  case $1 in
    *:*) shellspec_import_deep "${1#*:}" "$2" ;;
    *) shellspec_error "Import failed, '$2' not found" ;;
  esac
}

バグまとめ

これらの3種類のバグの影響を受けるシェルをまとめる次のようになります。

  • bash (4.0系?)、4.1系、4.2系・・・ Debian 3 から Debian 7(2018-05-31サポート終了)
  • bash 4.3系 ・・・ Debian 8(2020-06-30サポート終了)
  • mksh 39、40、Debaian 6 の pdksh (lksh) ・・・ Debian 6 から Debian 7
  • posh 0.13.2 (現時点で最新版)以前 ・・・ 最新 Debian 10 含む

ここにあげているバグに関しては、bashに関しては終了ステータスが正しく取れないだけでエラーにはなります。唯一サポート期間が残っている Debian 8 はもうすぐサポートが終わります。問題がある mksh を提供している Debian 7 はすでにサポートは終わっています。posh に関しては常用している人はまずいないでしょうし他にもバグが多数あるのでそもそも使用をおすすめできません。そのため番外編のバグに関しては現在はさほど問題にならないと思います。

さいごに

ということで、set -e を罠を避けて使うための方法でした。ちなみにこの記事はもっと簡潔にまとめて安心して set -e を使ってもらえるようにしたかったのですが無理でした・・・。シェルのバグとかシェルのバグとか。Linuxに関してはかなり調べていますが BSD 系とか UNIX はほとんど調べてないですし。(テストしてるのは Solaris 10, 11 と FreeBSD sh (ash?) と OpenBSD ksh の Linux 移植版の loksh ぐらい)

過去のバグに関してはほとんど影響ないでしょうが一部のシェルで動きが違っていたのは事実ですし、今も関係がある「注意点3 シェル関数と代入と条件文を組み合わせたときのバグ」の影響が大きいです。最もよく使われているであろう bash と dash で動きが違いますから。こんな状態では set -e の挙動を正しく把握するのは難しいというのは仕方ない話だと思います。

それでも多くの人が set -e の問題だと思ってるのは set -e とは関係ない話だと思いますし、ここであげた注意点にさえ気をつけていればシェルスクリプトのエラー処理を簡潔に出来るので、私は set -e の使用をおすすめします。記事は長くなりましたがコードは短い方が好きです。