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) に差し替えて、止まるかどうかを確かめるのが確実だ。
cron などで無人で回すときに踏みやすい、pipefail 下の exit 141 や二重起動は 自動実行のbashが突然落ちる・失敗を見逃す4つの罠 にまとめた。
bash の文法や終了ステータスの決まりを体系的に押さえておくなら、bash を前提にシェルスクリプトの書き方を一通り解説している本が手元にあると便利だ。
新しいシェルプログラミングの教科書 (三宅英明、電子書籍)