Rack Hijack時のExecutor状態を即時解放する修正
Rack HijackやWebSocketアップグレード時に ActionDispatch::Executor がExecutor状態を即座に解放するようになりました。これにより、fiber-scheduledサーバー(Falconなど)でのリロードブロック問題が解消されます。
背景
ActionDispatch::Executor はレスポンスボディの close コールバック、またはサーバーが提供する rack.response_finished を通じてExecutor状態(リローダーのshareロック)を解放していました。しかし、WebSocketアップグレードやfull rack hijackではボディが長命なストリーミング接続になるため、ソケットが閉じるまで close が発火しません。
この挙動が問題を引き起こす経路は、サーバーによって異なります。Puma ではAction Cableのhijackがワーカープールへのデタッチを行うため、元のリクエストスレッドが解放され問題が表面化しませんでした。一方、Falcon のようなfiber-scheduledサーバーではリクエストfiberがhijackされたソケットをインラインで読み続けるため、shareロックがクライアント切断まで保持されます。開発環境のリローダーが before_class_unload で排他ロック(:unload)を取得しようとすると、shareが保持されている間は取得不可能なため、クライアントが切断するまでコード再ロードがブロックされます。
関連する #57423 は ActiveSupport::Concurrency::ShareLock がfiber isolationのもとで同一スレッド上の全fiberを同一オーナーとして扱うバグを修正しており、本PRと組み合わせることで問題が完全に解消されます。
技術的な変更
Hijackを検出してExecutor状態を即時に完了させるため、ActionDispatch::Executor#call に3つの変更が加えられました。
べき等な finalize クロージャの導入
従来は state.complete! の呼び出しが3箇所に直書きされていましたが、二重完了を防ぐためのフラグ付き finalize クロージャに統一されました。
変更前:
state = @executor.run!(reset: true)
if response_finished = env["rack.response_finished"]
response_finished << proc { state.complete! }
end
# ...
unless response_finished
response << ::Rack::BodyProxy.new(response.pop) { state.complete! }
end
# ...
if !returned && !response_finished
state.complete!
end
変更後:
state = @executor.run!(reset: true)
completed = false
finalize = -> {
next if completed
completed = true
state.complete!
}
if response_finished = env["rack.response_finished"]
response_finished << proc { finalize.call }
end
# ...
if hijacked?(env, response)
finalize.call
elsif !response_finished
response << ::Rack::BodyProxy.new(response.pop) { finalize.call }
end
# ...
if !returned && !response_finished
finalize.call
end
hijacked? プライベートメソッドの追加
Hijack検出は新しいプライベートメソッド hijacked? が担います。env["rack.hijack_io"] の存在(full rack hijack)またはレスポンスステータス 101(WebSocketアップグレード)のいずれかが真であれば hijack と判定します。
def hijacked?(env, response)
return false unless response
env["rack.hijack_io"] || response.first == 101
end
なお、env["rack.hijack?"] はサーバーがhijackをサポートすることを示すフラグであり、hijackが実際に発生したかどうかとは無関係なため、検出条件には含まれていません。
hijackが検出された場合は finalize.call を即座に呼び出し、その後 rack.response_finished のコールバックが発火しても completed フラグにより二重完了が防がれます。
設計判断
finalize クロージャによる一元管理が採用されました。Hijack検出パスを追加するにあたり、新たな state.complete! の呼び出しを加えると既存の3パス(rack.response_finished・BodyProxy・ensureブロック)との組み合わせで二重完了リスクが生じます。べき等なクロージャへの統一は、この問題を完全に排除するとともに、将来的な完了パスの追加時にも同様の保護を提供します。
また、Long-lived hijackのコンシューマー(Action Cable)は before_class_unload でのコネクション切断によってリロードを管理しているため、hijack時点でshareを解放してもオートロードされた定数がクリアされるリスクはありません。リクエストの処理に必要なコードはhijackレスポンスを返す時点で解決済みであり、この判断が設計の前提になっています。
まとめ
本PRはfiber-scheduledサーバー環境でのリロードブロックという実用上の問題に対し、既存の完了パスをべき等なクロージャに統一したうえでhijack検出を追加するという最小限の変更で解決しています。ActionDispatch::Executor の完了ライフサイクルが明確に整理されたことで、将来的な新しい完了パスの追加も安全に行える基盤が整いました。