cronやCIで回すbashが、ある日exit 141で落ちる。テストの失敗を数えない。evalで意図しないコマンドが走る。二重起動でファイルを壊す。筆者が踏んだ4つの罠と、どう直したかをbash 5.3の実行結果つきで解説します。

本記事はプロモーションを含みます。
毎日 cron で回しているスクリプトが、ある日から落ち始める。テストは全件 pass なのに、壊れたコードが通っている。手で 1 回動かすと問題なく見えるので、原因を探すのに時間がかかります。
筆者がブログの記事 PR を作る自動化や、Claude Code の hook (コマンドを実行前に検査するスクリプト) を書く中で踏んだのは、次の 4 つでした。
| 場面 | 何が起きたか | どう直したか |
|---|---|---|
grep ... | head -n 15 | 入力が増えた日から exit 141 で落ちる | grep -m 15 で grep 自身に切り詰めさせる |
| パイプの中で失敗数を数える | 失敗が数えられず、常に全件 pass | プロセス置換で回し、先に red を確かめる |
eval echo "$f" で ~ を展開 | 文字列の中の $(...) まで実行される | 先頭の ~ と $HOME だけを文字列で置き換える |
| cron と手動が同時に走る | 同じ作業ディレクトリのファイルを互いに上書き | flock で囲み、競合の終了コードを分ける |
set -e が効かない場面は bashのset -eが効かない5つの場面と対処法 にまとめています。この記事はその続きで、「止まるべきでない所で止まる」「失敗を見逃す」側の話です。以下の実行結果は GNU bash 5.3.15 と util-linux 2.42.3 の flock で確かめました。
記事 PR を毎日作るジョブが、ある日から exit 141 で落ち、PR が出ない日が続きました。原因は、保存した記事の一覧から新しい 15 件を取り出す、次のような行でした。
set -o pipefail
line=$(printf 'a%.0s' $(seq 2000))
for i in $(seq 44); do echo "$line"; done | grep a | head -n 15 >/dev/null
echo "rc=$?"
rc=141
同じ行に 2 行しか流さなければ rc=0 です。手元で少ない入力を流して試すと通ってしまいます。
head -n 15 は 15 行を読んだら終了し、パイプの読み口を閉じます。その後に上流の grep がパイプへ書き込むと、カーネルが grep に SIGPIPE を送ります (pipe(7))。シグナルで終わったプロセスの終了ステータスは 128 + シグナル番号なので、SIGPIPE (13) なら 141 です。
pipefail は、パイプラインの右端ではなく「最後に非 0 で終わったコマンド」の値を全体の終了ステータスにします。そのため head が 0 で終わっても、全体は 141 になり、set -e ならそこで止まります。
上流が書き終える前に head が抜けるかどうかは、データ量とパイプの容量で決まります。Linux のパイプの容量は既定で 65,536 バイトです。入力が小さいうちは上流が先に書き終えるので再現せず、件数が積み上がったある日に落ち始めます。しかもタイミング次第なので、同じ入力でも毎回落ちるとは限りません。grep a big.txt | head -n 15 を 8 回試すと、7 回が 141、1 回が 0 でした。
| head -n 15 をやめ、grep -m 15 で grep 自身に 15 件で止まってもらいました。grep が読むのをやめて正常に終わるので、閉じたパイプへ書き込むプロセスがいなくなります。
grep -m 15 a big.txt >/dev/null; echo "rc=$?"
rc=0
ただし、これで直るのは grep がパイプラインの先頭で、ファイルを直接読んでいるときだけです。grep の前にさらにコマンドがあると、今度はその上流が SIGPIPE を受けます。
seq 200000 | grep -m 15 . >/dev/null; echo "rc=$?"
rc=141
pipefail を付けたスクリプトで | head や grep -m を見かけたら、「上流が書き終える前に読み手が抜けないか」を確かめます。
Claude Code の hook に、止めるべきコマンドと通すべきコマンドを並べたテーブル駆動のテストを書きました。最初に書いたテスト用のハーネスは、失敗数をパイプの中で数えていたため、失敗が 1 件も集計されませんでした。
failed=0
check() { [[ $1 == ok ]] || failed=$((failed + 1)); }
printf '%s\n' ng ng ok | while read -r v; do check "$v"; done
echo "failed=$failed"
failed=0
ng が 2 件あるのに 0 です。このハーネスは、どんな実装に対しても「全件 pass」を出します。
bash では、パイプラインの各コマンドはそれぞれの subshell で実行されます (Bash Reference Manual: Pipelines)。subshell は親の変数をコピーした別プロセスなので、while の中で failed を増やしても、親シェルの failed は 0 のままです。$(...) の中で数えた場合も同じです。
入力をプロセス置換 < <(...) で渡すと、while は親シェルで動きます。
while read -r v; do check "$v"; done < <(printf '%s\n' ng ng ok)
echo "failed=$failed"
failed=2
スクリプトでは shopt -s lastpipe も使えます。ジョブ制御が無効なとき、パイプラインの最後のコマンドを今のシェルで実行する設定です (The Shopt Builtin)。対話シェルではジョブ制御が有効なので効かず、手で試したときと結果が変わる点に注意します。
この罠は、テストが緑のまま気付けないのが一番の問題です。筆者はハーネスを直した後、実装より先に、テストが落ちる (red になる) ことを確かめてから進めました。
自作のハーネスに限らず、「全件 pass」しか見たことのないテストは、失敗を報告できるかどうかがまだ分かっていない状態です。
同じ hook には、sed -i の対象ファイルが symlink かどうかを判定する処理がありました。コマンド文字列から取り出したファイル名の ~ を展開するために、eval echo を使っていました。
f='$(touch pwned)~/x'
eval echo "$f" >/dev/null
ls pwned
pwned
~ を展開したつもりが、touch pwned が実行されています。
eval は引数をシェルのコードとして読み直します。展開の種類は選べず、チルダ展開・変数展開・コマンド置換がすべて行われます。
hook はユーザがコマンドを承認する前に動きます。ファイル名に $(...) が混ざったコマンドを検査しただけで、承認より前にその中身が実行されることになります。
eval をやめ、パラメータ展開の置換で先頭の ~ と $HOME だけを展開しました。
f='~/x'
f=${f/#\~/$HOME}
f=${f/#\$\{HOME\}/$HOME}
f=${f/#\$HOME/$HOME}
echo "$f"
/home/<user>/x
その代わり、~user/x や、途中に $VAR を含むパスは展開されなくなります。それでも、ここでの用途は「symlink かどうか」の判定です。展開できないパスは「symlink ではない」側に倒れるだけで、承認前にコードが実行されるよりは安全です。そう判断して、展開できる範囲を狭める方を選びました。
毎日の動画を作るジョブで、cron の make daily と、手で打った make daily が同じ作業ディレクトリで同時に動き、互いのファイルを上書きしていました。手で試すときは cron の時刻を意識しないので、重なっても気付きにくい場面です。
make daily を flock で囲み、同時には 1 つしか動かないようにしました。
flock -n tmp/daily.lock make daily-run
-n は、ロックが取れなければ待たずにすぐ終わる指定です。
flock -n は、ロックの競合を終了コード 1 で返します。中のコマンドが失敗したときの 1 と区別できません。
flock -n lock sleep 3 & sleep 1 # 先にロックを取らせておく
flock -n lock true; echo "競合 rc=$?"
flock -n lock2 false; echo "コマンド失敗 rc=$?"
flock -n -E 75 lock true; echo "競合 (-E 75) rc=$?"
競合 rc=1
コマンド失敗 rc=1
競合 (-E 75) rc=75
-E (--conflict-exit-code) で、競合のときの終了コードを変えられます (flock(1))。make から呼ぶなら、次のように競合だけを「実行中」として扱えます。
daily:
@flock -n -E 75 tmp/daily.lock $(MAKE) daily-run || { rc=$$?; [ $$rc -eq 75 ] && echo "実行中" || exit $$rc; }
flock -n lock cmd || echo 実行中 と書くと、cmd の本当の失敗まで「実行中」として握りつぶします。
| head と pipefail は、入力がパイプの容量 (既定 64KiB) を超えた日に 141 で落ちる。grep -m で切り詰めるなら、grep が先頭のときだけ直る$(...) の中で変えた変数は戻らない。数えるならプロセス置換で回し、先にテストが落ちることを見るeval は展開の種類を選べない。~ だけが要るならパラメータ展開で置き換えるflock で防ぐ。-E で競合と失敗を分ける4 つとも、手で小さな入力を 1 回流すだけでは問題なく見えます。入力を増やす、わざと落ちるケースを足す、$(...) を混ぜた値を渡す、2 つ同時に起動する。こうして無人で起きる状況を手元で作ってから確かめるのが確実です。
パイプと subshell、展開の順序といったシェルの仕組みを体系的に押さえておくなら、bash を前提にシェルスクリプトの書き方を一通り解説している本が手元にあると便利です。
新しいシェルプログラミングの教科書 (三宅英明、電子書籍)