Capybara: Preventing headless Chrome from freezing your test suite

Posted . Visible to the public.

We prefer to run our end-to-end tests with headless Chrome. While it's a very stable solution overall, we sometimes see the headless Chrome process freeze (or the Capybara driver losing connection, we're not sure).

The effect is that your test suite suddenly stops progressing without an error. You will eventually see an error after a long timeout but until then it will seem that your suite is frozen. If you're also using capybara-screenshot Show archive.org snapshot you will sit out that timeout twice, as capybara-screen again tries to communicate with the dead Chrome process.

Workaround

The code below will address the issue in two ways:

  • It will close the Chrome process after groups of 50 tests, making the entire issue very unlikely. Capybara will automatically start a new process for the next test.
  • In case Chrome communication breaks down, it will immediately raise an error instead of waiting for a long timeout.

The code expands on work by Tobias Kraze Show archive.org snapshot .

Include the module

Include the following StabilizeHeadlessChrome module in your tests:

class StabilizeHeadlessChrome
  class Reaper
    MAX_SCENARIOS_PER_DRIVER = 50

    class << self

      def driver_scenario_count
        @driver_scenario_count || 0
      end

      attr_writer :driver_scenario_count

      def browser_died?
        !!@browser_died
      end

      attr_writer :browser_died

      def scenario_started
        self.browser_died = false
      end

      def scenario_ended
        return unless session.driver.is_a?(::Capybara::Selenium::Driver)

        self.driver_scenario_count += 1

        if !browser_died? && driver_scenario_count >= MAX_SCENARIOS_PER_DRIVER
          kill_session!
        end
      end

      def browser_died!
        self.browser_died = true
        kill_session!
      end

      def kill_session!
        self.driver_scenario_count = 0
        # This closes the browser
        session.quit
      end

      private

      def session
        ::Capybara.current_session
      end

    end
  end

  class CustomHttpClient < Selenium::WebDriver::Remote::Http::Default

    def initialize(open_timeout: 10, read_timeout: 60)
      super
    end

    private

    def response_for(request)
      if Reaper.browser_died?
        raise 'Waiting for session to restart'
      else
        super
      end
    rescue Net::OpenTimeout, Net::ReadTimeout
      # Chrome just died on us. We will reset the session, which causes Chrome to restart.
      # In this case, capturing a screenshot will not work for this scenario.
      warn "\n\e[31mKilling Capybara session!\e[0m\n"
      ::Reaper.browser_died!
      raise
    end
  end
end

Call the reaper

To restart the Chrome process after 50 tests, Now call StabilizeHeadlessChrome::Reaper when your test starts and ends.

E.g. in Cucumber:

Before do
  StabilizeHeadlessChrome::Reaper.scenario_started
end

After do
  StabilizeHeadlessChrome::Reaper.scenario_ended
end

Adjust driver registration

To get early errors (instead of long timeouts) you need to pass a new :http_client option when registering a new Capybara driver.

Your old driver registration looks like this:

Capybara.register_driver :selenium do |app|
  ...
  Capybara::Selenium::Driver.new(app,
    browser: :chrome,
    options: options
   )
end

It now needs to look like this:

Capybara.register_driver :selenium do |app|
  ...
  Capybara::Selenium::Driver.new(app,
    browser: :chrome,
    options: options,
    http_client: StabilizeHeadlessChrome::CustomHttpClient.new
   )
end
Henning Koch
Last edit
Henning Koch
License
Source code in this card is licensed under the MIT License.
Posted by Henning Koch to makandra dev (2021-03-10 16:05)