CI が「description がありません」で落ちた。Markdown ファイル先頭の frontmatter(YAML のメタデータ)に必須の項目があるかを確かめる検査だ。ファイルを開くと、description: はちゃんと書いてある。再実行すると緑になる。よくある flake として片付けかけた。
原因を追って直す変更を出すと、今度はその CI で、別の検査のテストが 1 回だけ落ちた。こちらは差分に新しく足された test.skip( や expect(true).toBe(true) のような「テストを弱める書き方」を拾う検査で、テスト用の差分には両方が入っている。結果には expect(true) の検出だけが残り、skip の検出だけが消えていた。同じ入力を読んで、片方だけ見落としたことになる。
2 件の原因は同じ 1 行の形だった。そして、片方は自分から騒ぎ、もう片方はテストが無ければ誰にも気付かれずに済んでいた。この記事の主題はその差だ。
2 件は同じ 1 行だった
set -o pipefail
# 検査 A: あるべき項目があるか
if ! echo "$frontmatter" | grep -qE '^description:'; then
errors+=("description がありません")
fi
# 検査 B: あってはいけない書き方が足されていないか
if echo "$added" | grep -Eq '\b(test|it|describe)\.skip\b'; then
hits+=("skip")
fi
grep -q は一致を 1 つ見つけた時点で読むのをやめて終了する。左の echo がまだ書いている途中だと、読み手のいなくなったパイプに書き込んで SIGPIPE で落ち、終了コード 141 を返す。set -o pipefail はパイプの途中のコマンドの失敗も全体の失敗にする設定なので、パイプ全体が 141 になる。if から見れば「一致しなかった」と同じだ。
一致しなければ grep は最後まで読むので、何も起きない。裏返るのは一致したときだけだ。見つけた瞬間に、見つけたという報告ごと消える。pipefail を外せば裏返らないが、pg_dump | gzip のように左の失敗を拾いたいパイプまで見逃すことになる。SIGPIPE が起きる仕組みそのものはパイプのバッファと SIGPIPE の記事で書いたので、ここでは裏返った判定がどこに出るかを追う。
裏返る向きで、見えるか見えないかが決まる
検査 A は「無ければ失敗」の向きだ。裏返ると、ある項目を無いと言って CI を赤くする。迷惑だが、赤いので人が見る。
検査 B は「あれば警告」の向きだ。裏返ると、skip が足された差分を何も無かったことにして、CI は緑のまま進む。この検査は差分の危険度を判定して、レビューをどこまで厳しくするかを決める材料だった。skip を見落とせば、テストを無効にした変更が、何もしていない変更と同じ軽さで扱われる。気付けたのは、テストが「この差分なら skip が拾われるはずだ」と書いてあったからにすぎない。
| 検査の向き | 書き方の例 | 裏返ると | CI での見え方 |
|---|---|---|---|
| 無ければ失敗(必須の項目・設定キー) | if ! echo "$x" | grep -q | ある物を無いと言う | 赤くなる。再実行で直る flake に見える |
| あれば警告(skip・秘密情報・危険な関数の検出) | if echo "$x" | grep -q | ある物を見逃す | 緑のまま。テストが無ければ誰も気付かない |
騒ぐほうは迷惑なだけで、黙るほうが有害だ。しかも flake として表に出てくるのは騒ぐほうだけなので、flake を直して回るだけでは、静かなほうはいつまでも残る。実際、2 件を直したあとで同じ形を探すと、ほかに約 40 箇所見つかった。どれも今回の CI では表に出ていなかった。落ちていないのは、正しいからとは限らない。
どのくらいの頻度で裏返るか
一致する行を先頭に置き、後ろを埋めて入力の大きさを変えながら、同じパイプを大きさごとに何千回も流した。
Linux でも Mac でも、結果は同じ形になった。小さい入力ではたまに裏返り、64KB を超えると大半が、128KB を超えると毎回裏返る。冒頭の 1 件目も Linux の CI で、検査に渡したファイルは 10KB に届かなかった。
64KB はパイプのバッファの大きさだ。これを超えると echo は書き込みを待たされ、grep が一致を見つけて帰ったあとに残りを書くことが起きやすくなる。Linux では、64KB を少し超えたくらいでは 1〜2 割が通ってしまう。それより小さくても、grep が帰った時点で echo の書き込みがまだ残っていれば裏返る。こちらはタイミング次第で、同じ大きさの入力でも試すたびに回数がばらつき、数十回から数千回に 1 回だった。
だから小さい入力では「再実行で直る」に見える。一方で、128KB を十分に超える入力なら毎回起きるので、テストを書くにはむしろ都合がいい。次の節の再現テストで 200KB を使うのはそのためだ。
直し方と、毎回落ちる再現テスト
直し方は、パイプを here-string(<<<)に替えることだ。シェルが中身を全部用意してから grep に渡すので、途中で切られる書き手がいない。
if ! grep -qE '^description:' <<<"$frontmatter"; then
errors+=("description がありません")
fi
if grep -Eq '\b(test|it|describe)\.skip\b' <<<"$added"; then
hits+=("skip")
fi
再現テストは、一致する行を先頭に置き、その後ろを 200KB ほど埋めた入力で書く。小さい入力では緑のまま通ってしまい、直したことを証明できない。読者の手元でも次の 4 行で確かめられる。
set -o pipefail
x="description: ok"$'\n'"$(printf '%0200000d' 0)"
echo "$x" | grep -q '^description:'; echo "pipe: $?" # pipe: 141
grep -q '^description:' <<<"$x"; echo "here-string: $?" # here-string: 0
大きい入力のテストを作ると、もう 1 つ別の壁に当たる。Linux は 1 つの引数に 128KB までしか渡せないので、200KB の文字列を jq --arg のように引数で外部コマンドに渡すと、Argument list too long でコマンド自体が起動しない。Mac ではこの上限に当たらず通るので、手元で緑、CI で赤になる。大きい入力は引数ではなく標準入力で渡す。
# 引数で渡す(Linux で 128KB を超えると起動できない)
jq -n --arg c "$content" '{content: $c}'
# 標準入力で渡す
printf '%s' "$content" | jq -Rs '{content: .}'
残りを洗うときは「あれば警告」から
1 件見つけたら、同じ形を全部洗う。grep -q のほかに、grep -m1 や head も一致や必要な行数で早く読むのをやめるので同じ形になる。
grep -rnE '\|[[:space:]]*(grep([[:space:]]+-[^|]*)?[[:space:]]+(-[[:alpha:]]*[qm]|--quiet|--silent|--max-count)|head)' --include='*.sh' .
出てきた行は、前の表の向きで仕分ける。if echo ... | grep -q; then 警告 の形、つまり見つけたら何かを止めたり記録したりする行から直す。こちらは裏返っても誰も教えてくれない。「無ければ失敗」の向きは、裏返れば CI が赤くして教えてくれるので後でいい。
直したら、その行ごとに上の再現テストを 1 本足しておく。修正前のスクリプトでは赤、修正後は緑になることを確かめれば、誰かが短く書けるパイプに戻したときにも止まる。
まとめ
set -o pipefailの下のecho "$x" | grep -qは、一致したときにだけ「一致しない」に裏返ることがある。入力が大きければ毎回、小さくてもたまに- 裏返りが CI で見えるのは「無ければ失敗」の検査だけ。「あれば警告」の検査は緑のまま見逃すので、洗うときはそちらから直す
- 再現テストは、一致する行を先頭に置いて 200KB ほど埋めた入力で書く。その入力は引数ではなく標準入力で渡す
CI がたまに赤くなる検査は、少なくとも自分の不調を申告している。困るのは、同じ病気で一度も赤くならないほうだ。



