並列テスト終了後に残存するDRbスレッドのリークを修正
プロセスベースの並列テスト実行後、親プロセスが DRb.stop_service を呼び出していなかったことで DRb の accept ループスレッドが残存し、一部の環境でテストプロセスが終了できなくなる問題を修正しました。
背景
ActiveSupport::Testing::Parallelization は、初期化時に DRb.start_service でDRbサービスを起動し、ワーカープロセスへの作業キューをDRb経由で提供します。しかし、#shutdown メソッドはキューサーバのシャットダウンとワーカープロセスの回収のみを行い、DRb.stop_service を呼び出していませんでした。
この不整合により、DRbが内部で管理する accept ループスレッド(accept_or_shutdown / main_loop)がワーカー終了後も親プロセス内で生き続けました。ワーカープロセス側は fork 後に DRb.stop_service を呼び出していたため、クリーンアップが欠けていたのは親プロセスのみです。CIで 0 failures を出力した後にプロセスがタイムアウトするという形で顕在化したのも、この非デーモンスレッドが Rubyランタイムの終了をブロックしていたためです。
技術的な変更
Parallelization#shutdown の末尾、すべてのワーカープロセスが Process.waitpid で回収された後に DRb.stop_service の呼び出しを追加しました。
変更前:
def shutdown
# ...
@queue_server.shutdown
@worker_pool.each { Process.waitpid(_1) ... }
Parallelization.run_cleanup_hooks.each(&:call)
end
変更後:
def shutdown
# ...
@queue_server.shutdown
@worker_pool.each { Process.waitpid(_1) ... }
DRb.stop_service
Parallelization.run_cleanup_hooks.each(&:call)
end
変更箇所は2行の追加のみです。DRb.stop_service はクリーンアップフックの直前、つまりすべてのワーカー回収後に配置されており、DRbサービスが確実に不要になったタイミングで停止されます。
あわせて、リグレッションテストが activesupport/test/parallelization_test.rb に追加されました。テストは shutdown 後に Thread.list を検査し、バックトレースに drb/drb.rb を含む alive なスレッドが存在しないことを assert_empty で検証します。Process.respond_to?(:fork) が偽の環境(Windowsなど)ではスキップされます。
test "shutdown stops the DRb service started in initialize" do
skip "Process-based parallelization requires fork" unless Process.respond_to?(:fork)
parallelization = ActiveSupport::Testing::Parallelization.new(1)
parallelization.start
parallelization.shutdown
leaked = Thread.list
.reject { |t| t == Thread.main }
.select(&:alive?)
.select do |t|
bt = Array(t.backtrace).join("\n")
bt.include?("drb/drb.rb") && bt.match?(/accept_or_shutdown|main_loop/)
end
assert_empty leaked,
"Expected Parallelization#shutdown to call DRb.stop_service. Leaked:\n" +
leaked.map { |t| Array(t.backtrace).first(5).join("\n ") }.join("\n---\n")
end
設計判断
DRb.stop_service の挿入位置は、ワーカー回収後・クリーンアップフック前という順序が意図的に選ばれています。ワーカーが生きている間はDRbを介したキューアクセスが発生しうるため、Process.waitpid による全ワーカー回収を完了させてから停止するのが安全です。一方、クリーンアップフックは独立した後処理であり、DRbサービスへの依存がないため、その前に停止しても問題ありません。
変更の規模は最小限に抑えられており、start / shutdown の対称性(サービスを起動したものが停止する責任を持つ)という原則を取り戻す修正です。ワーカー側がすでに DRb.stop_service を呼び出していたという事実は、この呼び出しが必要であることを設計上も認識していたことを示しており、親プロセス側での漏れであったことが明確です。
まとめ
#shutdown に DRb.stop_service を1行追加するだけで、#initialize で開始したDRbサービスのライフサイクルが対称的に閉じられるようになります。起動と停止の責任を同一クラス内で完結させるという原則を徹底することで、CI環境でのプロセスタイムアウトという形で顕在化していた問題が根本から解消されます。