テストの後片付けが手元の環境を壊す4つの穴

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

テストの後片付けが手元の環境を壊す4つの穴

本記事はプロモーションを含みます。

はじめに: 後片付けのコードは、失敗したときにだけ走る

後片付けで踏んだ 4 つの穴: 普段の tmux を落とす、/tmp を消す、プロセスが残る、中断で掃除の材料が無い

テストの後片付け (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 で確かめた。

穴1: 後片付けの kill-server が普段の tmux サーバに届く

$TMUX が入っていると、tmux は TMUX_TMPDIR より $TMUX のソケットに繋ぐ。unset すると TMUX_TMPDIR の下に繋ぐ

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 を張った後ろにあった。

直し方: unset を trap より前に置き、片付けはソケットを直に指す

trap の後ろで unset する前に抜けると、片付けは普段のサーバに届く。unset を先に置き、片付けは -S でソケットを指す
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 まで必ず進むので、テストが緑でも、途中で抜ける経路を通さなければこの穴は見えない。

穴2: 変数が空になり、rm -rf が /tmp の中身を消す

名前を受け取って代入する関数に _ を渡すと、値を取り出せない。_ は直前のコマンドの最後の引数で上書きされる

別のリポジトリの e2e テストで、変数名を受け取ってそこに値を代入する補助関数を load_target _ と呼んでいた。後片付けは、その変数を埋め込んだパスを rm -rf で消す。変数は空になり、パスが /tmp/ を指した。/tmp/ にあった自分のユーザ所有のファイルが消えた。

なぜ空になるのか

_ は bash の特殊パラメータで、コマンドを実行するたびに直前のコマンドの最後の引数で上書きされる。代入先の変数名に使っても、値を取り出す前に上書きされる。

set_name() { printf -v "$1" '%s' svc; }
set_name _; echo "[$_]"   # => [_] (svc ではなく、直前のコマンドの引数が出る)

エラーも警告も出ない。値が空 (またはそれらしい別の値) のまま、次の行へ進む。

直し方: 空なら止める展開でパスを組む

変数が空だと rm -rf のパスが /tmp/ になる。${name:?} なら空のときエラーで止まり、rm は走らない

変数を埋め込んだパスを消すときは、${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 は一般ユーザでは作り直せず、ログインし直すことになる。

穴3: 止めたはずのエミュレータの java が残る

nix は node に exec し、java は別のセッションで動く。プロセスグループへの SIGTERM は java に届かず、node の PID に送ると node が java を止める

e2e テストで、Firebase の Auth と Firestore のエミュレータを nix develop -c firebase emulators:start で背景に起動し、終わったら止めていた。レビューで、停止の処理が node にも java にも届いていないと指摘された。

Firestore のエミュレータの本体は java で、起動の流れは次のとおりだった。

  • nix は firebase の node に exec する
  • java は node が起動し、別のセッションで動く
  • java を止めるのは、SIGTERM を受けた node の役目

そのため、プロセスグループへ SIGTERM を送っても java には届かない。さらに、起動したシェル (グループのリーダー) が終わったかを kill -0 <リーダー> で見ると、子が生きたまま「停止済み」と判定される。

直し方: exec で起動して、node 本体の PID に送る

( 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 を止める。

穴4: 中断すると、片付けに使うはずのレポートが無い

hurl は SIGINT で止まると JSON レポートを書かない。レポートから ID を拾う掃除は空振りし、目印で探す掃除なら残りを消せる

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 回とも、作ったデータは全部消えた。

共通する確かめ方: 途中で止めて、片付けを見る

後片付けは、途中で抜けた run で確かめる。普段の環境が変わっていないかと、何も残っていないかの 2 点を見る

4 つとも、最後まで正常に走った run では何も起きなかった。穴が出たのは、テストが途中で落ちた、抜けた、中断されたときだけだ。後片付けを書いたら、次の 2 点を「途中で止めた run」で確かめる。

  • 普段の環境が変わっていないか: 普段の tmux サーバの PID、/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 で確かめる。

参考