Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 23 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,23 +212,38 @@ RUN apt-get update && apt-get install gnupg wget -y && \
rm -rf /var/lib/apt/lists/*
```

### Multiple Browser Support
### Browser Management

FerrumPdf uses a single browser instance per Ruby process that is automatically created when needed using your configuration settings:

```ruby
# Create two browsers using the FerrumPdf config, but overriding `window_size`
FerrumPdf.add_browser(:small, window_size: [1024, 768]))
FerrumPdf.add_browser(:large, window_size: [1920, 1080]))
# Browser is auto-created on first use
FerrumPdf.render_pdf(url: "https://example.org")
```

FerrumPdf.render_pdf(url: "https://example.org", browser: :small)
FerrumPdf.render_pdf(url: "https://example.org", browser: :large)
You can also set a custom browser instance:

```ruby
# Set a custom browser with specific options
custom_browser = Ferrum::Browser.new(window_size: [800, 600], headless: false)
FerrumPdf.browser = custom_browser

# All subsequent calls will use this browser
FerrumPdf.render_pdf(url: "https://example.org")
```

You can also create a `Ferrum::Browser` instance and pass it in as `browser`:
To safely shut down the browser process:

```ruby
FerrumPdf.render_pdf(url: "https://example.org", browser: Ferrum::Browser.new)
# This will quit the current browser and set it to nil
FerrumPdf.browser = nil

# Next call will auto-create a new browser
FerrumPdf.render_pdf(url: "https://example.org")
```

**Thread Safety**: FerrumPdf is thread-safe within a single Ruby process. Multiple threads can safely use FerrumPdf concurrently, and they will share the same Chrome browser instance. However, each Ruby worker process will have its own separate Chrome instance.

## Debugging

One option for debugging is to use Chrome in regular, non-headless mode:
Expand Down
64 changes: 38 additions & 26 deletions lib/ferrum_pdf.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,39 @@ module FerrumPdf
autoload :Controller, "ferrum_pdf/controller"
autoload :HTMLPreprocessor, "ferrum_pdf/html_preprocessor"

mattr_accessor :browser_mutex, default: Mutex.new
mattr_accessor :include_assets_helper_module, default: true
mattr_accessor :include_controller_module, default: true
mattr_accessor :browsers, default: {}
mattr_accessor :config, default: ActiveSupport::OrderedOptions.new.merge(
window_size: [ 1920, 1080 ]
)

# This doesn't use mattr_accessor because having a `.browser` getter and also
# having local variables named `browser` would be confusing. For simplicity,
# this is explicitly accessed.
@@browser = nil

class << self
def configure
yield config
end

# Creates and registers a new browser for exports
# If a browser with the same name already exists, it will be shut down before instantiating the new one
def add_browser(name, **options)
@@browsers[name].quit if @@browsers[name]
@@browsers[name] = Ferrum::Browser.new(@@config.merge(options))
# Sets the browser instance to use for all operations
# If a browser is already set, it will be shut down before setting the new one
def browser=(browser_instance)
browser_mutex.synchronize do
@@browser&.quit
@@browser = browser_instance
end
end

# Provides thread-safe access to the browser instance
def with_browser
browser_mutex.synchronize do
@@browser ||= Ferrum::Browser.new(config)
@@browser.restart unless @@browser.client.present?
yield @@browser
end
Comment on lines +43 to +48
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def with_browser
browser_mutex.synchronize do
@@browser ||= Ferrum::Browser.new(config)
@@browser.restart unless @@browser.client.present?
yield @@browser
end
def with_browser(browser=nil)
if browser.present?
yield browser
else
browser_mutex.synchronize do
@@browser ||= Ferrum::Browser.new(config)
@@browser.restart unless @@browser.client.present?
yield @@browser
end
end

If we want to support the browser: nil option for render_pdf to let users pass in their own separate browser, we could do something like this.

end

# Renders HTML or URL to PDF
Expand Down Expand Up @@ -68,36 +84,32 @@ def render_screenshot(screenshot_options: {}, **load_page_args)
#
# This automatically applies HTML preprocessing if `html:` is present
#
def load_page(url: nil, html: nil, base_url: nil, authorize: nil, wait_for_idle_options: nil, browser: :default, retries: 1)
def load_page(url: nil, html: nil, base_url: nil, authorize: nil, wait_for_idle_options: nil, retries: 1)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should keep the browser: option, just in case someone needs to provide a separate browser instance.

That wouldn't need to quit the existing browser like reassigning the default browser would.

try = 0

# Lookup browser if a name was passed
browser = @@browsers[browser] || add_browser(browser) if browser.is_a? Symbol
with_browser do |browser|
# Closes page automatically after block finishes
# https://github.com/rubycdp/ferrum/blob/main/lib/ferrum/browser.rb#L169
browser.create_page do |page|
page.network.authorize(**authorize) { |req| req.continue } if authorize

# Automatically restart the browser if it was disconnected
browser.restart unless browser.client.present?
# Load content
if html
page.content = FerrumPdf::HTMLPreprocessor.process(html, base_url)
else
page.go_to(url)
end

# Closes page automatically after block finishes
# https://github.com/rubycdp/ferrum/blob/main/lib/ferrum/browser.rb#L169
browser.create_page do |page|
page.network.authorize(**authorize) { |req| req.continue } if authorize
# Wait for everything to load
page.network.wait_for_idle(**wait_for_idle_options)

# Load content
if html
page.content = FerrumPdf::HTMLPreprocessor.process(html, base_url)
else
page.go_to(url)
yield browser, page
end

# Wait for everything to load
page.network.wait_for_idle(**wait_for_idle_options)

yield browser, page
end
rescue Ferrum::DeadBrowserError
try += 1
if try <= retries
browser.restart
with_browser(&:restart)
retry
else
raise
Expand Down
52 changes: 49 additions & 3 deletions test/ferrum_pdf_test.rb
Original file line number Diff line number Diff line change
@@ -1,21 +1,67 @@
require "test_helper"

class FerrumPdfTest < ActiveSupport::TestCase
def teardown
# Reset browser state after each test to avoid flaky behavior
FerrumPdf.browser = nil
end

test "it has a version number" do
assert FerrumPdf::VERSION
end

test "re-registering browser shuts down previous browser" do
first_browser = FerrumPdf.add_browser :example
test "setting new browser replaces previous browser" do
first_browser = Ferrum::Browser.new
first_pid = first_browser.process.pid
FerrumPdf.browser = first_browser

second_browser = FerrumPdf.add_browser :example
second_browser = Ferrum::Browser.new
second_pid = second_browser.process.pid
FerrumPdf.browser = second_browser

# First browser should be shut down
assert_nil first_browser.process

# Second browser should have a different Process ID
assert_not_equal first_pid, second_pid

FerrumPdf.with_browser do |yielded_browser|
assert_same second_browser, yielded_browser
end
end

test "auto-creates browser when none is set" do
FerrumPdf.browser = nil

FerrumPdf.with_browser do |browser|
assert_instance_of Ferrum::Browser, browser
assert browser.client.present?
end
end

test "auto-created browser uses config settings" do
original_config = FerrumPdf.config.dup
FerrumPdf.config.window_size = [ 800, 600 ]

FerrumPdf.browser = nil

FerrumPdf.with_browser do |browser|
assert_instance_of Ferrum::Browser, browser
assert_equal [ 800, 600 ], browser.options.window_size
end
ensure
FerrumPdf.config.replace(original_config)
end

test "reuses same browser instance across multiple with_browser calls" do
FerrumPdf.browser = nil

first_call_browser = nil
second_call_browser = nil

FerrumPdf.with_browser { |browser| first_call_browser = browser }
FerrumPdf.with_browser { |browser| second_call_browser = browser }

assert_same first_call_browser, second_call_browser
end
end