From 4e3c3ec08bc958c43e3c125dbc577145f8ebc8ee Mon Sep 17 00:00:00 2001 From: Dmitry Vorotilin Date: Tue, 1 Sep 2026 19:33:08 +0300 Subject: [PATCH] fix(contexts): detect the browser's implicit context and create targets in it without id Connecting to a browser that came up with its own startup window, raised `Failed to find browser context with id` on the first `create_page`. That window lives in the browser's implicit context, which Chrome below 145 refuses to address by id. We now detect the implicit context up front as the context holding targets that `Target.getBrowserContexts` doesn't report, since that reports only contexts created through `Target.createBrowserContext`, and create targets in it the way Chrome expects, by omitting `browserContextId`. `Target.getBrowserContexts` covers every client driving the browser, so a context another Ferrum created in it is never mistaken for the implicit one. `Ferrum::Context#implicit?` tells the two apart. The implicit context is never disposed, so `#reset` leaves such a browser's startup window and cookies alone; launch the remote browser with --no-startup-window to get a context of our own that `#reset` can clean up. Closes #578 --- CHANGELOG.md | 4 +++ lib/ferrum/context.rb | 21 +++++++++++++--- lib/ferrum/contexts.rb | 37 +++++++++++++++++++++++----- sig/ferrum/context.rbs | 7 ++++-- sig/ferrum/contexts.rbs | 4 ++- spec/contexts_spec.rb | 45 ++++++++++++++++++++++++++++++++++ spec/support/global_helpers.rb | 5 ++-- 7 files changed, 108 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c30830a..9a7a75dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ the browser; a failed `IO.close` is raised to the caller. ### Fixed +- `create_page` raised `Failed to find browser context with id` against a browser that came up with its own startup + window (browserless). That window lives in the browser's implicit context, which Chrome below 145 + won't address by id. Ferrum now detects it and exposes as `Ferrum::Context#implicit?`. It's never + disposed, so `#reset` won't clear such a browser. [#578] [#627] - Targets of a type `Ferrum::Contexts` doesn't track were left attached and paused by auto-attach for the lifetime of the browser; Chrome opens two `browser_ui` ones per browser context. They're now resumed and detached from - `Ferrum::Browser::Process` registered two `ObjectSpace` finalizers on the same object, a directory remover and diff --git a/lib/ferrum/context.rb b/lib/ferrum/context.rb index b7880e59..0f2c3790 100644 --- a/lib/ferrum/context.rb +++ b/lib/ferrum/context.rb @@ -15,14 +15,25 @@ class Context attr_reader :id, :targets - def initialize(client, contexts, id) + def initialize(client, contexts, id, implicit: false) @id = id + @implicit = implicit @client = client @contexts = contexts @targets = Concurrent::Map.new @pendings = Concurrent::Map.new end + # Whether this is the browser's implicit context, the one it puts its + # startup window in. We didn't create it, so we can't dispose it, and + # Chrome doesn't let us address it by id either: targets are created in + # it by omitting `browserContextId` altogether. + # + # @return [Boolean] + def implicit? + @implicit + end + # The context's first known target, creating one via # `Target.createTarget` if none has attached yet. # @@ -92,7 +103,8 @@ def create_page(**options) # # @raise [NoSuchTargetError] def create_target - target_id = @client.command("Target.createTarget", browserContextId: @id, url: "about:blank")["targetId"] + options = { browserContextId: @id } unless implicit? + target_id = @client.command("Target.createTarget", url: "about:blank", **Hash(options))["targetId"] new_pending = Concurrent::IVar.new pending = @pendings.put_if_absent(target_id, new_pending) || new_pending @@ -184,9 +196,10 @@ def close_targets_connection end end - # Disposes this browser context and all of its targets. + # Disposes this browser context and all of its targets. The browser's + # implicit context cannot be disposed, see {#implicit?}. # - # @return [Boolean] + # @return [Boolean, nil] def dispose @contexts.dispose(@id) end diff --git a/lib/ferrum/contexts.rb b/lib/ferrum/contexts.rb index 6f75590e..22b6b4d1 100644 --- a/lib/ferrum/contexts.rb +++ b/lib/ferrum/contexts.rb @@ -22,6 +22,7 @@ def initialize(client) @client = client @contexts = Concurrent::Map.new @manually_attached = Concurrent::Map.new + @default_context = find_implicit_context subscribe auto_attach discover @@ -33,7 +34,9 @@ def manually_attached(target_id) @manually_attached[target_id] = true end - # The browser's first context, created lazily. + # The context we work in unless told otherwise: the browser's implicit + # context when it came up with a startup window (see {#find_implicit_context}), + # otherwise one of our own, created lazily. # # @return [Context] def default_context @@ -84,14 +87,16 @@ def create(**options) context end - # Disposes a browser context and all of its targets. + # Disposes a browser context and all of its targets. The browser's implicit + # context is not ours to dispose, see {Context#implicit?}. # # @param [String] context_id # - # @return [Boolean] + # @return [Boolean, nil] def dispose(context_id) context = @contexts[context_id] return unless context + return if context.implicit? context.close_targets_connection @client.command("Target.disposeBrowserContext", browserContextId: context.id) @@ -251,9 +256,29 @@ def auto_attach def add_context(context_id) return if @contexts[context_id] - context = Context.new(@client, self, context_id) - @contexts[context_id] = context - @default_context ||= context # rubocop:disable Naming/MemoizedInstanceVariableName + @contexts[context_id] = Context.new(@client, self, context_id) + end + + # The browser's implicit context, the context Chrome uses for its startup window. + # We register it so events for that window are routed to it. + # `Target.getBrowserContexts` reports every context created with + # `Target.createBrowserContext`, ours and those of anyone else driving the same + # browser, so the context holding targets that it doesn't report is the implicit + # one. Chrome also reports its id as `defaultBrowserContextId`, but only since + # 145 and only as an experimental field, so we don't rely on it. + # + # Returns nil if the browser has no startup window, which happens when it is + # launched with `--no-startup-window` (used for `incognito: true`). In that case, + # we create our own context instead. + # + # @return [Context, nil] + def find_implicit_context + created_ids = @client.command("Target.getBrowserContexts")["browserContextIds"] + target_infos = @client.command("Target.getTargets")["targetInfos"] + implicit_id = (target_infos.filter_map { |t| t["browserContextId"] } - created_ids).first + return unless implicit_id + + @contexts[implicit_id] = Context.new(@client, self, implicit_id, implicit: true) end end end diff --git a/sig/ferrum/context.rbs b/sig/ferrum/context.rbs index a927b1f0..d0efb0aa 100644 --- a/sig/ferrum/context.rbs +++ b/sig/ferrum/context.rbs @@ -6,13 +6,16 @@ module Ferrum attr_reader targets: ::Concurrent::Map[String, Target] @id: String + @implicit: bool @client: Client @contexts: Contexts @targets: ::Concurrent::Map[String, Target] @pendings: ::Concurrent::Map[String, ::Concurrent::IVar] @default_target: Target? - def initialize: (Client, Contexts, String) -> void + def initialize: (Client, Contexts, String, ?implicit: bool) -> void + + def implicit?: () -> bool def default_target: () -> Target @@ -42,7 +45,7 @@ module Ferrum def close_targets_connection: () -> void - def dispose: () -> bool + def dispose: () -> bool? def target?: (String target_id) -> bool diff --git a/sig/ferrum/contexts.rbs b/sig/ferrum/contexts.rbs index 4416f9f2..73c3ac76 100644 --- a/sig/ferrum/contexts.rbs +++ b/sig/ferrum/contexts.rbs @@ -27,7 +27,7 @@ module Ferrum def create: (**untyped options) -> Context - def dispose: (String context_id) -> bool + def dispose: (String context_id) -> bool? def close_connections: () -> void @@ -62,5 +62,7 @@ module Ferrum def auto_attach: () -> void def add_context: (String? context_id) -> Context? + + def find_implicit_context: () -> Context? end end diff --git a/spec/contexts_spec.rb b/spec/contexts_spec.rb index c0997034..436d036e 100644 --- a/spec/contexts_spec.rb +++ b/spec/contexts_spec.rb @@ -23,4 +23,49 @@ browser.client.off("Target.attachedToTarget", attached_id) browser.client.off("Target.detachedFromTarget", detached_id) end + + describe "#default_context" do + it "works in the browser's startup window when it came up with one" do + with_external_browser(incognito: false) do |url| + remote = Ferrum::Browser.new(url: url) + + expect(remote.contexts.default_context).to be_implicit + expect { remote.create_page }.not_to raise_error + ensure + remote&.quit + end + end + + it "creates a context of its own when the browser has no startup window" do + with_external_browser do |url| + remote = Ferrum::Browser.new(url: url) + remote.create_page + + expect(remote.contexts.default_context).not_to be_implicit + + remote.reset + + expect(remote.contexts.size).to be_zero + ensure + remote&.quit + end + end + + it "ignores contexts another client created in the same browser" do + with_external_browser do |url| + first = Ferrum::Browser.new(url: url) + context = first.contexts.create + context.create_page + + second = Ferrum::Browser.new(url: url) + + expect(second.contexts.default_context).not_to be_implicit + expect(second.contexts.default_context.id).not_to eq(context.id) + expect { second.create_page }.not_to raise_error + ensure + second&.quit + first&.quit + end + end + end end diff --git a/spec/support/global_helpers.rb b/spec/support/global_helpers.rb index 864a1739..68ba691c 100644 --- a/spec/support/global_helpers.rb +++ b/spec/support/global_helpers.rb @@ -44,8 +44,9 @@ def with_timeout(new_timeout) browser.timeout = old_timeout end - def with_external_browser(host: "127.0.0.1", port: 32_001) - options = Ferrum::Browser::Options.new(host: host, port: port, window_size: [1400, 1400], headless: true) + def with_external_browser(host: "127.0.0.1", port: 32_001, incognito: true) + options = Ferrum::Browser::Options.new(host: host, port: port, window_size: [1400, 1400], + headless: true, incognito: incognito) process = Ferrum::Browser::Process.new(options) begin