Skip to content

fix(browser): let quit clean up a browser whose contexts were never built - #634

Open
PhilMeyr wants to merge 1 commit into
rubycdp:mainfrom
PhilMeyr:fix/quit-nil-contexts
Open

fix(browser): let quit clean up a browser whose contexts were never built#634
PhilMeyr wants to merge 1 commit into
rubycdp:mainfrom
PhilMeyr:fix/quit-nil-contexts

Conversation

@PhilMeyr

@PhilMeyr PhilMeyr commented Sep 4, 2026

Copy link
Copy Markdown

Problem

Browser#quit guards on @client but then calls contexts.close_connections unconditionally:

def quit(wait: true)
  return unless @client

  contexts.close_connections

  @client.close
  thread = @process.stop(wait: wait)
  @client = @process = @contexts = nil
  thread
end

start can leave the browser with exactly the state that guard doesn't cover — @client set and @contexts still nil:

@client = Client.new(@process.ws_url, options)
@contexts = Contexts.new(@client)
rescue StandardError
  @process.stop
  raise
end

Contexts.new sends CDP commands (find_implicit_context, subscribe, auto_attach, discover), so a browser that dies right after the websocket handshake raises there, after @client was assigned. The rescue stops the process and re-raises without clearing the ivars.

That state is unrecoverable. restart is quit; start, so the next restart enters quit, passes return unless @client, and raises NoMethodError: undefined method 'close_connections' for nil before reaching the line that resets the ivars. Every subsequent restart fails the same way, on the same object, forever.

We hit this in production: Chrome started, aborted a moment later, and the long-lived browser singleton in our PDF worker was wedged until the process was restarted by hand. Downstream code that reasonably checks browser.client before restarting sees a non-nil client and never even attempts recovery.

Fix

Safe-navigate the contexts call. quit then does the right thing for a half-started browser: @client.close releases the websocket and its thread, @process.stop reaps the process, and the ivars are cleared, so the following start runs on a clean object.

This is deliberately the smaller of the two possible fixes. Clearing @client/@contexts in start's rescue instead would leave the client unclosed — letting quit handle it keeps the existing cleanup path responsible for cleanup.

The adjacent page&.close / context&.dispose in create_page guard the same way, for the same reason (#582).

Regression spec

Added to #restart: stub Contexts.new to raise, let one restart fail, un-stub, and assert the browser restarts. Without the fix it fails with

expected no Exception, got #<NoMethodError: undefined method 'close_connections' for nil>
  ./lib/ferrum/browser.rb:252:in 'Ferrum::Browser#quit'
  ./lib/ferrum/browser.rb:234:in 'Ferrum::Browser#restart'

bundle exec rspec spec/browser_spec.rb — 52 examples, 0 failures, 3 pending. Rubocop clean.

…uilt

When start raises after Client.new but before Contexts.new returns, the
browser keeps a non-nil @client and a nil @contexts. quit passes its
@client guard, then calls contexts.close_connections and dies with
NoMethodError before resetting any state, so restart can never recover.

Guarding with safe navigation lets quit close the client, stop the
process and clear the ivars, which is exactly what restart needs.
@route

route commented Sep 4, 2026

Copy link
Copy Markdown
Member

I think we need to use attr_reader vs ivar and browser_spec is for systems spec, not unit tests

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants