結論

  • trap は渡した文字列を登録し、発火した時点でコードとして読み直す。単引用符なら変数は発火時の値になり、二重引用符なら張った時点の値が構文の一部として埋まる
  • EXIT trap が発火するのはシェルの終了時で、張った関数の中ではない。その関数の local 変数はもう無いので、値は printf -v q '%q' で埋めるか、グローバル変数に置く
  • EXIT trap は SIGINT・SIGTERM・SIGHUP で止められても走り、SIGKILL では走らない。trap cleanup EXIT INT と INT も並べると、Ctrl-C の後もスクリプトが先へ進む
  • trap はシェルに 1 つの表で、関数ごとの範囲を持たない。ライブラリの trap - EXIT は呼び出し元の EXIT trap も消し、関数で張った RETURN trap は呼び出し元の return でも発火する
  • set -e の下では、EXIT trap の中の失敗が終了コードを書き換え、trap の残りの行を飛ばす。set -u だけなら trap は止まるが、終了コードは残る

以下は GNU bash 5.3.20 / Linux x86_64 で実測した。

前提: trap は「後で読み直す文字列」をシェルの表に登録する

trap '<文字列>' EXIT は、その場では何も実行しない。文字列をシェルの表に登録し、発火したときに新しいコマンドとして読み直す。

  • 表はシェル (プロセス) に 1 つで、シグナル (EXIT・RETURN を含む) ごとに 1 本の文字列を持つ。同じシグナルに張り直すと、前の文字列は置き換わる
  • 登録した文字列は trap -p <シグナル> で読める
  • 張った時点と発火した時点では、変数の値も、呼び出し中の関数も、表の中身も変わりうる。以下の罠はどれもこのずれから起きる

set -e 自体の効き方は bash の set -e が効かない場所と効きすぎる場所 に、 ERR trap がパイプの失敗で発火しない件は trap コマンドは pipe と併用すると動作しない に書いた。

関数の local 変数は、EXIT trap の発火時にはもう無い

main の中で張った EXIT trap は main が戻った後のシェルの終了時に発火し、そのとき local の tmp は消えている

main の中で張っても、EXIT trap が発火するのは exit でシェルが終わるとき。そのとき main はもう戻っていて、local tmp は消えている。

# single.sh
cleanup() { echo "cleanup [$1]"; }
main() {
  local tmp=/tmp/abc
  trap 'cleanup "$tmp"' EXIT
}
main
exit "${1:-0}"

オプション無しでは空文字に展開され、空のパスで後始末が走る。

$ bash single.sh; echo "rc=$?"
cleanup []
rc=0

set -u があると、trap の中で unbound variable になって止まる。set -u だけなら元の終了コードが残り、set -e も立てると 0 でも 3 でも 1 になる。

for opts in -u -eu; do
  for code in 0 3; do
    bash "$opts" single.sh "$code"; echo "bash $opts, exit $code => rc=$?"
  done
done
single.sh: line 1: tmp: unbound variable
bash -u, exit 0 => rc=0
single.sh: line 1: tmp: unbound variable
bash -u, exit 3 => rc=3
single.sh: line 1: tmp: unbound variable
bash -eu, exit 0 => rc=1
single.sh: line 1: tmp: unbound variable
bash -eu, exit 3 => rc=1

直し方は 2 つある。

  1. 張った時点の値を printf -v q '%q' でエスケープし、二重引用符で埋め込む
  2. local を外し、グローバル変数に置く
# fixed.sh
set -euo pipefail
cleanup() { echo "cleanup [$1]"; }
main() {
  local tmp=/tmp/abc q
  printf -v q '%q' "$tmp"
  trap "cleanup $q" EXIT
  trap -p EXIT
}
main
exit 3
$ bash fixed.sh; echo "rc=$?"
trap -- 'cleanup /tmp/abc' EXIT
cleanup [/tmp/abc]
rc=3
# global.sh
set -euo pipefail
cleanup() { echo "cleanup [$1]"; }
tmp=
main() {
  tmp=/tmp/abc
  trap 'cleanup "$tmp"' EXIT
}
main
exit 3
$ bash global.sh; echo "rc=$?"
cleanup [/tmp/abc]
rc=3
  • PITFALL: エラーは line 1 と出て、trap を張った 4 行目を指さない。処理を main() { ...; }; main "$@" に包み直したスクリプトやテストで起き、終了コード 1 の原因を探しにくい

二重引用符で埋めた値は、発火時にコードとして読まれる

二重引用符の trap は値を埋めた文字列を保存し、発火時にその文字列を読み直すので、値の引用符やセミコロンや空白が構文になる

二重引用符で組むと、変数は張った時点で展開される。発火時に読み直されるのは展開後の文字列なので、値の中の ' や ; や空白は、値でなくシェルの構文になる。 ブランチ名やリポジトリ名など、外から来た文字列をパスに含むときに効いてくる。

# t.sh
cleanup() { printf 'cleanup'; printf ' [%s]' "$@"; echo; }
wt=$1
case $2 in
  dq)  trap "cleanup '$wt'" EXIT ;;
  raw) trap "cleanup $wt" EXIT ;;
  sq)  trap 'cleanup "$wt"' EXIT ;;
  pq)  printf -v q '%q' "$wt"; trap "cleanup $q" EXIT ;;
esac
[[ -n ${3:-} ]] && trap -p EXIT
exit 0

値に ' と ; が入ると、'$wt' で囲んだつもりの引用が値の ' で閉じ、echo INJECTED がコマンドとして走る。

for form in dq sq pq; do
  echo "--- $form"; bash t.sh "/tmp/x'; echo INJECTED; '" "$form"; echo "rc=$?"
done
--- dq
cleanup [/tmp/x]
INJECTED
t.sh: line 1: : command not found
rc=0
--- sq
cleanup [/tmp/x'; echo INJECTED; ']
rc=0
--- pq
cleanup [/tmp/x'; echo INJECTED; ']
rc=0

trap -p で見ると、保存された文字列は cleanup '/tmp/x'; echo INJECTED; '' になっている (trap -p は ' を '\'' にエスケープして出す)。 : command not found は、末尾に残った '' が空文字のコマンドとして読まれたもの。

bash t.sh "/tmp/x'; echo INJECTED; '" dq show
trap -- 'cleanup '\''/tmp/x'\''; echo INJECTED; '\'''\''' EXIT
cleanup [/tmp/x]
INJECTED
t.sh: line 1: : command not found

引用符を付けずに埋めると、空白で引数が割れる。rm -rf に渡していれば、別の 2 つのパスを消しにいく。

for form in raw pq; do
  echo "--- $form"; bash t.sh "/tmp/a b" "$form"; echo "rc=$?"
done
--- raw
cleanup [/tmp/a] [b]
rc=0
--- pq
cleanup [/tmp/a b]
rc=0

保存された文字列を trap -p で見ると、単引用符の形は変数名のまま、%q の形はエスケープ済みの値になっている。

for form in sq pq; do
  echo "--- $form"; bash t.sh "/tmp/a b" "$form" show
done
--- sq
trap -- 'cleanup "$wt"' EXIT
cleanup [/tmp/a b]
--- pq
trap -- 'cleanup /tmp/a\ b' EXIT
cleanup [/tmp/a b]
書き方値を読む時点起きること
trap "cleanup '$wt'" EXIT張った時点値の ' ; が構文になる
trap "cleanup $wt" EXIT張った時点値の空白で引数が割れる
trap 'cleanup "$wt"' EXIT発火した時点値は安全。local 変数なら空になる
printf -v q '%q' "$wt"; trap "cleanup $q" EXIT張った時点値は安全。張った時点の値で固定される
  • PITFALL: 単引用符と local の組み合わせは前節の罠になる。関数の中で張るなら %q の形にする

EXIT trap は INT・TERM・HUP で止められても走る

EXIT trap だけを張ったスクリプトは INT・TERM・HUP で止めても cleanup が走り、KILL では走らない。INT を並べると止まらずに先へ進む

trap cleanup EXIT だけを張ったスクリプトを SIGINT・SIGTERM・SIGHUP で止めても、cleanup が走る。終了コードは signal で止まった値 (128 + 番号) のまま。 SIGKILL では走らない。

run() { # $1=signal $2=送り先 (pid|group) $3=trap の対象
  local start=${EPOCHREALTIME/./} p rc
  bash -c "trap 'echo cleanup' $3; sleep 5; echo after-sleep" & p=$!
  sleep 0.3
  if [[ $2 == group ]]; then kill -"$1" -- -"$p"; else kill -"$1" "$p"; fi
  wait "$p"; rc=$?
  echo "$1 -> $2, trap=$3: rc=$rc 経過=$(( (${EPOCHREALTIME/./} - start) / 100000 ))00ms"
}

set -m
for s in INT TERM HUP; do run "$s" group EXIT; done
run KILL group EXIT
cleanup
INT -> group, trap=EXIT: rc=130 経過=300ms
cleanup
TERM -> group, trap=EXIT: rc=143 経過=300ms
cleanup
demo.sh: line 7:    60 Hangup                     bash -c "trap 'echo cleanup' $3; sleep 5; echo after-sleep"
HUP -> group, trap=EXIT: rc=129 経過=300ms
demo.sh: line 7:    63 Killed                     bash -c "trap 'echo cleanup' $3; sleep 5; echo after-sleep"
KILL -> group, trap=EXIT: rc=137 経過=300ms

set -m で子を別のプロセスグループに入れ、kill -- -<pid> でグループ全体へ送っている。端末の Ctrl-C も、前面のプロセスグループ全体に SIGINT を送る。

trap cleanup EXIT INT と INT も並べると、INT で cleanup が走った後、スクリプトは終わらずに次の行へ進む。終了時にもう 1 回 cleanup が走り、終了コードは 0 になる。

run INT group 'EXIT INT'
cleanup
after-sleep
cleanup
INT -> group, trap=EXIT INT: rc=0 経過=300ms

INT に trap を張ると、bash は INT で終わらずに trap を実行して処理を続ける。後始末は EXIT だけに張る。

  • PITFALL: ジョブ制御の無いシェル (set -m 無し) で & を付けて起動した子は、SIGINT を無視した状態で始まる。kill -INT は効かず、子は sleep 5 を終えてから普通に終わるので、cleanup が出て rc=0 になる。INT で止まったように見えるが、5 秒待って普通に終わっただけ
run INT pid EXIT    # set -m の前に呼ぶ
after-sleep
cleanup
INT -> pid, trap=EXIT: rc=0 経過=5000ms

ライブラリの trap - EXIT は、呼び出し元の EXIT trap も消す

trap の表はシェルに 1 つなので、source したライブラリが張り直して trap - EXIT で外すと、呼び出し元の EXIT trap は戻らない

trap の表はシェルに 1 つで、関数ごとの範囲は無い。source したライブラリの関数が EXIT trap を張り直すと呼び出し元の分は置き換わり、trap - EXIT で外すと何も残らない。 「自分が張った trap だけを外す」操作は無い。

# lib.sh
gen() {
  trap 'echo "  [lib] cleanup"' EXIT
  echo "  [lib] gen の本体:"; trap -p EXIT
  trap - EXIT
}
bash -c '
  trap "echo \"[caller] cleanup\"" EXIT
  source "./$1"
  echo "before:"; trap -p EXIT
  gen
  echo "after:";  trap -p EXIT' _ lib.sh
echo "rc=$?"
before:
trap -- 'echo "[caller] cleanup"' EXIT
  [lib] gen の本体:
trap -- 'echo "  [lib] cleanup"' EXIT
after:
rc=0

[caller] cleanup は最後まで出ない。終了コードは 0 のままなので、後始末が抜けたことに気づく手がかりが無い。

張る前に trap -p の出力を取っておき、外した後に eval で戻す。$(trap -p EXIT) はコマンド置換の中でも呼び出し元の trap を返す。

# lib_saved.sh
gen() {
  local saved
  saved=$(trap -p EXIT)
  trap 'echo "  [lib] cleanup"' EXIT
  echo "  [lib] gen の本体:"; trap -p EXIT
  trap - EXIT
  if [[ -n $saved ]]; then eval "$saved"; fi
}
before:
trap -- 'echo "[caller] cleanup"' EXIT
  [lib] gen の本体:
trap -- 'echo "  [lib] cleanup"' EXIT
after:
trap -- 'echo "[caller] cleanup"' EXIT
[caller] cleanup
rc=0

戻す行を [[ -n $saved ]] && eval "$saved" で関数の最後に置くと、退避する trap が無いときに関数が 1 を返し、set -e の呼び出し元が止まる。if で書く。

# lib_and.sh
gen() {
  local saved
  saved=$(trap -p EXIT)
  trap 'echo "  [lib] cleanup"' EXIT
  trap - EXIT
  [[ -n $saved ]] && eval "$saved"
}
$ bash -ec 'source ./lib_and.sh; gen; echo "gen の後"'; echo "rc=$?"
rc=1
  • PITFALL: 本体を単独で実行すると正しく動く。スクリプトを source して関数ごとに試すテストハーネスの中でだけ、ハーネスの後始末が消える

関数で張った RETURN trap は、呼び出し元の return でも発火する

clean_tasks の中で張った RETURN trap は表に残り、呼び出し元の main の return でも発火する。後から呼んだ h では発火しない

RETURN trap は関数が戻るときに発火する。関数の中で張っても、その関数を抜けた後も表に残り、呼び出し元の関数が戻るときにも発火する。 変数の local に相当する仕組みは trap に無い。

bash -c '
  clean_tasks() { trap "echo \"  RETURN (\${FUNCNAME[0]} から戻る)\"" RETURN; echo "  clean_tasks の本体"; }
  main() { clean_tasks; echo "  main の本体"; }
  main
  echo "main の後:"; trap -p RETURN
  h() { echo "  h の本体"; }
  echo "後から呼んだ h:"; h'
  clean_tasks の本体
  RETURN (clean_tasks から戻る)
  main の本体
  RETURN (main から戻る)
main の後:
trap -- 'echo "  RETURN (${FUNCNAME[0]} から戻る)"' RETURN
後から呼んだ h:
  h の本体

後処理のメッセージが 2 回出る。発火するのは張った時点で呼び出し中だった関数の return で、後から呼んだ h では発火しない。

関数の最後で trap - RETURN と外すと、外した後に戻るので、自分の return でも発火しなくなる。

bash -c '
  clean_tasks() { trap "echo \"  RETURN (\${FUNCNAME[0]} から戻る)\"" RETURN; echo "  clean_tasks の本体"; trap - RETURN; }
  main() { clean_tasks; echo "  main の本体"; }
  main'
  clean_tasks の本体
  main の本体

関数本体を ( ) の subshell で書くと、RETURN trap はそもそも発火しない。set -o functrace の有無で変わらない。{ } の関数は functrace 無しでも自分の return で発火する。

bash -c '
  f() ( trap "echo \"  RET-f\"" RETURN; echo "  f の本体" )
  g() { trap "echo \"  RET-g\"" RETURN; echo "  g の本体"; }
  echo "--- functrace 無し"; f; g'
bash -c '
  set -o functrace
  f() ( trap "echo \"  RET-f\"" RETURN; echo "  f の本体" )
  g() { trap "echo \"  RET-g\"" RETURN; echo "  g の本体"; }
  echo "--- set -o functrace"; f; g'
--- functrace 無し
  f の本体
  g の本体
  RET-g
--- set -o functrace
  f の本体
  g の本体
  RET-g

後処理は RETURN trap に頼らず、関数の末尾で明示的に呼ぶ。

bash -c '
  info() { echo "  $*"; }
  clean_tasks() { echo "  clean_tasks の本体"; info "==> Task cleaned"; }
  main() { clean_tasks; echo "  main の本体"; }
  main'
  clean_tasks の本体
  ==> Task cleaned
  main の本体
  • PITFALL: 一時ディレクトリを作る関数を ( ) の本体で書き、RETURN trap で消すつもりにすると、片付けが走らずに残り続ける

EXIT trap の中の失敗は、set -e の下で終了コードを書き換える

末尾の kill と wait は 143 をスクリプトの終了コードにする。EXIT trap の中なら終了コードは残るが、set -e の下では wait の 143 で trap が止まり 143 で終わる

テスト用のサーバやエミュレータを裏で起動し、最後に kill と wait で片付けるとき、wait は待った子の終了コードを返す。SIGTERM で止めた子なら 143 になる。

bash -c 'sleep 30 & pid=$!; kill "$pid"; wait "$pid"; echo "wait rc=$?"'
wait rc=143

スクリプトの末尾に置くと、テストが全部通ってもスクリプトが 143 で終わる。

bash -c 'sleep 30 & pid=$!; run_tests() { echo "tests passed"; }; run_tests; kill "$pid"; wait "$pid"'
echo "rc=$?"
tests passed
rc=143

テストの結果を先に取っておき、wait の失敗は捨てて、取っておいた値で exit する。

bash -c 'sleep 30 & pid=$!; run_tests() { echo "tests passed"; }
  rc=0; run_tests || rc=$?
  kill "$pid"; wait "$pid" 2>/dev/null || true
  exit "$rc"'
echo "rc=$?"
tests passed
rc=0

EXIT trap の中に置くと、set -e が無ければ、trap の中の wait は終了コードを変えない。exit の値も、exit を書かずに最後まで走ったときの値も残る。

for code in 0 3; do
  bash -c 'sleep 30 & pid=$!; trap "kill $pid; wait $pid; echo \"trap: wait rc=\$?\"" EXIT; exit '"$code"
  echo "exit $code => rc=$?"
done
bash -c 'sleep 30 & pid=$!; trap "kill $pid; wait $pid" EXIT; echo "tests passed"'
echo "rc=$?"
trap: wait rc=143
exit 0 => rc=0
trap: wait rc=143
exit 3 => rc=3
tests passed
rc=0

set -e の下では、trap の中の wait の 143 で trap が止まり、スクリプトは 143 で終わる。wait の次の行は実行されない。|| true を付けると元の終了コードに戻る。

for code in 0 3; do
  bash -ec 'sleep 30 & pid=$!; trap "kill $pid; wait $pid; echo \"trap: wait の次の行\"" EXIT; exit '"$code"
  echo "exit $code => rc=$?"
done
bash -ec 'sleep 30 & pid=$!; trap "kill $pid; wait $pid || true" EXIT; exit 3'
echo "wait || true, exit 3 => rc=$?"
exit 0 => rc=143
exit 3 => rc=143
wait || true, exit 3 => rc=3

最初の節の「set -eu だと終了コードが必ず 1 になる」も同じ形で、trap の中の失敗が元の終了コードを上書きしている。 後始末の中で失敗しうるコマンドには || true を付け、trap の残りの行を飛ばさないようにする。

  • PITFALL: テストは全部通ったのに、make が「失敗 (143)」で終わる。テストの出力だけを見ていると、どこで 143 になったかが分からない

まとめ

場面起きること対策
関数の中の trap '... "$tmp"' EXIT で tmp が local発火時に tmp が無い。空で走るか、set -u で止まる (set -eu なら rc=1)printf -v q '%q' で埋めるか、グローバル変数に置く
trap "cleanup '$v'" EXIT値の ' ; が構文になる単引用符で書くか %q で埋める
trap "cleanup $v" EXIT値の空白で引数が割れる同上
INT / TERM / HUP で止まるEXIT trap は走る。rc は 128 + 番号後始末は EXIT だけに張る
trap cleanup EXIT INTCtrl-C の後も先へ進み、cleanup が 2 回走るINT を並べない
SIGKILL で止まるEXIT trap は走らないtrap では防げない
ジョブ制御無しの & の子へ kill -INT無視されるset -m で別グループにして送る
ライブラリの trap - EXIT呼び出し元の EXIT trap も消えるsaved=$(trap -p EXIT) で退避し、if で戻す
関数で張った RETURN trap呼び出し元の return でも発火する後処理は関数の末尾で明示的に呼ぶ
( ) 本体の関数の RETURN trap発火しない同上
末尾の kill; wait143 がスクリプトの終了コードになるrc を取っておき exit "$rc"
set -e の下の EXIT trap の wait143 で終わり、trap の残りを飛ばすwait "$pid" || true

参考