結論

  • 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 ]] && cmdc が偽だと関数が 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 || DA / B の失敗でも D が走る文を ; で切る
|| / if から呼んだ関数・subshell中の失敗が素通り関数内で || return 1
while / if 条件の $(cmd)失敗が「偽」になる取得を条件の外へ出す
$( ) の中失敗が素通りshopt -s inherit_errexit
set -e より前の行守られないシェバン直後に置く
source されるファイルの set呼び元に残るライブラリには書かない
関数を source して呼ぶテストランナーの設定で動く( set -euo pipefail; f ); rc=$?

参考