結論

  • パイプの終了コードは、既定では末尾のコマンドのもの。set -o pipefail を立てると、最後に非 0 で終わったコマンドのものになる
  • || と if はパイプ全体の終了コードを見る。cmd | grep -v X || true は cmd の失敗も 0 にし、if cmd | grep -q X は取得の失敗を「一致なし」と同じ偽にする
  • 読み手が終わった後に書き手が書くと、書き手は SIGPIPE で 141 になり、pipefail がそれを全体の終了コードにする。| head、| grep -q、stdin を読まない読み手で起き、出力が小さくても書く順番しだいで起きる
  • mapfile < <(cmd) はパイプではないので、cmd の失敗はどこにも残らない
  • 判定に使う出力は先に変数へ取り、here-string で渡す。fallback 付きのコマンドは、成功したときだけ stdout に出す

以下は GNU bash 5.3.15 / GNU coreutils 9.11 / GNU grep 3.12 / Linux x86_64 で実測した。回数を書いたものはタイミングで決まるので、環境によって数字が変わる。

前提: パイプの終了コードはどのコマンドのものか

既定ではパイプの終了コードは右端のコマンドのもので、pipefail を立てると最後に非 0 で終わったコマンドのものになる

a | b の $? は b の終了コードで、a がどう終わったかは入らない。 pipefail を立てると、最後 (右端寄り) に非 0 で終わったコマンドの終了コードになる。各コマンドの終了コードは PIPESTATUS に残る。

false | true; echo "既定:     rc=$?"
(exit 2) | (exit 3) | true; echo "既定:     rc=$? PIPESTATUS=${PIPESTATUS[*]}"

set -o pipefail
false | true; echo "pipefail: rc=$?"
(exit 2) | (exit 3) | true; echo "pipefail: rc=$? PIPESTATUS=${PIPESTATUS[*]}"
既定:     rc=0
既定:     rc=0 PIPESTATUS=2 3 0
pipefail: rc=1
pipefail: rc=3 PIPESTATUS=2 3 0

pipefail が返すのは 1 つだけで、(exit 2) も失敗したことは PIPESTATUS を見ないと分からない。

検査のパイプは、末尾のコマンドの終了コードが判定になるように並べる。 PNG の先頭 8 バイトの判定は cmp の結果だけで決まり、ファイルが無いときも「PNG でない」と同じ 1 になる。

printf '\x89PNG\r\n\x1a\n' > sig.bin
head -c 8 missing.png | cmp -s - sig.bin; echo "missing.png: rc=$? PIPESTATUS=${PIPESTATUS[*]}"
head: cannot open 'missing.png' for reading: No such file or directory
missing.png: rc=1 PIPESTATUS=1 1
  • PITFALL: head -c 8 は 8 バイトを 1 回の write で書き終えるので、後述の SIGPIPE は起きない。pipefail の下で 200 回回して、非 0 は 0 回だった

|| と if はパイプ全体の終了コードを見る

cmd | grep -v X || true の || true は cmd の失敗も消す

a | b || c は (a | b) || c と読まれるので、|| true は cmd の失敗も 0 にする。grep だけを { } で括ると、cmd の失敗は pipefail で伝わる

a | b || c は (a | b) || c と読まれる。grep の「一致 0 件 = 1」だけを許すつもりの || true が、前段の失敗も 0 にする。 make の .SHELLFLAGS := -eu -o pipefail -c も、この 1 行では効かない。

成否に関わらず Created version を stdout に出し、失敗時は stderr にエラーを出して 1 で終わる CLI の代役で試す。

cli() {
  echo "Created version [3]"
  [[ $1 == ok ]] && return 0
  echo "ERROR: permission denied" >&2
  return 1
}
export -f cli
# make の .SHELLFLAGS := -eu -o pipefail -c と同じ条件で 1 行ずつ流す
run() { echo "--- $1"; bash -eu -o pipefail -c "$1"; echo "rc=$?"; }

run 'cli ok 2>&1 | grep -v "Created version"'
run 'cli ng 2>&1 | grep -v "Created version" || true'
run 'cli ok 2>&1 | { grep -v "Created version" || true; } >&2'
run 'cli ng 2>&1 | { grep -v "Created version" || true; } >&2'
--- cli ok 2>&1 | grep -v "Created version"
rc=1
--- cli ng 2>&1 | grep -v "Created version" || true
ERROR: permission denied
rc=0
--- cli ok 2>&1 | { grep -v "Created version" || true; } >&2
rc=0
--- cli ng 2>&1 | { grep -v "Created version" || true; } >&2
ERROR: permission denied
rc=1

1 つ目は成功しているのに、全行を消した grep が 1 を返す。これを避けるために || true を足すと、2 つ目のように失敗も 0 になる。 || true を grep だけに掛けるには { } で括る。cli の失敗は pipefail で伝わる (4 つ目)。

  • PITFALL: 次の行に無条件の成功メッセージがあると、失敗が「成功しました」と表示される。間引く出力が stderr 由来なら >&2 で戻さないと、エラーが stdout に混ざる

if cmd | grep -q X は取得の失敗を「一致なし」と同じ偽にする

cmd が落ちても grep は一致なしの 1 を返すので、if は無いので作るの側へ進む。取得を先に済ませると失敗をそこで止められる

if の条件では set -e が効かず、パイプの終了コードは grep のものになる。 cmd が認証切れやネットワーク断で落ちても、grep の「一致なし」と同じ 1 になり、「無いので作る」の側へ進む。

lookup() { echo "error: token expired" >&2; return 1; }   # 認証切れで落ちる CLI の代役

if lookup | grep -q '^my-item$'; then echo "exists"; else echo "既定:     not exists (rc=$?)"; fi
set -o pipefail
if lookup | grep -q '^my-item$'; then echo "exists"; else echo "pipefail: not exists (rc=$?)"; fi
error: token expired
既定:     not exists (rc=1)
error: token expired
pipefail: not exists (rc=1)

pipefail を立てても、lookup の 1 と grep の 1 は区別できない。取得を先に済ませ、失敗はそこで止める。

if out=$(lookup); then
  if grep -q '^my-item$' <<<"$out"; then echo "exists"; else echo "not exists"; fi
else
  echo "分けた形: lookup failed (rc=$?)"
fi
error: token expired
分けた形: lookup failed (rc=1)
  • PITFALL: pipefail の下では、一致しても偽になることがある。grep -q は最初の一致で終わるので、まだ書いている上流が SIGPIPE で 141 になる (次の節)
set -o pipefail
list() { echo my-item; seq 200000; }
if list | grep -q '^my-item$'; then echo "exists"; else echo "pipefail: NOT-EXISTS (rc=$?) PIPESTATUS=${PIPESTATUS[*]}"; fi
pipefail: NOT-EXISTS (rc=141) PIPESTATUS=141 0

読み手が終わった後に書くと、書き手は 141 で終わる

読み手が先に終わってパイプの読み口が閉じた後に書き手が write すると、書き手は SIGPIPE で 141 になり、pipefail で全体も 141 になる

読み手が終わってパイプの読み口が閉じた後に書き手が write すると、書き手は SIGPIPE を受け、終了コード 141 (128 + 13) で終わる。メッセージは出ない。 読み手が 0 で終わっても、pipefail は書き手の 141 を採る。

起きるかどうかは、読み手が終わった後に書き手がまだ write するかで決まる。

  • 読み手が終わる前に書き終えていれば起きない
  • 出力がパイプのバッファ (Linux 既定 64KiB) を超えると、書き手は読み手が読むのを待つ。その間に読み手が終われば起きるので、ほとんど毎回起きる
  • バッファに収まる量でも、何回かに分けて write する書き手では、どちらが先かで起きたり起きなかったりする

fzf で選んだ後に producer が 141 になる件は、 スクリプトから呼ぶ fzf が返す値と終了コード に書いた。

stdin を読まない読み手は、小さな出力でもまれに 141 にする

読み手が stdin を読まずに終わると、出力が大きければ毎回、小さくてもまれに書き手が 141 になる。

set -o pipefail
count() {
  local n=0 i
  for i in $(seq "$2"); do eval "$1" || n=$((n + 1)); done
  echo "$1: $2 回中 $n 回 非 0"
}

head -c 1000000 /dev/zero | tr '\0' x | true; echo "1MB | true: rc=$? PIPESTATUS=${PIPESTATUS[*]}"
count "printf 'a\nb\n' | true" 2000
count "printf 'a\nb\n' | cat >/dev/null" 2000
1MB | true: rc=141 PIPESTATUS=141 141 0
printf 'a\nb\n' | true: 2000 回中 7 回 非 0
printf 'a\nb\n' | cat >/dev/null: 2000 回中 0 回 非 0

テストで外部コマンドを関数に差し替えるときに踏む。 秘密を argv に載せないため printf ... | curl --config - で stdin から渡す関数を、stdin を読まない偽の curl で試すと、まれに 141 で落ちる。

send() { printf 'header = "Authorization: Bearer %s"\n' "$TOKEN" | curl --config - https://api.example.invalid/; }

# NG: stdin を読まない
curl() { echo '{"success":true}'; }
# OK: 実物と同じく stdin を読み切る
curl() { cat >/dev/null; echo '{"success":true}'; }

それぞれを count 'send >/dev/null' 2000 と、CPU 1 個に固定した count 'taskset -c 0 bash -o pipefail -c "send >/dev/null"' 200 で回した。

# NG の偽物
send >/dev/null: 2000 回中 0 回 非 0
taskset -c 0 bash -o pipefail -c "send >/dev/null": 200 回中 2 回 非 0
# OK の偽物
send >/dev/null: 2000 回中 0 回 非 0
taskset -c 0 bash -o pipefail -c "send >/dev/null": 200 回中 0 回 非 0

CPU を固定しないとほとんど落ちず、固定すると落ちる。偽物は実物と同じく stdin を読み切る。

  • PITFALL: 出力を out=$( (fn) 2>&1 ) で受けていると、エラー文言も残らずに set -e で止まる。「CI でたまに落ちて、手元では再現しない」形になる

| head で切ると、head が終わった後に上流が書けば 141

head が N 行で終わった後に上流が書くと 141。バッファを超える量ならほぼ毎回、超えなくても上流の書き方しだいで起き、最後まで読む awk なら 0 回

head は N 行を読んだら終わる。上流がその後に write すると 141 になる。

set -o pipefail
line=$(printf 'a%.0s' $(seq 2000))   # 2000 文字の行

for i in $(seq 44); do echo "$line"; done | grep a | head -n 15 >/dev/null
echo "44 行 (約 88KB): rc=$? PIPESTATUS=${PIPESTATUS[*]}"
n=0
for r in $(seq 50); do
  for i in $(seq 44); do echo "$line"; done | grep a | head -n 15 >/dev/null || n=$((n + 1))
done
echo "44 行 (約 88KB): 50 回中 $n 回 非 0"

# バッファ未満でも、head が終わった後に grep が書けば 141 になる
n=0
for r in $(seq 50); do
  for i in $(seq 20); do echo "$line"; done | grep a | head -n 15 >/dev/null || n=$((n + 1))
done
echo "20 行 (約 40KB): 50 回中 $n 回 非 0"
44 行 (約 88KB): rc=141 PIPESTATUS=141 141 0
44 行 (約 88KB): 50 回中 49 回 非 0
20 行 (約 40KB): 50 回中 38 回 非 0

バッファを超える 88KB ではほぼ毎回 141 になり、grep が落ちた後に書こうとしたループも 141 になる。 バッファ未満の 40KB でも、head の後に grep が残りを書くと 141 になる。

量でなく、上流の書き方で決まる。シェルのループで 1 行ずつ echo する上流は 292 バイトしか出さなくても大半が 141 になり、1 回の write で書き終える seq は 0 になる。

set -o pipefail
echo "seq 100 の出力: $(seq 100 | wc -c) バイト"

printf '行ごとに echo する上流: '
for r in $(seq 20); do
  for i in $(seq 100); do echo "$i"; done | head -n 1 >/dev/null; printf '%s ' $?
done
echo

printf 'seq (1 回の write):    '
for r in $(seq 20); do
  seq 100 | head -n 1 >/dev/null; printf '%s ' $?
done
echo
seq 100 の出力: 292 バイト
行ごとに echo する上流: 141 141 141 141 0 141 0 141 141 141 141 141 141 141 141 0 141 141 141 141 
seq (1 回の write):    0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 

最後まで読む awk で切れば、上流は書き終えられる。grep がファイルを直接読むなら、grep -m で grep 自身が止まるので 141 にならない。 ただし grep の前にさらに上流があると、その上流が SIGPIPE を受ける。

n=0
for r in $(seq 50); do
  for i in $(seq 44); do echo "$line"; done | grep a | awk 'NR <= 15' >/dev/null || n=$((n + 1))
done
echo "44 行 | grep | awk 'NR <= 15': 50 回中 $n 回 非 0"

grep -m 15 a in.txt >/dev/null; echo "grep -m 15 a in.txt: rc=$?"
seq 200000 | grep -m 15 . >/dev/null; echo "seq 200000 | grep -m 15: rc=$? PIPESTATUS=${PIPESTATUS[*]}"
44 行 | grep | awk 'NR <= 15': 50 回中 0 回 非 0
grep -m 15 a in.txt: rc=0
seq 200000 | grep -m 15: rc=141 PIPESTATUS=141 0
  • PITFALL: 入力が小さいうちや、1 回試して 0 が出たときは安全に見える。件数が日々増える入力では、ある日から落ち始める。数十回回すか、上流の書き方 (1 行ずつか、まとめてか) を見て判断する

終わらない入力を head で切ると、毎回 141

yes や /dev/urandom は終わらないので、head が終わった後も必ず書く。

set -o pipefail
yes | head -n 1 >/dev/null; echo "yes | head -n 1: rc=$? PIPESTATUS=${PIPESTATUS[*]}"
LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16 >/dev/null; echo "tr < /dev/urandom | head -c 16: rc=$? PIPESTATUS=${PIPESTATUS[*]}"

# set -e の下ではメッセージ無しで止まる
bash -euo pipefail -c 'p=$(LC_ALL=C tr -dc "A-Za-z0-9" </dev/urandom | head -c 16); echo "p=$p"'
echo "set -euo pipefail: rc=$?"
yes | head -n 1: rc=141 PIPESTATUS=141 0
tr < /dev/urandom | head -c 16: rc=141 PIPESTATUS=141 0
set -euo pipefail: rc=141

set -e の下では p= の行で止まり、echo まで届かない。

入力を先に有限にすると 141 にならない。

for i in $(seq 5); do
  p=$(head -c 256 /dev/urandom | LC_ALL=C tr -dc 'A-Za-z0-9'); echo "rc=$? 長さ=${#p}"
done
rc=0 長さ=57
rc=0 長さ=62
rc=0 長さ=50
rc=0 長さ=68
rc=0 長さ=59
  • PITFALL: 有限の入力では、残る文字数が毎回変わる。長さと文字種を検査し、足りなければ引き直す

echo "$big" | grep -q PAT は一致しても 141 になりうる

grep -q は最初の一致で終わるので、一致行の後ろが大きいほど echo が 141 になる。here-string は書き手のプロセスを作らないので 0 回

grep -q は最初の一致で終わる。大きな文字列を echo で流すと、一致した後も echo が書いていて 141 になる。一致行の後ろの量で頻度が変わる。

set -euo pipefail
for size in 4096 16384 65536 131072; do
  big="match
$(head -c "$size" /dev/zero | tr '\0' x)"
  n=0 m=0
  for i in $(seq 30); do
    echo "$big" | grep -q match || n=$((n + 1))
    grep -q match <<<"$big" || m=$((m + 1))
  done
  echo "一致行の後ろ ${size} バイト: echo | grep -q は 30 回中 $n 回 非 0 / <<< は 30 回中 $m 回 非 0"
done
一致行の後ろ 4096 バイト: echo | grep -q は 30 回中 0 回 非 0 / <<< は 30 回中 0 回 非 0
一致行の後ろ 16384 バイト: echo | grep -q は 30 回中 0 回 非 0 / <<< は 30 回中 0 回 非 0
一致行の後ろ 65536 バイト: echo | grep -q は 30 回中 3 回 非 0 / <<< は 30 回中 0 回 非 0
一致行の後ろ 131072 バイト: echo | grep -q は 30 回中 30 回 非 0 / <<< は 30 回中 0 回 非 0

here-string は書き手のプロセスを作らないので、SIGPIPE が起きない。 HTML やページの snapshot、API の応答を変数に持って if echo ... | grep -q で分岐するところは、grep -q PAT <<<"$big" にする。

  • PITFALL: 「続きがあるか」の判定に使うと、エラーにならずに処理を打ち切る。「さらに表示」の有無を誤って判定し、古い分を数えずに終わった例がある。| head -1 も同じ形で書き手を 141 にするので、最後まで読む awk などに置き換える

パイプでない経路

mapfile < <(cmd) は cmd の失敗を捨てる

パイプの終了コードは PIPESTATUS と pipefail に届くが、プロセス置換の終了コードはどこにも届かず、mapfile の 0 だけが残る

プロセス置換 <(cmd) はパイプではないので、pipefail も PIPESTATUS も cmd を見ない。 終了コードは mapfile のもので、cmd が途中で落ちても短い配列が返るだけになる。

$ bash -c 'set -euo pipefail
mapfile -t arr < <(printf "a\nb\n"; exit 3)
echo "rc=$? count=${#arr[@]} PIPESTATUS=${PIPESTATUS[*]}"'
rc=0 count=2 PIPESTATUS=0

塞ぎ方は 2 つ。末尾に番兵を流して受けた側で有無を見るか、一時ファイルを経由して生成器の終了コードを直接見る。

# 生成器の代役。FAIL を付けると途中で落ちる
gen() { printf 'a\nb\n'; [[ -n ${FAIL:-} ]] && return 3; printf 'c\n'; }

# 1) 末尾に番兵を流し、受けた側で有無を見る
gen_with_end() { gen && printf '__END__\n'; }
mapfile -t rec < <(FAIL=1 gen_with_end)
[[ ${rec[-1]:-} == __END__ ]] && echo "番兵あり: 全部届いた (番兵を含め ${#rec[@]} 行)" || echo "番兵あり: generator died (${#rec[@]} 行)"

# 2) 一時ファイルを経由し、生成器の終了コードを直接見る
tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT
if FAIL=1 gen >"$tmp"; then mapfile -t rec <"$tmp"; echo "一時ファイル: ${#rec[@]} 行"; else echo "一時ファイル: generator died (rc=$?)"; fi
番兵あり: generator died (2 行)
一時ファイル: generator died (rc=3)
  • PITFALL: 件数の検査だけでは足りない。1 レコードが複数フィールドの形式では、フィールド数の倍数で切れると件数の検査を通る。番兵で「全部届いた」を、件数で「区切りがずれていない」を確かめる

heredoc と here-string は、大きさで pipe か一時ファイルかが変わる

bash 5.1 以降の heredoc と here-string は、65536 バイトまでは pipe、それを超えると一時ファイルで渡る

bash 5.1 からは、展開した中身がパイプのバッファより小さければ pipe で、超えると一時ファイルで渡す (bash の NEWS の 5.1 の項)。5.0 までは常に一時ファイルだった。

probe() { [[ -p /dev/stdin ]] && echo pipe; [[ -f /dev/stdin ]] && echo file; }

printf 'heredoc 小: '; probe <<EOF
small
EOF

big=$(head -c 200000 /dev/zero | tr '\0' a)
printf 'heredoc 大: '; probe <<EOF
$big
EOF

printf 'here-string 小: '; probe <<<"small"
printf 'here-string 大: '; probe <<<"$big"

# 切り替わる大きさ (改行 1 バイトを含む)
for n in 65534 65535 65536; do
  s=$(head -c "$n" /dev/zero | tr '\0' a)
  printf 'here-string %s+1 バイト: ' "$n"; probe <<<"$s"
done
heredoc 小: pipe
heredoc 大: file
here-string 小: pipe
here-string 大: file
here-string 65534+1 バイト: pipe
here-string 65535+1 バイト: pipe
here-string 65536+1 バイト: file

この環境では、改行を含めて 65536 バイトまでが pipe、65537 バイトからが一時ファイルだった。

  • PITFALL: 「heredoc は一時ファイルなので -p が偽」という説明は 5.0 までの話。小さな入力で試すと -p だけで足りるように見え、大きな heredoc を渡した日に読まれなくなる。両方を拾うには [[ -p /dev/stdin || -f /dev/stdin ]]

出力の行き先

echo x 1>&2 | tee f は f に何も書かない

シェルはパイプを繋いだ後に 1>&2 を適用するので、echo の stdout はパイプから外れて tee には何も届かない。tee の側で 1>&2 すると両方に出る

シェルはパイプを繋いでから、各コマンドのリダイレクトを適用する。 左辺の 1>&2 は stdout をパイプから stderr へ付け替えるので、tee の stdin には何も来ない。

log=$(mktemp); trap 'rm -f "$log"' EXIT

echo hello 1>&2 | tee -a "$log"
echo "echo hello 1>&2 | tee -a log: log は $(wc -c <"$log") バイト"

: >"$log"
echo hello | tee -a "$log" 1>&2
echo "echo hello | tee -a log 1>&2: log は $(wc -c <"$log") バイト"
hello
echo hello 1>&2 | tee -a log: log は 0 バイト
hello
echo hello | tee -a log 1>&2: log は 6 バイト

画面にはどちらも hello が出る。「画面には stderr で出しつつファイルにも残す」ログ関数は、tee の側で 1>&2 する。

  • PITFALL: 画面の表示は正しいので、ファイルが空なことは、後で調べるためにファイルを開くまで分からない

primary || fallback は、失敗した primary が書いた分を残す

|| は primary の終了を待ってから fallback を始めるだけなので、primary がそれまでに stdout へ書いたエラー本文が fallback の出力の前に残る

|| は primary が終わってから fallback を始めるだけで、それまでに stdout へ書いた分は取り消せない。 パイプやコマンド置換は stdout を流れてきた順に受け取り、終了コードは最後に分かる。

primary() { echo '{"error":"rate limited"}'; return 1; }   # エラー本文を stdout に出して非 0 で終わる CLI の代役
fallback() { echo "answer from fallback"; }

out=$(primary || fallback)
printf '%s\n' "--- primary || fallback" "$out"

run_with_fallback() {
  local out
  if out=$(primary "$@"); then
    printf '%s\n' "$out"
    return 0
  fi
  printf '%s\n' "$out" >&2   # 失敗した分は stderr へ
  fallback "$@"
}
out=$(run_with_fallback 2>/dev/null)
printf '%s\n' "--- run_with_fallback (stdout だけ)" "$out"
--- primary || fallback
{"error":"rate limited"}
answer from fallback
--- run_with_fallback (stdout だけ)
answer from fallback

primary の stdout をいったん変数に受け、成功したときだけ出す。

  • PITFALL: 受けてから出すので、成功したときも出力が逐次には流れなくなる。fallback の無い経路では primary を直接実行する

PIPE_BUF を超える write には、同じパイプへの別の write が割り込む

1 回の write が PIPE_BUF の 4096 バイト以下なら行は混ざらず、8192 バイトでは 2000 行中 440 行に別の write が割り込んだ

POSIX がパイプへの write の原子性を保証するのは、PIPE_BUF バイト以下の write だけ。Linux の PIPE_BUF は 4096 で、POSIX が最低限として保証するのは 512。 stdout と stderr が同じパイプを指すとき、それを超える write の途中に別の write が入りうる。

2 つのプロセスが同じパイプへ、1 回の write で 1 行ずつ書く。A と B が同じ行に混ざった行を数える。

getconf PIPE_BUF /

w() { python3 -c 'import os, sys
c, n, k = sys.argv[1], int(sys.argv[2]), int(sys.argv[3])
b = (c * (n - 1) + "\n").encode()
for _ in range(k): os.write(1, b)' "$@"; }

for size in 4096 8192; do
  mixed=$({ w A "$size" 2000 & w B "$size" 2000 & wait; } | awk '/A/ && /B/ {m++} END {print m + 0}')
  echo "1 行 ${size} バイト x 2000 行 x 2 プロセス: 混ざった行 ${mixed}"
done
4096
1 行 4096 バイト x 2000 行 x 2 プロセス: 混ざった行 0
1 行 8192 バイト x 2000 行 x 2 プロセス: 混ざった行 440

構造化された出力 (JSON の行やテスト結果) とログを同じパイプに流し、別のプロセスが行単位で解析するときに踏む。1 回の write を PIPE_BUF 以下に保つか、出力先を分ける。

  • PITFALL: 4097 バイトの結果ブロックの途中に stderr のログ行が入り、--- PASS の行頭が分断されて、解析する側が合否を読めなかった例がある。1 行のログが単独で PIPE_BUF を超えても同じことが起きる

まとめ

場面起きること対策
a | b の $?b の終了コードだけpipefail を立てる
pipefail最後に非 0 で終わったコマンドの値 1 つ原因は PIPESTATUS で見る
cmd | grep -v X || truecmd の失敗も 0cmd | { grep -v X || true; }
if cmd | grep -q X取得の失敗が「一致なし」になるout=$(cmd) || ... の後に grep -q X <<<"$out"
stdin を読まない読み手書き手がまれに 141偽物も stdin を読み切る
生成 | headhead の後に上流が書くと 141最後まで読む awk、ファイルに grep -m
yes | head、/dev/urandom | head毎回 141入力を先に有限にする
echo "$big" | grep -q一致しても 141grep -q PAT <<<"$big"
mapfile < <(cmd)cmd の失敗が残らない番兵か一時ファイル
heredoc / here-string65536 バイトまでは pipe、超えると一時ファイル-p || -f で判定する
echo x 1>&2 | tee ff が空echo x | tee f 1>&2
primary || fallbackprimary の stdout が残る変数で受け、成功時だけ出す
PIPE_BUF を超える write別の write が割り込むwrite を小さくするか、出力先を分ける

参考