From 5bfcba1f3eddf248559957fc7d928b4876664a99 Mon Sep 17 00:00:00 2001 From: Dmitry Vorotilin Date: Wed, 21 Oct 2026 15:49:49 +0300 Subject: [PATCH] fix(contexts): detach nested service worker sessions so Chrome can stop them A page's service worker is auto-attached twice, at the browser level and under the page session. The nested session was detached on the browser client, Chrome answered "No session with given id", so it stayed attached. Detach on the parent session, after the resume is acknowledged. Chrome only acknowledges it once every session that paused the worker resumed it, so this runs on its own thread to not block the other attach event. --- CHANGELOG.md | 1 + lib/ferrum/contexts.rb | 30 +++++++++++++++--------------- sig/ferrum/contexts.rbs | 8 ++++---- spec/worker_spec.rb | 11 +++++++++++ 4 files changed, 31 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3206ce8..bfd8415c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ `429 Too Many Requests` for a full remote browser service, and exposes it as `#status` ### Fixed +- Service workers registered by a page stayed attached and alive for the browser's lifetime, now Chrome can stop them - An out-of-process iframe kept its parent page's session when `Target.targetCreated` came before `Target.attachedToTarget`, so commands sent through it reached the page [#663] - `:extensions` run before the document's own scripts in popups and out-of-process iframes too. Popups used to get diff --git a/lib/ferrum/contexts.rb b/lib/ferrum/contexts.rb index 33138fb7..3cf79c85 100644 --- a/lib/ferrum/contexts.rb +++ b/lib/ferrum/contexts.rb @@ -159,14 +159,14 @@ def subscribe def subscribe_attached_target(client) client.on("Target.attachedToTarget") do |params| info, session_id = params.values_at("targetInfo", "sessionId") - next detach_untracked(session_id) unless ALLOWED_TARGET_TYPES.include?(info["type"]) + next detach_untracked(session_id, client) unless ALLOWED_TARGET_TYPES.include?(info["type"]) context_id = info["browserContextId"] add_context(context_id) target = @contexts[context_id]&.add_target(session_id: session_id, params: info, attached: true) rearm_auto_attach(session_id, info["type"]) - handle_attach(target, session_id, params) + handle_attach(target, session_id, params, client) end end @@ -218,11 +218,11 @@ def rearm_auto_attach(session_id, type) subscribe_attached_target(client) end - def handle_attach(target, session_id, params) + def handle_attach(target, session_id, params, parent) return unless target if target.service_worker? - detach_unless_manually_attached(target, session_id) + detach_unless_manually_attached(target, session_id, parent) elsif target.worker? || target.shared_worker? connect_worker(target) else @@ -247,21 +247,17 @@ def add_extensions_and_resume(session_id, waiting) # we don't track: Chrome's own `browser_ui` targets, for instance. We # never resume those, so left alone they stay attached and paused for # the lifetime of the browser. Resume them and drop the session. - def detach_untracked(session_id) - detach(session_id) - rescue BrowserError, TimeoutError - nil + def detach_untracked(session_id, parent) + silent_detach(session_id, parent) end # Attaching keeps a service worker alive forever, so unless the caller # explicitly asked to connect to it (via Context#attach_target), we # just resume it and let go. - def detach_unless_manually_attached(target, session_id) + def detach_unless_manually_attached(target, session_id, parent) return if @manually_attached.delete(target.id) - detach(session_id) - rescue BrowserError, TimeoutError - nil + silent_detach(session_id, parent) end # Workers have no events to notify us when they're ready, so we @@ -277,9 +273,13 @@ def resume(session_id) @client.session(session_id).command("Runtime.runIfWaitingForDebugger", async: true) end - def detach(session_id) - resume(session_id) - @client.command("Target.detachFromTarget", sessionId: session_id) + def silent_detach(session_id, parent) + Utils::Thread.spawn do + @client.session(session_id).command("Runtime.runIfWaitingForDebugger") + parent.command("Target.detachFromTarget", sessionId: session_id) + rescue Error + nil + end end def discover diff --git a/sig/ferrum/contexts.rbs b/sig/ferrum/contexts.rbs index 3da8c009..7e5a82ad 100644 --- a/sig/ferrum/contexts.rbs +++ b/sig/ferrum/contexts.rbs @@ -47,11 +47,11 @@ module Ferrum def rearm_auto_attach: (String session_id, String type) -> void - def handle_attach: (Target? target, String session_id, Hash[String, untyped] params) -> void + def handle_attach: (Target? target, String session_id, Hash[String, untyped] params, (Client | SessionClient) parent) -> void - def detach_untracked: (String session_id) -> void + def detach_untracked: (String session_id, (Client | SessionClient) parent) -> void - def detach_unless_manually_attached: (Target target, String session_id) -> void + def detach_unless_manually_attached: (Target target, String session_id, (Client | SessionClient) parent) -> void def connect_worker: (Target target) -> void @@ -59,7 +59,7 @@ module Ferrum def resume: (String session_id) -> void - def detach: (String session_id) -> void + def silent_detach: (String session_id, (Client | SessionClient) parent) -> Thread def discover: () -> void diff --git a/spec/worker_spec.rb b/spec/worker_spec.rb index 9c96fa91..26e3cce1 100644 --- a/spec/worker_spec.rb +++ b/spec/worker_spec.rb @@ -83,6 +83,17 @@ def wait_for_target(&block) expect(browser.service_workers).to contain_exactly(target) end + it "detaches from them so Chrome can terminate them" do + page.go_to + page.evaluate_async(%(navigator.serviceWorker.register("/sw.js").then(arguments[0])), 5) + wait_for_target(&:service_worker?) + + wait_for do + browser.client.command("Target.getTargets")["targetInfos"] + .select { |info| info["type"] == "service_worker" }.map { |info| info["attached"] } + end.to eq([false]) + end + it "connects on demand through Context#attach_target, keeping it alive" do page.go_to page.evaluate_async(%(navigator.serviceWorker.register("/sw.js").then(arguments[0])), 5)