GitHub Actions で CI したら Broken pipe や I/O Error がでるようになった話（または SIGPIPE の罠）

## はじめに

Travis CI で動かしていたシェルスクリプトを GitHub Actions に変更したら `Broken pipe`（または `printf: I/O error` 等）というエラーメッセージが出るようになってしまいました。結論から言うとシグナル `SIGPIPE` のトラップ状況が異なっているからのようです。

※ 今回エラーメッセージがでるようになったのはシェルスクリプトを使ったテストですが、CI から直接または間接的にシェルスクリプトや外部コマンドが実行される場合にも当てはまるはずです。

## ローカルで再現

まずローカルで再現させる方法です。1行100文字で1000行（おそらく Linux のパイプのバッファサイズである 64KB 以上）ぐらいのファイルを用意してください。そして以下のシェルスクリプトを作って実行します。

```sh
#!/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）で停止します。

```sh
#!/bin/bash
set -o pipefail
cat 1000.txt | head -n 5
```

つまり、普段何気なく `cat でかいファイル.txt | head -n 5` などとやると実はエラーが発生しているのです。

では上記のスクリプトをこう書き換えてみましょう。

```sh
#!/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` の出力を全て受け取るようにすることで回避が可能です。例えば以下のような方法です。

```sh
#!/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` にはデフォルトに戻す方法があります。

```sh
#!/bin/sh
trap - PIPE
cat 1000.txt | head -n 5
```

しかしこれはシェルスクリプト開始時に `SIG_IGN` に設定されているものに対しては効果がありません。その挙動は `strace` を使用するとわかります。

まず普通に上記のスクリプトを実行した場合です。`SIG_DFL` に設定しているのがわかります。

```console
$ 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` への設定は行われません。

```console
$ 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](https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=573780)

おまけ参考リンク
[Don't fear SIGPIPE!](http://www.pixelbeat.org/programming/sigpipe_handling.html)

## env --default-signal で SIG_DFL に戻す

実はシェルスクリプトで `SIG_IGN` から `SIG_DFL` へ戻せない事への対応として、GNU Core Utilities の env コマンドに `--default-signal` オプションが追加されているので、`cat` コマンドを `env` コマンド経由で呼び出せば回避できます。

https://www.gnu.org/software/coreutils/manual/html_node/env-invocation.html

> ‘--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` コマンドの呼び出しを上書きすることができます。環境変数を使うよりも便利なテクニックなので覚えておくと良いと思います。

```sh
#!/bin/sh

cat() {
  env --default-signal=PIPE cat "$@"
}

cat 1000.txt | head -n 5
```

## Perl（他言語）で SIG_DFL に戻す

`SIG_DFL` に戻せないのはシェルスクリプトの制限なので他の言語を使って戻すことができます。例えば Perl を使った場合は以下のようなコードで戻すことができます。

```sh
#!/usr/bin/env perl
$SIG{'PIPE'} = 'DEFAULT';
# system(); 等で実行したいシェルスクリプトを呼び出す
```

## さいごに

ということで、GitHub Actions に乗り換えたら Broken Pipe が出るようになったのでいろいろと調べてみました。CI サービスは便利ですが、GitHub Actions に限らずサービスによって微妙に環境が違っていたりして予期せぬ所でハマったりして困りますね。検証するにもコードを push しないとわからないので時間がかかってしまうので大変です。（さて、あとは GitHub Actions に報告を・・・どこにすればいいんだろう？）