CIの「Bats Tests」が、issue本文が長い回にだけ、しかも毎回ではなく稀に落ちる。エラーの中身はJSON出力のパース失敗で、同じテストをもう一度流すとあっさり通る。ローカルで同じ入力を使って再現させようとしても、なぜか再現しない。
これは、GitHub issueを解析して実装計画に使う自社の開発支援スクリプトで実際に起きた話です。原因を突き止めると、地味だけど強烈な罠でした。Linuxのパイプには容量の上限があり、読み手が早めに読み切って抜けると、書き手はSIGPIPEで問答無用で殺される、というやつです。
この記事で学べること
- なぜ「小さい入力ではずっと動いていたコード」が、ある日突然壊れるのか
- Linuxのパイプがなぜ
headのような早期終了リーダーと相性最悪なのか - 壊れた再現条件(入力サイズのしきい値)をテストにどう固定化するか
- パイプの代わりにbashのhere-stringを使うと何が変わるのか
前提条件
- bash(
<<<here-string構文を使うため) - Linux環境でのパイプ動作の基礎知識があるとより理解しやすい
何が起きていたか — 「小さい入力なら動く」という罠
問題のスクリプトは、GitHub issueの本文を受け取り、受入条件(AC)の抽出や本文プレビューの生成、comprehensive深度での影響ファイル抽出を行います。issue本文は数百文字のこともあれば、詳細な仕様や長いログ貼り付けで数万文字に膨らむこともあります。
実装は素直に「変数をパイプでつないでいく」スタイルでした。本文プレビューを作る処理はこう書かれていました(修正後の実際のコード)。
# NOTE: uses here-strings (not pipes) for the same SIGPIPE-safety reason as
# breaking_keyword_scan above — a large $1 fed through a pipe into a
# downstream head -N that early-exits can SIGPIPE-kill the upstream writer.
extract_ac() {
grep -E '^\s*-\s*\[[ x]\]|^[0-9]+\.\s' <<<"$1" | head -20 | json_array
}
これは修正後の姿です。直す前はextract_ac・extract_requirements・本文プレビュー生成・comprehensive深度の影響ファイル抽出が揃っていました。「変数をechoやprintfでパイプに流し込みgrepやheadで受ける」実装でした。実際のコミットメッセージにはこう書かれています。
breaking_keyword_scan のみ here-string 化されていたが、 同じ大容量 body を処理する extract_ac / extract_requirements / body_preview / comprehensive depth の affected_files・components 抽出は echo|grep や printf|head のパイプ実装のままだった。
このスクリプトを使ったテストは、小さめのissue本文に対しては何度実行しても安定してパスする状態でした。CIで時々落ちるのは、たまたまissue本文が長い回だけ、それも毎回ではありません。原因を知らない状態でこの現象に出会うと、正直かなり厄介です。
なぜパイプは「早く抜けるリーダー」と相性が悪いのか
ここでLinuxのパイプの仕組みを思い出す必要があります。パイプは無限のバッファではなく、Linuxのデフォルトのパイプ容量は64KBです。書き手(echoやprintf)が64KB分書き込んでもまだ読み手が受け取らない場合、書き手はブロックされて待たされます。
一方でhead -c 500やhead -Nは、指定した分だけ読んだらさっさとプロセスを終了します。読み終わったらパイプの読み込み端(read end)を開けたままにしておく義理はありません。ここが今回の急所です。
読み手が終了してパイプの読み込み端が閉じられた状態で書き手側がまだ書き込もうとすると、カーネルはその書き込みプロセスにSIGPIPEシグナルを送ります。デフォルトのSIGPIPEハンドラはプロセスを即座に終了させるので、echoやprintfはエラーメッセージも残さず静かに死にます。パイプでつないだ後続のコマンド(jq -Rs .によるJSON文字列化)には中途半端な出力しか渡らず、JSON全体の構文が壊れます。
ここで効いてくるのが入力サイズです。issue本文が数百文字程度なら、echo "$BODY"が書き込む量はパイプ容量の64KBに届かず、書き込みはブロックすら発生しません。つまり小さい入力ではそもそもこの競合状態が起こる条件が成立しないわけです。issue本文が育って64KBの壁を超えて初めて、書き込みがブロックされたままheadに先に抜けられる余地が生まれ、SIGPIPEが飛びます。「小さい入力ではずっと動いていた」のは実装が正しかったからではなく、壊れる条件にたまたま到達していなかっただけでした。
要は、読み手だけ先にゴールテープを切って、書き手が宙に浮いたまま取り残される話です。再現の仕組みを図にするとこうなります。
最小の再現コードで確認する
言葉で説明するより手元で試した方が納得できます。パイプ版と修正後のhere-string版を並べます。
# パイプ版: 大きい入力で壊れうる
big_body=$(head -c 70000 </dev/zero | tr '\0' 'a')
echo "$big_body" | head -c 500 > /tmp/preview.txt
echo "exit status of echo (via PIPESTATUS): ${PIPESTATUS[0]}"
bashのPIPESTATUSでパイプの左側(echo)の終了ステータスを見ると、SIGPIPE起因の異常終了(141 = 128+SIGPIPE)が観測できます。パイプの右側のhead自体は正常終了して0を返すため注意が必要です。pipefailでも右端だけ見て安心する書き方だと見逃します。
修正後はこうです。
# here-string版: 早期終了するheadでもSIGPIPEが起きない
big_body=$(head -c 70000 </dev/zero | tr '\0' 'a')
head -c 500 <<<"$big_body" > /tmp/preview.txt
here-string(<<<)は、シェルが文字列全体を一時ファイルにまず書き出してから、それを読み取り専用のfdとして渡す仕組みです。同時に動く2つのプロセスの間をライブなパイプでつなぐわけではないので、「読み手が先に抜けて書き手が宙に浮く」競合状態自体が発生しません。読み手がどれだけ早く抜けても、書き手は最初から「もう書き終わっている」状態なのでSIGPIPEの飛びようがないわけです。
直し方: 全部のパイプをhere-stringに統一する
今回の修正はecho|grepやprintf|headのパイプ実装を洗いざらいhere-stringに置き換えるものでした。実装コメントにもその意図がはっきり書かれています。
uses a here-string (not a pipe) so grep -q's early-exit on match cannot cause an upstream SIGPIPE / silent false negative on large bodies
本文プレビュー生成も同様にhere-string化されました。
"body_preview": $(head -c 500 <<<"$BODY" | jq -Rs .)
面白いのは、breaking_keyword_scanだけはすでにhere-string化されていたことです(breakingキーワードを見る決定論スキャン)。同じ関数群でなぜ足並みが揃っていなかったのか。後から追加されたextract_acのようなロジックが、既存のパイプ実装のスタイルをそのままコピーして増えていったからだと考えられます。「1箇所直したから他も安全」という思い込みが一番危ないという教訓でもあります。
再現条件をテストに固定化する
原因が分かって直しただけでは、いずれ誰かが「パイプの方が短く書ける」と書き戻すかもしれません。それを防ぐため、テストコードには70KB程度に膨らませたissue本文を使う専用のテストケースが追加されています。テストファイルの冒頭コメントには、テストスイートが何をカバーしているかがまとめられています。
Covers: breaking_keyword_scan determinism across all depths (minimal / standard / comprehensive), full-body scan beyond the 500-char body_preview boundary, Japanese keyword detection, and a >64KB body regression to pin the here-string (non-pipe) SIGPIPE-safe implementation.
実際のテストケースはこうです。
@test "standard depth: ~70KB body with leading keyword -> true (SIGPIPE regression)" {
PAD="$(printf '%*s' 70000 '')"
PAD="${PAD// /a}"
BODY="${AC_STUB}breaking change needed"$'\n'"${PAD}"
FIXTURE="$FIXTURE_DIR/large.json"
make_fixture "$FIXTURE" "Large body issue" "$BODY"
export GH_FIXTURE="$FIXTURE"
run "$SCRIPT" 5 --depth standard
[ "$status" -eq 0 ]
echo "$output" | jq -e '.breaking_keyword_scan == true'
}
70000個の空白を生成してからaで埋める地味な儀式です。64KBのパイプ容量よりわずかに余裕を持たせた70KBという本文サイズを、意図的に組み立てているのがポイントです。小さい本文だけをテストしていた頃は、このバグは構造的にテストで検出不可能でした。パイプ容量の壁を明示的に超える入力サイズを用意して初めて、この種の回帰をテストで捕まえられます。
動作確認
修正効果は次で確認できます。
- 63〜70KB程度のダミー本文でスクリプトを実行し、出力が正しいJSONとしてパースできるか
bashのPIPESTATUSや終了コードを見て、途中のコマンドがSIGPIPE相当(141)で落ちていないか- 修正対象の関数を全部grepし、
| headや| grepのパターンが残っていないか
注意点・Tips
- パイプの右側だけ見て安心しない —
cmd1 | cmd2の終了コードは、シェルではデフォルトで右側(cmd2)のものです。左側のcmd1がSIGPIPEで死んでいても、$?や単純なifチェックだけでは気づけません。パイプの各コマンドの終了コードを見たい場合はPIPESTATUS(bash)を使ってください head・grep -qのように「必要な分だけ読んで即終了する」コマンドをパイプの右側に置くときは要注意。左側の書き込みが大きくなりうる場合、パイプ容量を超えた瞬間にこの罠が牙を剥きます- 小さい入力でテストが通っていたことを「動作確認済み」の証拠にしない。境界値(今回なら64KB前後)を超える入力で意図的にテストしないと、バグは潜在化したまま本番でだけ発火します
- here-stringは万能ではない。シェルが文字列全体をバッファに書き出してから渡すため、巨大すぎる入力(数百MB級)ではオーバーヘッドが無視できません。数十KB〜数百KB程度の入力向けの対策と捉えてください
なお、シェルスクリプトのテストにまつわる別の罠もあります。set -euo pipefailが失敗ケースのテストを無力化する問題です。合成テストが全部緑でも実データでは漏れているといった話は、テストは全部緑なのに現実では漏れていた話にまとめています。あちらはテストスイート側の盲点、こちらはシェルの低レベルな挙動に起因するバグという、原因のレイヤーが異なる話です。
こうした「本番データでだけ稀に再現しない」類の足場のバグは、AIエージェント向けの開発パイプラインを作り込むと思った以上によく顔を出します。このあたりの足場づくりも含めてAI開発の支援でよく触れます。
まとめ
CIでだけ、たまにしか再現しないシェルスクリプトの不具合は、疑うべき候補として「パイプ容量とシグナル配送」を持っておくと当たりを付けやすくなります。
- Linuxのパイプ容量は64KBが既定で、これを超える書き込みは読み手が受け取るまでブロックされる
headやgrep -qのように必要な分だけ読んで即終了するコマンドがパイプの右側にいると、左側の書き手が取り残され、パイプが閉じた瞬間にSIGPIPEで即死する- 小さい入力ではこの競合状態自体が発生しないため、「テストはずっと緑」という状態と「バグが存在しない」はイコールではない
- 対策は、変数を経由する処理を可能な限りbashのhere-string(
<<<)に統一すること。同時実行の2プロセス間のライブなパイプではなくなるため、早期終了リーダーによるSIGPIPEの余地がなくなる - 再発防止には、パイプ容量のしきい値を意図的に超える入力サイズでの回帰テストを固定化しておく
シェルスクリプトは「動いているからヨシ」で放置されがちですが、動いて見える範囲が罠の外側だっただけ、ということは普通にあります。



