bashのtrap(ERR)が関数内に継承されないわけがない

試したことをありのままに書いたら分かりにくい記事になってしまったので、そのうち整理してまとめたい。
(以下記事本文)


久しぶりにシェルスクリプトでtrapの罠にはまったので備忘録。
(trapを使うたびにこの罠にかかっている気がする。trap自体それほど使わないし、使ったとしても一度実装したら滅多に変更しない部分だし…。)


# 普通の関数呼び出し
コマンドの失敗箇所が関数の外側か内側かで trap が動く/動かないの違いがあった。


## 関数外部のエラー
関数化していない箇所でコマンドが失敗する場合。
(以降のサンプルコードでは失敗を `false` で実装した)

~~~shell:trap_sample1_1.sh
#!/usr/bin/env bash
set -e
set -o pipefail

trap 'echo "ERR";' ERR

echo "1st"
false
echo "2nd"
~~~

~~~:実行結果
1st
ERR
~~~

`false` でコマンドが失敗すると、まず trap が動作して "ERR" を出力し、`set -e` の効果でスクリプトが終了するため "2nd" は出力しない。
(これは想定通り)


## 関数内部のエラー
関数化した箇所でコマンドが失敗する場合。

~~~shell:trap_sample1_2.sh
#!/usr/bin/env bash
set -e
set -o pipefail

trap 'echo "ERR";' ERR

foo()
{
	echo "1st"
	false
	echo "2nd"
}

foo
~~~

~~~:実行結果
1st
~~~

`false` でコマンドが失敗すると `set -e` の効果でスクリプトは終了するが、trap が動作しないため "ERR" は出力しない。
(これは想定外)


## 関数内部のエラー(trapを関数内に移動)
trap コマンドを関数内で実行した場合。

~~~shell:trap_sample1_3.sh
#!/usr/bin/env bash
set -e
set -o pipefail

foo()
{
	trap 'echo "ERR";' ERR

	echo "1st"
	false
	echo "2nd"
}

foo
~~~

~~~:実行結果
1st
ERR
~~~

`false` でコマンドが失敗すると、まず trap が動作して "ERR" を出力し、`set -e` の効果でスクリプトが終了するため "2nd" は出力しない。
(これは想定通り)


## ERRトラップはデフォルト設定では関数内に継承されない
ここで思い出す。

>
シェル関数は、後で使うために一連のコマンドを保存するものです。 シェル関数の定義は既に シェルの文法 で説明しています。 シェル関数名が単純なコマンド名として使われた場合、 関数名に対応するコマンド群が実行されます。 関数は現在のシェルのコンテキスト内で実行されます。 つまり、新しいプロセスを生成して関数を処理することはありません (これはシェルスクリプトと対照的な点です)。 関数の実行時には、関数に与えた引き数が位置パラメータとなります。 特殊パラメータ # は更新され、この変更が反映されます。 特殊パラメータ 0 は変わりません。 関数の実行中は FUNCNAME 変数の最初の要素に関数の名前が設定されます。
上記以外は、**シェル実行環境の状態は全て、関数とその呼び出し側で同じになります。  ただし、以下の例外があります**: DEBUG と RETURN のトラップ (後述の シェルの組み込みコマンド の項で、組み込みコマンド trap の説明を参照) は、 関数に trace 属性  (後述の組み込みコマンド  declare の説明を参照) が与えられている場合や、-o functrace シェルオプションが 組み込みコマンド set によって有効になっている (つまり、全ての関数が DEBUG と  RETURN  のトラップを継承している) 場合を除いて 継承されません。 **ERR トラップは、-o errtrace シェルオプションが有効になっていない限り 継承されません。**
(https://linuxjm.osdn.jp/html/GNU_bash/man1/bash.1.html)


## 関数内部のエラー(errtraceオプション有効化)
ということで、`-o errtrace` シェルオプションを有効にすると

~~~shell:trap_sample1_4.sh
#!/usr/bin/env bash
set -e
set -o pipefail
set -o errtrace

trap 'echo "ERR";' ERR

foo()
{
	echo "1st"
	false
	echo "2nd"
}

foo
~~~

~~~:実行結果
1st
ERR
~~~

`false` でコマンドが失敗すると、まず trap が動作して "ERR" を出力し、`set -e` の効果でスクリプトが終了するため "2nd" は出力しない。
(これで想定通りになった)

やった。


# (5/29追記) 全然ダメだった。

## コマンド置換"$()"で関数を呼び出す
コマンド置換 `$()` だと思うように動かない…

### NGケース

~~~shell:trap_sample2_1.sh
#!/usr/bin/env bash
set -e
set -o pipefail
set -o errtrace

trap 'echo "[$BASHPID] ERR";' ERR

foo()
{
	echo "[$BASHPID] 1st"
	false
	echo "[$BASHPID] 2nd"
}

echo "[$BASHPID] start"
xxxx="$(foo)"
echo "[$BASHPID] end"
echo "$xxxx" | sed "s/^/>>> /"
~~~

~~~:実行結果
[4304] start
[4304] end
>>> [5376] 1st
>>> [5376] ERR
>>> [5376] 2nd
~~~

**関数foo** (PID5376のサブシェルで動く)
`false` でコマンドが失敗すると、trap が動作して "ERR" を出力するが関数は終了せず "2nd" も出力した。(`false` で止まってほしかった)

**呼び出し元のシェル** (PID4304)
関数の中でコマンドが失敗(`false`)しても最後まで実行して "end" を出力した。(`foo` で止まってほしかった)


### OKケース(嘘)

~~~shell:trap_sample2_2.sh
#!/usr/bin/env bash
set -e
set -o pipefail
set -o errtrace

trap 'echo "[$BASHPID] ERR";' ERR

foo()
{
	echo "[$BASHPID] 1st"
	echo "[$BASHPID] 2nd"
	false
}

echo "[$BASHPID] start"
xxxx="$(foo)"
echo "[$BASHPID] end"
echo "$xxxx" | sed "s/^/>>> /"
~~~

~~~:実行結果
[3420] start
[3420] ERR
~~~

~~関数内の false で trap が動作＆終了して "end" も動いていない。~~
<font color="Red">**これは間違いだった。**</font>

<font color="Red">**関数foo** (サブシェルで動く)
ログには出ていないけど、`false` でコマンドが失敗して trap が動作して関数が終了している。</font>

<font color="Red">**呼び出し元のシェル** (PID3420)
関数内でコマンドが失敗(`false`)すると、trap が動作して "ERR" を出力して終了した。</font>


### 違いは何か
関数内の false の位置が違う。

* 関数内の途中のコマンドが失敗
trap は**サブシェル内**で動作する。処理は**止まらず最後まで実行する**。

* 関数内の最後のコマンドが失敗
trap は<font color="Red">**サブシェルと**</font>**呼び出し元のシェル**<font color="Red">**の両方**</font>で動作する。処理は失敗した時点で~~**止まる**。~~ 止まったように見えるけど、関数末尾に到達したから終わっただけか？


### たぶんこう書くのが正解
~~面倒くさがらず~~ trap に頼らず、エラーが起きるかもしれない箇所は `return` を書く。
失敗を意味する `return 1` なら呼び出し元に戻って trap が動作するハズ。

~~~shell:trap_sample2_1.sh(修正版1)
foo()
{
	echo "[$BASHPID] 1st"
	false  ||  return 1
	echo "[$BASHPID] 2nd"
}
~~~

~~~:実行結果
[924] start
[924] ERR
~~~

成功を意味する `return 0` なら呼び出し元に戻っても trap は動作しないハズ。

~~~shell:trap_sample2_1.sh(修正版2)
foo()
{
	echo "[$BASHPID] 1st"
	false  ||  return 0
	echo "[$BASHPID] 2nd"
}
~~~

~~~:実行結果
[5460] start
[5460] end
>>> [1944] 1st
~~~

ほらね。
でもまだ動かないケースがあるから、また追記~~する予定~~ した。


## コマンド置換"$()"で関数を呼び出す2
コマンド置換 `$()` の中で失敗しても呼び出し元のスクリプトが終了しないケースがある。


### 終了しないケース

~~~shell:trap_sample3_1.sh
#!/usr/bin/env bash
set -e
set -o pipefail
set -o errtrace

trap 'echo "[$BASHPID] ERR";' ERR

foo()
{
	echo "[$BASHPID] 1st"
	false  ||  return 1
	echo "[$BASHPID] 2nd"
}

echo "[$BASHPID] start"
echo "[$BASHPID] $(foo)"
echo "[$BASHPID] end"
~~~

~~~:実行結果
[3292] start
[3292] [1684] 1st
[1684] ERR
[3292] end
~~~

関数内の false で関数は終了したが、呼び出し元のスクリプトは終了せずに "end" まで動いてしまった。
ということは `xxxx="$(foo)"` と `echo "$(foo)"` には違いがあるということだ。


### コマンド置換の終了ステータスに注意
`xxxx="$(foo)"` と `echo "$(foo)"` の違いは終了ステータスだった。

~~~shell:コマンド置換を変数に代入する
$ xxxx=$(false)
$ echo $?
1
~~~

~~~shell:コマンド置換をコマンドの引数として使う
$ echo $(false)
$ echo $?
0
~~~

コマンド置換を変数に代入する場合は、コマンド置換(`$(false)`)の終了ステータスがその行の終了ステータスになるみたい。なので `set -e` しておけばスクリプトを終了できる。
コマンド置換をコマンドの引数として使う場合は、コマンド(`echo`)の終了ステータスがその行の終了ステータスになるため、 `set -e` してもスクリプトを終了できない。

コマンド置換を使うときは、結果を一旦変数に入れてから使った方が良さそうだ。


## trapは2度うごく

ここまでに例示したコードの trap は標準出力に出力するだけだったので気がつかなかったが、trap が2回動作しているケースがあった。
ためしに /dev/tty (標準出力をリダイレクトしたときでも端末に出力できるデバイスファイル)に出力してみると…

~~~shell:trap_sample4_1.sh
#!/usr/bin/env bash
set -e
set -o pipefail
set -o errtrace

trap 'echo "[$BASHPID] ERR"; echo "<$BASHPID>" > /dev/tty;' ERR

foo()
{
	echo "[$BASHPID] 1st"
	false  ||  return 1
	echo "[$BASHPID] 2nd"
}

echo "[$BASHPID] start"
xxxx="$(foo)"
echo "[$BASHPID] end"
echo "$xxxx" | sed "s/^/>>> /"
~~~

~~~:実行結果
[6048] start
<5532>
[6048] ERR
<6048>
~~~

コマンド置換のサブシェル(PID=5532)と元のシェル(PID=6048)でそれぞれ trap が動いていることがわかった。

trap　を使うほとんどのケースで trap が2回動くことは望んでいないと思うので、`-o errtrace` シェルオプションはデフォルトで無効になっているんだと思う。
このへんの仕様もきちんと理解しないまま、便利だからと気軽に使っているとそのうち痛い目にあうかもしれないな。