From 546e819fbe8e2585e2a226f8465d218adfb20b67 Mon Sep 17 00:00:00 2001 From: Dmitry Vorotilin Date: Mon, 28 Sep 2026 20:55:02 +0300 Subject: [PATCH] fix(client): close the socket when the browser dies before the close handshake `WebSocket#close` only sends a close frame; the socket is closed by the driver's `on_close`, which runs when the browser answers with a close frame of its own. A browser that is already dead never answers, so `#quit` and `#restart` left the socket open until GC finalized it -- one leaked descriptor per call, enough to raise `Errno::EMFILE` from `Process.spawn` when browsers are restarted in a loop under a low `nofile` limit. Close the socket explicitly at the end of `Client#close`, after the existing grace period for the dispatch thread, so a healthy quit still completes the handshake through `on_close` and the forced close is a no-op there. --- lib/ferrum/client.rb | 1 + lib/ferrum/client/web_socket.rb | 18 ++++++++++++++++++ sig/ferrum/client/web_socket.rbs | 2 ++ spec/client_spec.rb | 10 ++++++++++ 4 files changed, 31 insertions(+) diff --git a/lib/ferrum/client.rb b/lib/ferrum/client.rb index 4321476e..24069745 100644 --- a/lib/ferrum/client.rb +++ b/lib/ferrum/client.rb @@ -299,6 +299,7 @@ def close @pendings.clear @thread.kill unless @thread.join(1) @subscriber.close + @ws.force_close end # diff --git a/lib/ferrum/client/web_socket.rb b/lib/ferrum/client/web_socket.rb index cdbca237..95697e68 100644 --- a/lib/ferrum/client/web_socket.rb +++ b/lib/ferrum/client/web_socket.rb @@ -141,6 +141,24 @@ def close @driver_mutex.synchronize { @driver.close } end + # + # Closes the underlying socket whether or not the close handshake ever + # completed. + # + # {#close} only sends a close frame; the socket itself is closed by + # {#on_close}, which runs when the browser answers with a close frame of + # its own. A browser that is already dead never answers, so without this + # the socket stays open until GC finalizes it -- long enough to exhaust + # the process's file descriptors when browsers are restarted in a loop. + # + # @return [void] + # + def force_close + @sock.close unless @sock.closed? + rescue IOError, SystemCallError + # nop + end + private def start diff --git a/sig/ferrum/client/web_socket.rbs b/sig/ferrum/client/web_socket.rbs index bb5338d8..8c31e9a0 100644 --- a/sig/ferrum/client/web_socket.rbs +++ b/sig/ferrum/client/web_socket.rbs @@ -30,6 +30,8 @@ module Ferrum def close: () -> void + def force_close: () -> void + private def start: () -> void diff --git a/spec/client_spec.rb b/spec/client_spec.rb index b1585041..f3cbbe33 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -38,6 +38,16 @@ def messages expect(Ferrum::Utils::ElapsedTime.elapsed_time(start)).to be < 1 end + it "closes the socket when the browser dies before answering the close frame" do + remote.create_page + socket = remote.client.instance_variable_get(:@ws).instance_variable_get(:@sock) + Process.kill("KILL", -remote.process.pid) + + remote.quit + + expect(socket).to be_closed + end + it "reports an unexpected reader error instead of raising in the main thread" do page = remote.create_page driver = remote.client.instance_variable_get(:@ws).instance_variable_get(:@driver)