POSIX準拠シェルのためのpipefailとPIPESTATUSの実装（改良版）

## はじめに

:::note warn
この記事は古い記事です。説明のために残していますが、代わりに改良された以下のライブラリを使用してください。
:::

https://github.com/ko1nksm/sh-pipestatus

```sh:改良されたライブラリの使い方
# From
cmd1 | cmd2 | cmd3

# To
pipe 'cmd1 | cmd2 | cmd3' "$@"

# or （set -o pipefail相当）
pipe -fail 'cmd1 | cmd2 | cmd3' "$@"

# exit codes are in
echo "$? and $PIPESTS" # => 0 and 0 0 0
```

---

POSIXシェルにおいて複数のコマンドをパイプで繋いだとき、途中のコマンドでエラーが起きたとしても全体の終了ステータスは最後のものになります。

```sh
#!/bin/sh

foo() { exit 11; }
bar() { exit 12; }
baz() { exit 13; }

foo | bar | baz
echo $? # => 13

foo | bar | baz | cat
echo $? # => 0
```

これでは途中で何かしらのエラーが発生したとしても検出できず、場合によっては処理がそのまま進んでしまいます。これを防ぐオプションが `pipefail` でパイプラインの各コマンドの終了ステータスを取得するための変数が `PIPESTATUS` です。

```bash
#!/bin/bash

省略

foo | bar | baz
echo "${PIPESTATUS[@]}" # => 11 12 13

set -o pipefail
foo | bar | baz | cat
echo $? # => 13
```

`pipefail` は bash, zsh, ksh, mksh, yash で利用可能で `PIPESTATUS` は bash と mksh（R40 以降）と zsh （では `pipestatus` 変数）で利用可能です。

便利な機能ですが `pipefail` は POSIX.1-2024 で標準化された機能で、少し古いシェルでは使えません。また `PIPESTATUS` は POSIX で標準化されていないので使えないシェルがあります。この問題は「[ここ](https://www.unix.com/302268337-post4.html)」に有名なの回避策があります。

```sh
# https://www.unix.com/302268337-post4.html より
run() {
  j=1
  while eval "\${pipestatus_$j+:} false"; do
    unset pipestatus_$j
    j=$(($j+1))
  done
  j=1 com= k=1 l=
  for a; do
    if [ "x$a" = 'x|' ]; then
      com="$com { $l "'3>&-
                  echo "pipestatus_'$j'=$?" >&3
                } 4>&- |'
      j=$(($j+1)) l=
    else
      l="$l \"\$$k\""
    fi
    k=$(($k+1))
  done
  com="$com $l"' 3>&- >&4 4>&-
             echo "pipestatus_'$j'=$?"'
  exec 4>&1
  eval "$(exec 3>&1; eval "$com")"
  exec 4>&-
  j=1
  while eval "\${pipestatus_$j+:} false"; do
    eval "[ \$pipestatus_$j -eq 0 ]" || return 1
    j=$(($j+1))
  done
  return 0
}

```

しかしこの関数の使い方を見るとすぐわかる問題点があります。

>   use it as:
>
>       run cmd1 \| cmd2 \| cmd3
>       exit codes are in $pipestatus_1, $pipestatus_2, $pipestatus_3

上記の通り各コマンドの区切り文字として `\|` を使用しています。（パイプとして認識されないようにエスケープしており、これは `|` という文字です。）つまりコマンドの引数として `|` という文字が渡されてしまうと誤動作を起こしてしまうということです。またエラーが発生した場合の終了ステータスは `1` 固定であるため `pipefail` とも挙動が異なります。（もっとも元記事は `pipefail` を実装したとは書いてないので間違いというわけではありません。）

また `set -e` の状態だと期待通りに動作しないことがあり、dash ではエラーが発生したコマンド以降の戻り値が取れません。（その他のシェルでは取れるようなので dash のバグかも知れないです。）

最初、参照元のコードを読んだときは何をやってるかさっぱりだったのですが、今ならシェルスクリプト力もついたので自力で実装できるんじゃないかと思い改良版を実装しました。この記事では参照元のコードを「オリジナルのコード」と呼ぶことにし、いくつかの問題を解決した新しい実装を提示します。


## 実装

```sh
# License: Creative Commons Zero v1.0 Universal

pipeline() {
  r=$1 i=0 com='' ret=''
  while [ $# -gt 1 ] && i=$(($i+1)) && shift; do
    com="$com( ($1) &&:; echo xs${i}=\$? >&4; exec 4>&- )${2:+ | }"
    ret="$ret \$xs${i}"
  done
  echo "{ $r=\$( ( exec >&3; $com; exec 3>&- ) 4>&1 ); } 3>&1"
  echo "$r=\$(eval \$$r; echo $ret; exit \$xs${i})"
}

pipefail() {
  pipeline "$@"
  remove_trailing_zeros='until [ "$i" = "${i% 0}" ]; do i=${i% 0}; done'
  echo "(i=\$$1; $remove_trailing_zeros; exit \${i##* })"
}
```

（ずいぶん短くすることができました。😁）

```sh
# 使い方
eval "$(pipeline PSTATUS \
  "printf 1" \
  "awk '{print \$1+1}END{exit 2}'" \
  "cat" \
  "awk '{print \$1+1}END{exit 4}'" \
  "cat" \
)"
echo "$PSTATUS"


3 # 標準出力
0 2 0 4 0  # PSTATUS

# PSTATUS の各値の参照の仕方（例）
echo "${PSTATUS%% *}" # 最初の値
echo "${PSTATUS##* }" # 最後の値
check() {
  eval "set -- $1" # スペース区切りの各値を位置パラメータに展開する
  echo $1 # 1つ目の値
  echo $2 # 2つ目の値
}
check "$PSTATUS" 
```

使い方はオリジナルのコードから随分と変わります。`pipeline` は `eval` で実行できるコードを出力する関数です。`PIPESTATUS` はグローバル変数 `$pipestatus_N` ではなく `pipeline` 関数の第一引数で指定した名前の変数に代入されます。名前は決め打ちでも良かったのですが指定できた方が便利だと思います。また複数の変数に別々にいれるのではなく単一の変数にスペース区切りで入れています。この値を処理するのは難しくないでしょう。（上記に参照方法の例を書いています。）

残りの引数はパイプラインの各コマンドです。一引数がそれぞれ一つのコマンド（＋引数）になります。この引数には変数も使用可能です。その場合`'cmd "$1"'` のように文字列で指定します。分かりづらいかもしれませんが `eval` と同じと考えれば良いです。一引数が一コマンドであるため `\|` のような区切り文字は不要です。（あった方が見やすいかもしれませんが必要ないので不要にしています。区切り文字を使いたければ単に無視する処理を入れるだけです。）

`pipeline` 関数の戻り値は最後のコマンドの値ですが、代わりに `pipefail` を使用すると最後にエラーになった値になります。

### 注意点

OpenBSD ksh には `if` などの条件文と組み合わせて`eval` を使用した場合に、 `set -e` をしていると処理が中断されてしまうバグがあります。（参考 「[Behaviour of “eval” under “set -e” in conditional expression](https://unix.stackexchange.com/questions/523098/behaviour-of-eval-under-set-e-in-conditional-expression) 」「[ksh/sh: eval misbehaving under "set -e" in AND-OR list/conditional statement](https://marc.info/?l=openbsd-bugs&m=156043348715252)」）

```sh
#!/bin/sh
set -e

# 本来ならfalseが出力されるべきだが eval で中断されるため何も出力されない
if eval "false"; then
  echo "true"
else
  echo "false"
fi

# 回避策
if eval "false &&:"; then
  echo "true"
else
  echo "false"
fi
```

そのため `pipefail`、`pipefail` 関数でも同様の対策が必要になります。

```sh
if eval "$(pipeline PSTATUS 'foo' 'bar' 'baz') &&:"; then
```

なおこのバグはオリジナルのコードにも影響します。（以下の箇所です）

```sh
 while eval "\${pipestatus_$j+:} false"; do
```

一応、関数側で `&&:` を追加することで回避することもできます。ただし常につけてしまうと今度は少し古い zsh （5.0系とそれ以前）のバグで動作しなくなるため zsh では追加しないようにする必要があります。

```sh
pipeline() {
  r=$1 i=0 com='' ret=""
  [ "${ZSH_VERSION:-}" ] && wa='' || wa='&&:' # この行を追加
  while [ $# -gt 1 ] && i=$(($i+1)) && shift; do
    com="$com( ($1) &&:; echo xs${i}=\$? >&4; exec 4>&- )${2:+ | }"
    ret="$ret \$xs${i}"
  done
  echo "{ $r=\$( ( exec >&3; $com; exec 3>&- ) 4>&1 ); } 3>&1"
  echo "$r=\$(eval \$$r; echo $ret; exit \$xs${i}) $wa" # 最後に &&: を追加
}
pipefail() {
  pipeline "$@"
  remove_trailing_zeros='until [ "$i" = "${i% 0}" ]; do i=${i% 0}; done'
  echo "(i=\$$1; $remove_trailing_zeros; exit \${i##* }) $wa" # 最後に &&: を追加
}
```
## コード解説

コードは大幅に変わっていますが、パイプラインのそれぞれのコマンドの戻り値を取得する仕組みは元記事のコードとそう変わりません。`pipeline` 関数および `pipefail` 関数は `eval` するためのコードを出力します。例えば以下のコードを実行する場合

```sh
eval "$(pipeline PSTATUS 'foo "$@"' 'bar "$@"' 'baz "$@"')"
```

`pipeline` 関数は次のようなコードを出力します。（見やすさのため適当な場所で改行やインデントを入れています。）

```sh
{
  PSTATUS=$(
    (
      exec >&3;
      ( (foo "$@") &&:; echo xs1=$? >&4; exec 4>&- )
        |
      ( (bar "$@") &&:; echo xs2=$? >&4; exec 4>&- )
        |
      ( (baz "$@") &&:; echo xs3=$? >&4; exec 4>&- );
      exec 3>&-
    ) 4>&1
  );
} 3>&1
PSTATUS=$(eval $PSTATUS; echo  $xs1 $xs2 $xs3; exit $xs3)
```

コマンドの戻り値を取得する方法のキモは、戻り値をファイルディスクリプタ 4 経由で渡すことです。各コマンドをパイプでつなぐと本来の戻り値（`$?`）は消えてしまいます。そのため戻り値が消える前に `echo xs1=$? >&4` のようにしてファイルディスクリプタ 4 に出力します。そして外側の `4>&1` で標準出力に出力し、コマンド置換 `PSTATUS=$(...)` でこの値をキャプチャします。この時、本来の標準出力の内容と混じらないように、本来の標準出力はファイルディスクリプタ 3に退避（`exec >&3`）させ、再度外側で元に戻して（`3>&1`）います。

その他、オリジナルのコードからの改良点を羅列しておきます。

- `pipeline`, `pipefail` 関数の戻り値を、`set -o pipefail` と互換にした
- `set -e` の状態でも正しく動作するようにした
- グローバル変数を使わないようにした

オリジナルのコードからのマイナス点は POSIX 準拠かつ実際のシェルで正しく動くようにサブシェルをいくつか使用するのでパフォーマンスがわずかに低下します。

### 余談

このコードを見て「`exec 4>&-` は必要ないのでは？」とか「`exec >&3` ではなくて `( ) >&3`を使えばいいのでは？」とか「 ここは遅い `( )` でなく `{ }` でいいのでは？」と思った人へ。それらは特定の（古い）シェルのバグのワークアラウンドです。もしかしたら改善の余地はあるかもしれませんが疲れました。一応テストもしているので全てのPOSIXシェルに対応できているはずです。