テストを流したら、普段の tmux が落ちて端末の窓が全部閉じた。/tmp のファイルが消えた。止めたはずのプロセスが残った。後片付けのコードが手元を壊す・ゴミを残す 4 つの穴と、途中で止めて確かめる方法を解説する。

本記事はプロモーションを含みます。
テストの後片付け (teardown) は、テスト本体より危ない。本体はテスト用に用意した場所で動くが、後片付けは「消す」「止める」を実行する。消す先や止める先を取り違えると、手元の本物の環境に届く。しかも、後片付けの誤りは正常に終わった run では表に出ず、テストが途中で落ちたときや中断したときにだけ出る。
筆者がこの 1 週間で踏んだのは、次の 4 つだった。
| 穴 | 何が起きたか | どう塞いだか |
|---|---|---|
| 片付けが普段の tmux に届く | テスト用でなく普段の tmux サーバを kill-server し、端末の窓が全部閉じた | unset TMUX を trap より前に置き、片付けは -S でソケットを直に指す |
| 片付けのパスが空になる | rm -rf が /tmp/ のユーザ所有ファイルを消した | ${name:?} で、空なら止める |
| 止めたはずのプロセスが残る | エミュレータの java が止まらない | 起動を exec にして、記録した PID で止める |
| 中断すると片付けの材料が無い | SIGINT で hurl がレポートを書かず、作ったデータの ID が分からない | 目印で探して消す形にする |
偽の curl が子プロセスに届かず、本物の API トークンで API を叩いた件は「テストもCIも緑なのに壊れた実装を通す穴」に書いた。この記事は、その「テストが本物に届く」側の続きだ。シェルの実行結果は GNU bash 5.3.15 と tmux 3.7c で確かめた。
tmux を操作する CLI ツールの smoke テストを書いていた。テストは TMUX_TMPDIR を一時ディレクトリに向けて別の tmux サーバを立て、終わったら trap で tmux kill-server を呼んで片付ける。
このテストを、普段と違う置き方 (シンボリックリンク経由) で実行したら、途中の go build が失敗してスクリプトが抜けた。その瞬間、普段使っている tmux サーバが止まり、端末 (Ghostty) のウィンドウが全部閉じた。
端末のクラッシュに見えたが、journal には tmux の子のスコープが一斉に終わった記録と pty fd closed が残っていた。端末が落ちたのではなく、端末の中の tmux が止められていた。
-S も -L も付けない tmux コマンドは、環境変数 $TMUX が入っていれば、その先頭にあるソケットのサーバへ命令する。TMUX_TMPDIR を変えても、$TMUX が優先される。
TMUX=/tmp/fake-sock,1,0 TMUX_TMPDIR=/tmp/x tmux ls
# => error connecting to /tmp/fake-sock (No such file or directory)
TMUX= TMUX_TMPDIR=/tmp/x tmux ls
# => error connecting to /tmp/x/tmux-1000/default (No such file or directory)
tmux の中で打った tmux が、今いるサーバに命令するための作りだ。$TMUX は <ソケット>,<サーバの PID>,<セッション番号> の形をしている。man には、$TMUX と TMUX_TMPDIR のどちらが勝つかは書かれていない。上の結果は tmux 3.7c で測ったものだ。
テストのスクリプトには unset TMUX があった。ただし trap を張った後ろにあった。
main() {
set -euo pipefail
local work
work=$(mktemp -d)
export TMUX_TMPDIR=$work
unset TMUX # trap より前。間で抜けると cleanup が普段のサーバを落とす
trap 'tmux -S "$TMUX_TMPDIR/tmux-$(id -u)/default" kill-server 2>/dev/null || true' EXIT
# ... テスト本体
}
main "$@"
直したのは 2 か所だ。
unset TMUX を trap より前へ移した。trap を張った時点で、$TMUX はもう無いkill-server に -S でテスト用のソケットを渡した。環境変数がどうなっていても、テスト用のサーバにしか届かない前者だけでも今回の事故は防げる。それでも後者を足したのは、隔離を環境変数の順序に頼りたくなかったからだ。-L / -S で明示すれば、どこで抜けても片付けの宛先は変わらない。
確かめたのは、途中で抜けた場合と通常の実行の両方で、普段のサーバの PID が変わらないことだ。サーバの PID は tmux display-message -p '#{pid}' で出る。通常の実行は unset まで必ず進むので、テストが緑でも、途中で抜ける経路を通さなければこの穴は見えない。
別のリポジトリの e2e テストで、変数名を受け取ってそこに値を代入する補助関数を load_target _ と呼んでいた。後片付けは、その変数を埋め込んだパスを rm -rf で消す。変数は空になり、パスが /tmp/ を指した。/tmp/ にあった自分のユーザ所有のファイルが消えた。
_ は bash の特殊パラメータで、コマンドを実行するたびに直前のコマンドの最後の引数で上書きされる。代入先の変数名に使っても、値を取り出す前に上書きされる。
set_name() { printf -v "$1" '%s' svc; }
set_name _; echo "[$_]" # => [_] (svc ではなく、直前のコマンドの引数が出る)
エラーも警告も出ない。値が空 (またはそれらしい別の値) のまま、次の行へ進む。
変数を埋め込んだパスを消すときは、${name:?} で書く。変数が空か未設定なら、その場でエラーになり、rm は実行されない。
name=""
rm -rf "/tmp/${name}" # /tmp/ の中身を消す
rm -rf "/tmp/${name:?}.pid" # name: パラメータが null または設定されていません (rm は走らない)
set -u は未設定の変数しか止めない。空文字が入った変数はそのまま通るので、消す処理のパスには :? を使う。
消えたファイルには、普段の tmux のソケットも入っていた。tmux はソケットを消されても、サーバに SIGUSR1 を送ると作り直す。これは tmux の man に書かれている。
mkdir -m 700 "/tmp/tmux-$(id -u)"
pkill -USR1 -u "$(id -u)" -x tmux
tmux list-sessions
親のディレクトリが無いと作り直しに失敗するので、先に mkdir する。一方、/tmp/.X11-unix のソケットと、他のセッションの一時ファイルは戻せなかった。/tmp/.X11-unix は一般ユーザでは作り直せず、ログインし直すことになる。
e2e テストで、Firebase の Auth と Firestore のエミュレータを nix develop -c firebase emulators:start で背景に起動し、終わったら止めていた。レビューで、停止の処理が node にも java にも届いていないと指摘された。
Firestore のエミュレータの本体は java で、起動の流れは次のとおりだった。
exec するそのため、プロセスグループへ SIGTERM を送っても java には届かない。さらに、起動したシェル (グループのリーダー) が終わったかを kill -0 <リーダー> で見ると、子が生きたまま「停止済み」と判定される。
( exec nix develop .#firebase -c firebase emulators:start --only auth,firestore ) &
echo $! > emu.pid # exec しているので、node 本体の pid になる
kill "$(cat emu.pid)" # node が java を止める
サブシェルの中で exec すると、$! が指すプロセスが nix を経て node 本体になる。その PID に SIGTERM を送れば、node が自分の子の java を止める。
API の e2e シナリオを hurl で書き、run ごとにテスト用のデータを作って、最後に消す形にした。最初は、hurl の JSON レポートから作ったデータの ID を拾って消す後処理にしていた。
レビューで、hurl は SIGINT で中断されると JSON レポートを書かないと指摘された。レポートは実行の最後にまとめて書かれるからだ。CI で新しい push が前の run を取り消したとき (cancel-in-progress) や、手元で Ctrl-C したときに、後処理には読む入力が無い。作ったデータはそのまま残る。
hurl --report-json out a.hurl & sleep 1; kill -INT $!; wait; ls out
# report.json が無い
もう 1 つ、hurl は既定では失敗したエントリより後ろを実行しない。消す処理をシナリオの末尾に書いておくと、途中のアサートが 1 つ落ちただけで消す処理まで飛ばされる。続行させるには --continue-on-error が要る。
掃除を「レポートから ID を拾って消す」から「目印で探して消す」に変えた。作るときに run を見分ける目印を付けておき、掃除は API でその目印のデータを探して消す。こうすると、掃除はどこで止まった run の後でも同じ手順で動く。レポートが無くても、前の run の残りまで拾える。
確かめるために、実行を 3.5-5.2 秒の間で 7 回中断し、そのたびに掃除を流した。7 回とも、作ったデータは全部消えた。
4 つとも、最後まで正常に走った run では何も起きなかった。穴が出たのは、テストが途中で落ちた、抜けた、中断されたときだけだ。後片付けを書いたら、次の 2 点を「途中で止めた run」で確かめる。
/tmp/ の中身、手元のシェルの環境変数pgrep で java まで)、テスト用のデータ、一時ディレクトリ止める場所は、setup の直後、本体の途中、片付けの途中の 3 つを選ぶ。筆者は穴 1 で「途中で抜けた場合と通常の実行」を、穴 4 で「時刻をずらした 7 回の中断」を流した。
テストのハーネス自体が失敗を数えていなかった件や、cron と手動の二重起動でファイルを上書きした件は「自動実行のbashが突然落ちる・失敗を見逃す4つの罠」に、set -e が止まらない場面は「bashのset -eが効かない5つの場面と対処法」に書いた。
trap、パラメータ展開の :?、プロセスとシグナルの扱いは、シェルスクリプトの基本の範囲だ。書き方を体系的に押さえ直すなら次の本がよい。
新しいシェルプログラミングの教科書 (三宅英明、電子書籍)
| 穴 | 確かめ方・塞ぎ方 |
|---|---|
| 片付けが普段の tmux に届く | unset TMUX を trap より前に置く。片付けは tmux -S <テスト用のソケット> で指す |
| 片付けのパスが空になる | 消すパスは ${name:?} で組む。代入先の名前に _ を使わない |
| 止めたはずのプロセスが残る | ( exec ... ) & で起動し、記録した本体の PID に送る。kill -0 <リーダー> で判定しない |
| 中断で掃除の材料が無い | 目印で探して消す。消す処理を失敗で飛ばさない (--continue-on-error) |
| 偽物が子プロセスに届かない | テストの入口で本物のトークンを unset する (別記事) |
後片付けは、成功した run ではなく、途中で止めた run で確かめる。
-L / -S、TMUX_TMPDIR、SIGUSR1 でのソケットの作り直し)_、Shell Parameter Expansion の ${parameter:?word})--continue-on-error、--report-json)