結論
set -eは「条件の文脈の外で、コマンドが非 0 を返したら止まる」だけの仕組みで、失敗を報告はしない- 効きすぎる: 関数の戻り値、
x=$(cmd)の代入、((n++))、wait、readが非 0 を返すと、メッセージ無しでその行で終わる - 効かない:
&&/||の途中、if/whileの条件、そこから呼んだ関数と subshell の中、$( )の中、set -eより前の行 - 失敗の扱いは
set -e任せにせず、その文に|| return 1や|| fallbackを付けて明示する
以下のコードはすべて GNU bash 5.3.15 で実測した。
前提: set -e が止めない文脈
bash は、失敗したコマンドが次のどれかに当たるとき set -e で終了しない。
while/untilの直後のコマンドリストif/elifの条件&&/||のリストの途中 (最後以外)!で反転したコマンド
この免除は、その文脈で実行される関数本体と subshell の中まで及ぶ。 逆に、この文脈の外で非 0 を返した文は、関数呼び出しでも代入文でも止まる。
効きすぎる: 思わぬ行で無言終了する
関数末尾の [[ cond ]] && cmd は関数ごと失敗させる
&& の左辺が偽だと、リスト全体の終了状態は 1 になる。それが関数の最後の文なら、関数の戻り値が 1 になる。
set -e
load_env() {
[[ -e "$1" ]] && export FOO=1 # ファイルが無いと戻り値 1
}
load_env /nonexistent # ここでスクリプトごと終了 (rc=1)
echo "ここには来ない"[[ ]] && の行は && の途中なので、その行では落ちない。落ちるのは 1 を返した関数を呼んだ側で、原因の行と止まる行がずれる。
スクリプトの最終行に置いても同じで、処理が全部済んだあとに終了コード 1 で終わる。
対策は if で書くか、return 0 で締める。
load_env() {
if [[ -e "$1" ]]; then
export FOO=1
fi
}関数の非 0 は一般にこの扱いになる。return 1 した関数を単体で呼ぶと、呼び出し元ごと何も言わずに終わる。
「進めないから止める」なら理由を出して exit し、「付随処理を飛ばしただけ」なら警告を出して return 0 にする。
mark_status() {
grep -q '^status:' "$f" || { echo "WARN: no status line in $f" >&2; return 0; }
sed -i "s/^status:.*/status: $1/" "$f"
}- PITFALL: shellcheck も
bash -nも通る。条件が真になるパスでしかテストしないと、この分岐は踏まれない
x=$(cmd) の単独代入は cmd の失敗で止まる
コマンド置換を含む代入文の終了状態は、コマンド置換の終了状態になる。次の行に置いたガードには届かない。
$ bash -c 'set -euo pipefail
installed="$(nosuchcmd --version)"
[[ -n $installed ]] || echo "guidance"'; echo "rc=$?"
bash: line 2: nosuchcmd: command not found
rc=127
ガードは代入文そのものに || で付ける。|| の左辺になった時点で免除される。
set -euo pipefail
installed="$(tool --version 2>/dev/null)" || installed=""
[[ -n $installed ]] || echo "tool が要る"grep の「一致なし = 1」と pipefail が重なると、この形を踏みやすい。
lineno=$(grep -n "$pattern" "$f" | head -1 | cut -d: -f1) || :
[[ -n $lineno ]] || echo "warn: not found"「見つからなければ空を返す」関数を v=$(resolve) で呼ぶ場合も同じで、関数の最後が偽のテストだと呼び出し側が落ちる。関数は return 0 で締め、成否は出力が空かどうかで表す。
- PITFALL:
local x=$(cmd)は逆に止まらない。終了状態が組み込みコマンドlocalのもの (0) になるため。local x; x=$(cmd)と分けると止まる
((n++)) は n が 0 のとき失敗を返す
((...)) は式の値が 0 なら終了状態 1 を返す。後置インクリメントの式の値は加算前の値なので、0 から数え始めると初回で止まる。
set -e
n=0
((n++)) # 式の値は 0 => 終了状態 1 で停止
echo "ここには来ない"n=1 からなら通る。カウンタは n=$((n + 1)) で増やす。
wait はバックグラウンドの終了状態をそのまま返す
set -e
(exit 3) & pid=$!
rc=0
wait "$pid" || rc=$? # || が無いと wait の 3 でここで停止
echo "rc=$rc" # => rc=3- PITFALL:
wait "$pid"の次の行でrc=$?を取る形は、その行に届く前に止まる
read は EOF で 1 を返す
破壊的な操作の前に Enter で確認を取るスクリプトを、パイプや cron から動かすと read が EOF で 1 を返し、そこで止まる。
これは安全側に倒れる挙動なので、そのままでよい。
set -euo pipefail
read -r -p "続行するなら Enter: " _ # 非対話では EOF => 停止
destroy_resources- PITFALL:
read ... || trueと書くと EOF が握り潰され、非対話では確認を素通りする。非対話で通したいなら--yesのような明示フラグを別に用意する
効かない: 失敗が素通りする
A && B || D の D は「どれかが失敗したとき」に走る
&& と || は同じ優先度の左結合なので、A && B && C || D は ((A && B && C) || D) になる。
set -e は && / || の途中では発火しないので、A や B の失敗も止まらずに D へ流れる。
$ bash -c 'set -e; ENV_FILE=/nonexistent.env EXPECTED=p1
set -a && . "$ENV_FILE" && set +a && [[ "${PROJECT_ID:-}" == "$EXPECTED" ]] || { echo "接続先が違う: ${PROJECT_ID:-未設定}" >&2; exit 1; }'
bash: line 2: /nonexistent.env: No such file or directory
接続先が違う: 未設定
env の読み込み失敗が「接続先が違う」と報告される。set -e があるなら文を ; や改行で切り、ガードは 1 文に閉じる。
set -e
set -a; . "$ENV_FILE"; set +a # 読み込み失敗はここで止まる
[[ "${PROJECT_ID:-}" == "$EXPECTED" ]] || { echo "接続先が違う: ${PROJECT_ID:-未設定}" >&2; exit 1; }
run-the-thingこの免除は意図して使うこともできる。cmd "$pkg" && break は、失敗してもリトライのループを続ける。
|| の左辺や if の条件で呼んだ関数の中は、丸ごと免除される
set -euo pipefail
f() { false; echo "ここまで来る"; }
rc=0
f || rc=1 # => "ここまで来る" が出て、rc は 0 のまま関数の終了状態は最後に実行したコマンドのものになるので、途中の失敗は消える。関数内で set -e し直しても、呼び出しが終わるまで効かない。
subshell も同じで、( set -e; false; echo reached ) || rc=$? は reached を出す。
対策は、関数の中で失敗を自分で返すこと。
validate() {
local body
body=$(jq -c . "$1") || return 1
echo "$body"
}
rc=0
validate broken.json || rc=1 # => rc=1- PITFALL: 単体で
fと呼ぶと止まるので、手で試しても気づかない。テストで失敗を注入しても空振りし、「落ちない = 直った」と誤読する
while / if の条件に入れたコマンド置換の失敗は「偽」になる
"$(cmd)" の失敗は引数が空文字になるだけで、外側の述語が偽を返してループが続く。
set -euo pipefail
# get_url が失敗しても回り続ける
while ! is_logged_in "$(get_url)"; do sleep 3; done
# 取得を条件の外に出すと、失敗で止まる
while true; do
url=$(get_url) || { echo "failed to get url" >&2; exit 1; }
is_logged_in "$url" && break
sleep 3
done- PITFALL: 1 回だけ取得していた処理をポーリングに書き換えると、
set -eで止まっていた失敗が無限ループに変わる
$( ) の中には set -e が届かない
bash のコマンド置換は既定で errexit を外して走る。shopt -s inherit_errexit を立てると継承する (posix mode では自動で有効)。
$ bash -c 'set -e; x=$(false; echo REACHED); echo "x=[$x]"'; echo "exit=$?"
x=[REACHED]
exit=0
$ bash -c 'set -e; shopt -s inherit_errexit; x=$(false; echo REACHED); echo "x=[$x]"'; echo "exit=$?"
exit=1
- PITFALL:
$(echo $-)や$(shopt -po errexit)で状態を確かめると、確かめる式自体がコマンド置換なので常に off と答える。見るならcase $- in *e*)を直接書く
set -e より前の行は守られない
set は実行した時点から後にしか効かない。main() の中で source lib.sh => set -euo pipefail の順に書くと、source の失敗は素通りする。
#!/usr/bin/env bash
set -euo pipefail # シェバン直後に置く
main() {
source "${script_dir}/lib/funcs.sh" # 読み込み失敗で止まる
# 以降の処理
}
main "$@"- PITFALL: 読み込みに失敗したライブラリの関数を、別コマンドの引数に埋めたコマンド置換で呼ぶと、空文字になるだけで止まらない。
echo "X=$(lib_func)" > out.envはX=だけのファイルを書いて rc=0 で終わり、空値が後段へ流れる
オプションはシェル全体の状態
set -e / set -o pipefail は関数や定義ファイルに属さず、実行中のシェルの状態として効く。
source される側のファイルに set を書くと、呼び元に残る。
# lib.sh
set -euo pipefail
# caller.sh
case $- in *e*) echo "before: ON";; *) echo "before: OFF";; esac
source ./lib.sh
case $- in *e*) echo "after: ON";; *) echo "after: OFF";; esac
false
echo "NOT REACHED"出力は before: OFF => after: ON で、NOT REACHED は出ずに exit 1。読み込んだ後の無関係な行で落ちるので、原因のファイルに辿り着きにくい。ライブラリ側には書かない。
逆に、関数を source して呼ぶテストは、テストランナー側の設定で動く。本番が set -euo pipefail で止まる前提の関数も、set +e のランナーでは失敗の後ろまで走る。pipefail も同じ。
$ echo 'get() { false | cat; }' > lib.sh
$ bash -c 'source ./lib.sh; get; echo $?'
0
$ bash -c 'set -o pipefail; source ./lib.sh; get; echo $?'
1
失敗系のテストは、本番と同じオプションの subshell で呼ぶ。
# ランナー自身は set +e のまま
source ./target.sh
( set -euo pipefail; target_function ); rc=$?- PITFALL: この subshell を
|| ...の左辺やifの条件に置くと、前述の免除で errexit が効かなくなる。終了状態は次の文で$?から取る
まとめ
| 場面 | 挙動 | 対策 |
|---|---|---|
関数末尾の [[ c ]] && cmd | c が偽だと関数が 1 を返し、呼び出し元で停止 | if で書くか return 0 で締める |
x=$(cmd) の単独代入 | cmd の失敗で停止。次行のガードに届かない | 同じ文に || fallback を付ける |
local x=$(cmd) | 止まらない | local x; x=$(cmd) と分ける |
((n++)) で n=0 | 停止 | n=$((n + 1)) |
wait "$pid" | 子の終了状態で停止 | wait "$pid" || rc=$? |
read の EOF | 停止 (安全側) | || true で潰さない |
A && B || D | A / B の失敗でも D が走る | 文を ; で切る |
|| / if から呼んだ関数・subshell | 中の失敗が素通り | 関数内で || return 1 |
while / if 条件の $(cmd) | 失敗が「偽」になる | 取得を条件の外へ出す |
$( ) の中 | 失敗が素通り | shopt -s inherit_errexit |
set -e より前の行 | 守られない | シェバン直後に置く |
source されるファイルの set | 呼び元に残る | ライブラリには書かない |
| 関数を source して呼ぶテスト | ランナーの設定で動く | ( set -euo pipefail; f ); rc=$? |
