set -eを書いたのに、失敗したコマンドの後もスクリプトが進む。|| で呼んだ関数、コマンド置換、local付き代入、&&で終わる関数など、止まらない場面と直し方をbash 5.3の実行結果つきで解説します。
本記事はプロモーションを含みます。
set -euo pipefail を先頭に書いておけば、途中で失敗したら止まる。そう思って書いたスクリプトが、失敗の後も平気で進んでいたことはありませんか?
結論から書くと、set -e には 効かない文脈 があり、しかもその中で呼んだ関数の中身まで丸ごと無効になります。代表的なのは次の 5 つです。
| 場面 | 何が起きるか | 直し方 |
|---|---|---|
f || rc=1 のように呼んだ関数 | 関数の中のどの失敗でも止まらない | 関数の中で || return 1 を書く |
echo "$(cmd)" | cmd が失敗しても空文字で進む | いったん x=$(cmd) で受ける |
local x=$(cmd) | local の成功 (0) で失敗が消える | 宣言と代入を分ける |
最後が A && B の関数 | A が偽だと関数が 1 を返し、呼び出し元で止まる | if で書くか return 0 |
x=$(jq ...) | 壊れた JSON でも先頭の値が入る | 終了ステータスを必ず見る |
以下の実行結果は、どれも GNU bash 5.3.15 で確かめたものです。
複数のファイルを順に検証し、1 件が失敗しても残りは続けたい。そんなときに書きがちなのがこの形です。
set -euo pipefail
f() { false; echo "ここまで来る"; }
rc=0
f || rc=1
echo "rc=$rc"
ここまで来る
rc=0
false で止まるどころか、関数は最後まで走り、rc も 0 のままです。
set -e は、&& / || の途中のコマンドと、if / while の条件では無視されます。この無視は、その文脈で呼んだ関数の本体にも及びます。関数の中で set -e をし直しても、呼び出しが終わるまで効きません。
関数の終了ステータスは最後に実行したコマンド (ここでは echo) のものなので、|| rc=1 も発火しません。
関数の中では、失敗を自分で返します。
validate() {
local body
body=$(jq -c . "$1") || return 1
curl -sS --fail-with-body ... || return 1
}
単体で f と呼べば止まるので、手で試しても気付きにくいのがこの場面の厄介なところです。
コマンド置換 $(...) の失敗が set -e に届くのは、代入だけの行 のときに限られます。
set -e
echo "[$(false)]" # 止まらない。[] を出して進む
x=$(false) # ここで止まる
代入だけの行の終了ステータスは「最後に実行したコマンド置換の終了ステータス」と決まっています。一方、echo のようにコマンドを実行する行では、そのコマンド (echo) の終了ステータスが優先されます。
失敗を検知したいコマンド置換は、いったん変数で受けます。
out=$(cmd)
echo "[$out]"
逆に、失敗を意図して無視したいときは x=$(cmd) || true で回避できます。
関数の中で local を付けて代入すると、コマンド置換の失敗が消えます。
set -e
f() { local x=$(false); echo "ここまで来る"; }
f
local は組み込みコマンドなので、行の終了ステータスは local 自身の成功 (0) で上書きされます。場面2 の「コマンドを実行する行」と同じ理屈です。
宣言と代入を分けます。
f() {
local x
x=$(false) # ここで止まる
}
なお、false; local rc=$? のように、直前のコマンドの $? を local で受けるのは問題ありません (rc=1 が取れます)。0 に戻るのは local x=$(cmd) の形だけです。
「条件が立つときだけ実行」を短く書いた関数は、逆に 止まってほしくない所で止まります。
set -e
v=""
f() { [[ -n "$v" ]] && echo hi; }
f
echo "ここに来ない"
関数の中では && の左辺なので止まりません。ところが関数の戻り値は最後に実行したコマンドの終了ステータスなので、[[ ]] の 1 がそのまま返り、呼び出し元の f の行で set -e が発火します。
if で書きます。どの分岐も実行しなかった if は 0 を返します。
f() {
if [[ -n "$v" ]]; then
echo hi
fi
}
関数の末尾に return 0 を置いても構いません。
この形は shellcheck (0.11.0) では警告されませんでした。静的解析が通っても安心できない例です。
set -e そのものの穴ではありませんが、場面1 と重なると被害が大きくなるのが jq です。jq は入力を「JSON 値の並び」として先頭から読むので、正しい値の後ろに壊れた文字列があると、先頭の値を出力してから 失敗します。
printf '{"a":1} garbage' > bad.json
jq -c . bad.json; echo $?
{"a":1}
jq: parse error: ...
5
x=$(jq -c . bad.json) だと、x に {"a":1} が入った状態で失敗が返ります。これを場面1 のように || rc=1 で呼んだ関数の中でやると、失敗は無視され、壊れた入力の一部だけが後続 (API への送信など) へ渡ります。
終了ステータスを必ず見ます。
body=$(jq -c . bad.json) || { echo "壊れた JSON" >&2; exit 1; }
複数の値も部分的な出力も許さないなら、-s で全体を読んでから 1 値かを確かめます。
jq -s -c 'if length == 1 then .[0] else error("not a single value") end' file.json
|| / && / if の中で呼んだ関数では、set -e は丸ごと効かない。関数の中で || return 1 を書くx=$(cmd) の行だけ。echo "$(cmd)" と local x=$(cmd) では消えるA && B の関数は、逆に呼び出し元を止める。if で書くどれも「手で 1 回試すと動く」ので、実行してみるだけでは気付けません。怪しい形を見つけたら、わざと失敗するコマンド (false) に差し替えて、止まるかどうかを確かめるのが確実です。
bash の文法や終了ステータスの決まりを体系的に押さえておくなら、bash を前提にシェルスクリプトの書き方を一通り解説している本が手元にあると便利です。
新しいシェルプログラミングの教科書 (三宅英明、電子書籍)