クライアント切断時のプロセスクラッシュを修正(Server・Application)
クライアントがハンドシェイク途中で切断した際に Errno::EPIPE が伝播してSpringプロセスが強制終了するバグが修正されました。Spring::Server#serve と Spring::Application#serve の計5箇所の書き込みサイトが適切にレスキューされ、切断済みクライアントへの書き込み失敗がプロセス終了に繋がらなくなりました。
背景
Issue #724 として報告されたこのバグは、クライアントが Spring.connect_timeout(デフォルト5秒)でタイムアウトするなど、ハンドシェイク途中で切断した場合に顕在化します。Shopifyのモノレポのような起動が遅い環境では、プリローダーの起動待ちでタイムアウトが発生しやすく、再現性が高い問題でした。
クライアントが切断した後にサーバー側がそのソケットへ書き込もうとすると、OSは Errno::EPIPE(Broken pipe)を発生させます。この例外がレスキューされずに伝播すると、それを保持しているプロセスが終了します。影響を受けるクラッシュサイトは2つのプロセスにまたがる計5箇所でした。
以前にも同様の修正を試みた #725 がありましたが、Spring::Application#serve 内の4箇所のうち一部が見落とされていたため、本PRで改めて全箇所を網羅する形で修正が行われました。
技術的な変更
本PRの核心は ignore_client_disconnect という小さなヘルパーメソッドの導入です。Errno::EPIPE を飲み込む共通の抽象が用意されたことで、各クラッシュサイトを均一な方法でガードできるようになりました。
# 変更後
def ignore_client_disconnect
yield
rescue Errno::EPIPE
end
このヘルパーは lib/spring/application.rb の private セクションに追加され、serve メソッド内の4箇所に適用されています。変更前後の対比は以下の通りです。
変更前:
rescue Exception
log "preload failed"
client.puts(1) # preload failure
raise
end
変更後:
rescue Exception
log "preload failed"
ignore_client_disconnect { client.puts(1) } # preload failure
raise
end
既存の rescue Exception ハンドラ内部にある3つの書き込みは、同一スコープの rescue では捕捉できないため個別にラップする必要がありました。具体的には print_exception、streams.each(&:close)、client.puts(1) if pid の3箇所です。
# 変更前
if streams && !e.is_a?(SystemExit)
print_exception(stderr, e)
streams.each(&:close)
end
client.puts(1) if pid
# 変更後
if streams && !e.is_a?(SystemExit)
ignore_client_disconnect { print_exception(stderr, e) }
streams.each { |stream| ignore_client_disconnect { stream.close } }
end
ignore_client_disconnect { client.puts(1) if pid }
Spring::Server#serve 側は既存の rescue SocketError に rescue Errno::EPIPE を1ブロック追加するだけのシンプルな修正です。
# 変更後
rescue SocketError => e
raise e unless client.eof?
rescue Errno::EPIPE => e
log "client disconnected with error #{e.message}, ignoring command"
さらに、Errno::EPIPE の場合はログメッセージを "exception: #{e}" ではなく "client disconnected (#{e.message}), ignoring command" に変更することで、既知の切断イベントと予期しない例外を明確に区別できるようになりました。
テストは test/unit/application_test.rb と test/unit/server_test.rb として新規追加されました。UNIXSocket.pair でソケットペアを作成し、クライアント側を事前にクローズしてから serve を呼び出すことで、各クラッシュサイトを個別に検証するリグレッションテストになっています。
設計判断
ignore_client_disconnect を独立したヘルパーに切り出す設計 が採用されたことが重要なポイントです。begin/rescue Errno::EPIPE をインラインで書くことも可能でしたが、ヘルパーに集約することで「クライアント切断への耐性」という意図が各呼び出し箇所で明示的に伝わります。
rescue Exception ブロック内での Errno::EPIPE は、同レベルの別 rescue 節では捕捉できません。Rubyの例外処理の仕様上、同一 begin/end 内の rescue 節は上から順に評価され、先にマッチした節がすべての処理を担うためです。この制約に対応するため、問題のある書き込みをブロックごとラップするアプローチが採られました。
PRのコメントでは Errno::ECONNRESET も合わせてレスキューする案が検討されましたが、Issue #724 で実際に報告された症状(Errno::EPIPE)に修正範囲を絞り込み、過度な変更を避ける方針が選択されています。application.rb:164 の SocketError に関するクラッシュサイトも別経路として存在しますが、スコープ外として今後のフォローアップに委ねられています。
まとめ
今回の修正は、ignore_client_disconnect という単一のヘルパーを導入することで、2つのプロセスにまたがる5箇所のクラッシュサイトを一貫した方法でガードするものです。特に Spring::Server のacceptループがクラッシュして以降のすべてのクライアントがタイムアウトするという最悪ケースが解消され、spring stop を手動実行しなければ復旧できない状況が回避されるようになりました。