はじめに
Travis CI で動かしていたシェルスクリプトを GitHub Actions に変更したら Broken pipe
(または printf: I/O error
等)というエラーメッセージが出るようになってしまいました。結論から言うとシグナル SIGPIPE
のトラップ状況が異なっているからのようです。
※ 今回エラーメッセージがでるようになったのはシェルスクリプトを使ったテストですが、CI から直接または間接的にシェルスクリプトや外部コマンドが実行される場合にも当てはまるはずです。
ローカルで再現
まずローカルで再現させる方法です。1行100文字で1000行(おそらく Linux のパイプのバッファサイズである 64KB 以上)ぐらいのファイルを用意してください。そして以下のシェルスクリプトを作って実行します。
# !/bin/sh
cat 1000.txt | head -n 5
この状態では「エラーはでない」はずです。実はこの処理の中でシグナル SIGPIPE
がこっそり発生しています。なぜなら cat
が 1000 行出力している途中で head
は 5 行読み込んだタイミングで終了してしまうため cat
の出力先がなくなってしまうからです。しかしそれでもエラーがでないのは SIGPIPE
が発生したときのデフォルトの処理が Terminate だからです。そのため cat
はエラー出力をする間もなく強制終了します。
cat
が SIGPIPE
で終了したのであればエラーになるのでは?と思った人、半分正しいです。実際 cat
はエラー終了しています。しかしシェルスクリプトの仕様上、パイプの左側(つまり cat
) でエラーになっても無視されます。
bash 等では set -o pipefail
を実行することでパイプの途中でエラーになった場合にエラー終了させることができるので以下のようにスクリプトを書き換えると、見事に SIGPIPE
のエラー(Linuxの場合は終了ステータス 13 (SIGPIPE) + 128 = 141)で停止します。
# !/bin/bash
set -o pipefail
cat 1000.txt | head -n 5
つまり、普段何気なく cat でかいファイル.txt | head -n 5
などとやると実はエラーが発生しているのです。
では上記のスクリプトをこう書き換えてみましょう。
# !/bin/sh
trap '' PIPE # PIPEシグナルの扱いを Ignore (SIG_IGN) に変更する
cat 1000.txt | head -n 5
cat: 書き込みエラー: Broken pipe
(cat: write error: Broken pipe
)というエラーが出力されたはずです。(ただし set -o pipefail
を実行してないのでスクリプト自体は正常終了です。)
SIGPIPE
を無視 (Ignore) に設定したらエラーメッセージも表示されないような気もしますが、単にシグナル発生時に何もせず続行する進むというだけなので、標準出力に出力ができなかった cat
はエラーを出力します。
このエラー出力を回避する簡単な方法は標準エラー出力を /dev/null
に捨てるという方法ですが、これだとその他のエラーもわからなくなってしまうので最後の手段にしたいところです。そもそもこのエラーは出力先 head
が早期に終了してしまうことで、cat
の出力先が無くなってしまうために発生します。そこで cat
の出力を全て受け取るようにすることで回避が可能です。例えば以下のような方法です。
# !/bin/sh
trap '' PIPE
cat 1000.txt | { head -n 5; cat >/dev/null; }
head
は早期に終了しますが、残りの出力を後続の cat >/dev/null
が受け取るため cat 1000.txt
の出力先が無くなることはなくエラーになることもありません。他にも wc
など出力をすべて受け取るコマンドを利用することで回避可能です。ただし本来はすべての行を処理することなく終了していたものを、すべての行を処理するわけなので遅くなってしまいます。遅くせずに回避する方法は標準エラー出力を捨てるしかないかもしれないです。(head
終了直後に cat
に SIGKILL
を送って強制終了させるという方法を考えてみましたが、プロセスIDの取得方法とか面倒なコードが必要になりそうです。)
GitHub Actions の問題
さて GitHub Actions の話に戻りますが、私が書いていたシェルスクリプトのコードは trap '' PIPE
で SIGPIPE
を無視するようなコードは書いていません。すなわち Terminate で静かにエラー終了するはずですが、GitHub Actions で実行するとなぜかエラーメッセージが表示されるようになってしまいました。どうも GitHub Actions は SIGPIPE
をデフォルトの Terminate ではなく Ignore に設定してからワークフローに書いたシェルスクリプトを実行するようなのです。
シグナルハンドラはデフォルト(SIG_DFL
) または Ignore (SIG_IGN
) であれば親プロセスの状態を継承します。そのためシェルスクリプトおよびそこから呼び出した cat
は SIGPIPE
が Ignore に設定された状態で実行してしまいます。
trap - PIPE では SIG_DFL に戻せない
GitHub Actions に問題があるとはいえ、シェルスクリプト内でデフォルト (SIG_DFL
) に戻せば回避可能・・・と思うかもしれませんができません。一応シェルスクリプトの trap
にはデフォルトに戻す方法があります。
# !/bin/sh
trap - PIPE
cat 1000.txt | head -n 5
しかしこれはシェルスクリプト開始時に SIG_IGN
に設定されているものに対しては効果がありません。その挙動は strace
を使用するとわかります。
まず普通に上記のスクリプトを実行した場合です。SIG_DFL
に設定しているのがわかります。
$ strace ./test.sh 2>&1 | grep SIGPIPE
rt_sigaction(SIGPIPE, NULL, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=SA_RESTORER, sa_restorer=0x7ff3003fef20}, 8) = 0
rt_sigaction(SIGPIPE, {sa_handler=SIG_DFL, sa_mask=~[RTMIN RT_1], sa_flags=SA_RESTORER, sa_restorer=0x7fcde743ef20}, NULL, 8) = 0
次に SIG_IGN
に設定してから実行した場合です。SIG_DFL
への設定は行われません。
$ trap '' PIPE
$ strace ./test.sh 2>&1 | grep SIGPIPE
rt_sigaction(SIGPIPE, NULL, {sa_handler=SIG_IGN, sa_mask=[], sa_flags=SA_RESTORER, sa_restorer=0x7ff3003fef20}, 8) = 0
この挙動は POSIX でも規定されています。
https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#trap
Signals that were ignored on entry to a non-interactive shell cannot be trapped or reset, although no error need be reported when attempting to do so. An interactive shell may reset or catch signals ignored on entry. Traps shall remain in place for a given shell until explicitly changed with another trap command.
Debian Bug report で bash の バグとして報告されていますが、正しい挙動であるとのことです。
"trap - SIGNAL" should reset SIGNAL to SIG_DFL not to initial value
おまけ参考リンク
Don't fear SIGPIPE!
env --default-signal で SIG_DFL に戻す
実はシェルスクリプトで SIG_IGN
から SIG_DFL
へ戻せない事への対応として、GNU Core Utilities の env コマンドに --default-signal
オプションが追加されているので、cat
コマンドを env
コマンド経由で呼び出せば回避できます。
‘--default-signal[=sig]’
Unblock and reset signal sig to its default signal handler. Without sig all known signals are unblocked and reset to their defaults. Multiple signals can be comma-separated. The following command runs seq with SIGINT and SIGPIPE set to their default (which is to terminate the program):
従来のシェルでは SIG_DFL
に戻すことが不可能であることも書かれています。
In the following example, we see how this is not possible to do with traditional shells. Here the first trap command sets SIGPIPE to ignore. The second trap command ostensibly sets it back to its default, but POSIX mandates that the shell must not change inherited state of the signal - so it is a no-op.
trap '' PIPE && sh -c 'trap - PIPE ; seq inf | head -n1'
ただし、このオプションは比較的最近の GNU Core Utilities 8.31 からの機能なので、現時点で最新の Debian buster や Ubuntu focal 等ではまだ使うことができません。
参考までですが cat
コマンドの呼び出しを全て、env --default-signal=PIPE cat
に変更するのは大変ですよね?こういう場合には cat
シェル関数を定義することで cat
コマンドの呼び出しを上書きすることができます。環境変数を使うよりも便利なテクニックなので覚えておくと良いと思います。
# !/bin/sh
cat() {
env --default-signal=PIPE cat "$@"
}
cat 1000.txt | head -n 5
Perl(他言語)で SIG_DFL に戻す
SIG_DFL
に戻せないのはシェルスクリプトの制限なので他の言語を使って戻すことができます。例えば Perl を使った場合は以下のようなコードで戻すことができます。
# !/usr/bin/env perl
$SIG{'PIPE'} = 'DEFAULT';
# system(); 等で実行したいシェルスクリプトを呼び出す
さいごに
ということで、GitHub Actions に乗り換えたら Broken Pipe が出るようになったのでいろいろと調べてみました。CI サービスは便利ですが、GitHub Actions に限らずサービスによって微妙に環境が違っていたりして予期せぬ所でハマったりして困りますね。検証するにもコードを push しないとわからないので時間がかかってしまうので大変です。(さて、あとは GitHub Actions に報告を・・・どこにすればいいんだろう?)