結論
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 が発火するのは 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=0set -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
donesingle.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 つある。
- 張った時点の値を
printf -v q '%q'でエスケープし、二重引用符で埋め込む 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 の原因を探しにくい
二重引用符で埋めた値は、発火時にコードとして読まれる

二重引用符で組むと、変数は張った時点で展開される。発火時に読み直されるのは展開後の文字列なので、値の中の ' や ; や空白は、値でなくシェルの構文になる。
ブランチ名やリポジトリ名など、外から来た文字列をパスに含むときに効いてくる。
# 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=0trap -p で見ると、保存された文字列は cleanup '/tmp/x'; echo INJECTED; '' になっている (trap -p は ' を '\'' にエスケープして出す)。
: command not found は、末尾に残った '' が空文字のコマンドとして読まれたもの。
bash t.sh "/tmp/x'; echo INJECTED; '" dq showtrap -- '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 で止められても走る

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 EXITcleanup
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 経過=300msset -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 経過=300msINT に 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 したライブラリの関数が 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 でも発火する

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 で片付けるとき、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=0EXIT 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=0set -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 INT | Ctrl-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; wait | 143 がスクリプトの終了コードになる | rc を取っておき exit "$rc" |
set -e の下の EXIT trap の wait | 143 で終わり、trap の残りを飛ばす | wait "$pid" || true |