並列テストシャットダウン時の無限ループを修正:ワーカーが途中で死んでもハングしない
Server#shutdown の wait_for_active_workers ループ中にワーカーが死亡した場合でも、シャットダウンが正常に完了するよう修正されました。これにより、OOMキルやDRb接続断などによってワーカーが stop_worker を呼べなかった際の無限ループが解消されます。
背景
並列テストのシャットダウン処理には、#55794 で修正しきれなかったタイムウィンドウが残っていました。#55794 は Parallelization#shutdown の冒頭で Process.waitpid(pid, WNOHANG) を一度だけ実行し、シャットダウン開始前に すでに死亡しているワーカーを検出する仕組みを導入しました。しかし、ワーカーが Server#shutdown 内の wait_for_active_workers ループ 実行中に 死亡した場合はカバーされていませんでした。
Server#shutdown は while active_workers? でワーカーが DRb 越しに stop_worker を呼ぶのを待ちます。ワーカーが parallelize_teardown フック内で例外を起こす、DRb 接続が切断される、またはクリーンアップ途中に OOM キルされるといったケースでは、stop_worker は永遠に呼ばれません。結果として、シャットダウンは無限ループに陥ります。この問題は #57052 として報告されており、Capybara/Cuprite を使ったシステムテストを含む CI 環境で断続的に発生し、ウォッチドッグが約 90 秒後にプロセスを強制終了するまでハングし続けていました。
根本原因は、ワーカーの死亡検知が「一度きりのスナップショット」としてしか行われていなかった点にあります。Server#shutdown のループ内では、ゾンビプロセスを回収する仕組みが存在しなかったため、初期スイープ後に死亡したワーカーは検出不能なまま残り続けていました。
技術的な変更
Server クラスに reap_dead_workers メソッドが追加され、wait_for_active_workers ループの各イテレーションで呼び出されるようになりました。これにより、シャットダウン前だけでなく、シャットダウン中の任意のタイミングで死亡したワーカーを検出できます。
変更前:
def wait_for_active_workers
while active_workers?
sleep 0.1
end
end
変更後:
def wait_for_active_workers
while active_workers?
reap_dead_workers
sleep 0.1
end
end
def reap_dead_workers
dead_pids = @worker_pids.values.select do |pid|
Process.waitpid(pid, Process::WNOHANG)
rescue Errno::ECHILD
true
end
remove_dead_workers(dead_pids)
end
reap_dead_workers は @worker_pids に登録されている全 PID に対して Process.waitpid(pid, Process::WNOHANG) を呼び出します。WNOHANG フラグにより、子プロセスが終了していない場合でもブロックせずに nil を返すため、ループのパフォーマンスに影響を与えません。プロセスが終了済みであれば PID を返し、そのエントリが dead_pids に収集されます。また、プロセスが既に回収済みの場合は Errno::ECHILD が発生しますが、これも true として扱い dead_pids に含めることで、取りこぼしを防いでいます。収集した dead_pids は既存の remove_dead_workers メソッドに渡され、アクティブワーカーリストから除去されます。
リグレッションテストも追加されました。テストは、ワーカーをフォークして登録させた後、親プロセスが Server#shutdown に入った 後で そのワーカーを SIGKILL で強制終了するシナリオを再現します。Timeout.timeout(3, Minitest::Assertion, "Expected shutdown to not hang") でラップすることで、修正なしではテストがハングすることを明示的に検証できます。
設計判断
wait_for_active_workers の 各ポーリングサイクル でゾンビ回収を実行するアプローチが採用されました。
検出タイミングを Parallelization#shutdown の冒頭に限定する前回の方式との違いは、「スナップショット型」から「継続的監視型」への転換にあります。0.1 秒のスリープごとに全ワーカーの生死を確認するため、シャットダウン中のどの瞬間に死亡したワーカーも、最大 0.1 秒以内に検出されます。WNOHANG を使用することでブロッキングを回避し、既存のポーリング構造に最小限の変更で組み込める設計となっています。
Errno::ECHILD を true として扱うことも重要な判断です。これは、別のコードパスで既に waitpid が完了しているプロセスを「死亡済み」として正しく分類するためのもので、二重回収を安全に扱います。
まとめ
この修正は、「一度きりの死亡検知」から「ループ内での継続的な死亡検知」へと移行することで、#55794 が残していたタイムウィンドウを完全に塞ぎます。OOMキルや DRb 切断など、ワーカーが stop_worker を呼べないあらゆる異常終了シナリオに対して、並列テストのシャットダウンが確実に完了するようになりました。