In February 2026 I moved an invoicing app's deploys to a new deploy tool, and my notes from that week say every PDF it tried to make failed. The app renders each invoice as a web page and has headless Chrome print it, through Ferrum and a thin Rails wrapper around it, ferrum_pdf. Nothing failed quickly. The background job that builds finished invoices wraps the render in its own sixty-second timer, which cuts in before Ferrum's. The draft-invoice preview has no timer of its own, so it waits out Ferrum's ninety seconds and then raises the error I wrote down that week, a Ferrum::ProcessTimeoutError saying Browser did not produce websocket url. A minute or more of nothing, and an error that named no cause.
I chased the plausible causes first, and the fix commit still carries their marks. It changes how the app caches its IP rules. It sets a base-URL variable the new setup didn't have. It raises the container's memory limit from 512 MB to 1,280 MB. It removes three Chrome flags: single-process, disable-logging and disable-dev-tools. It also adds a 161-line rake task that walks the PDF pipeline one layer at a time, in six steps from system information to a real invoice template, with a stylesheet check in between. Step two is the one that matters here:
# …
begin
start = Time.now
browser = Ferrum::Browser.new(ferrum_config)
elapsed = (Time.now - start).round(2)
puts " Ferrum started (#{elapsed}s)"
puts " WS URL: #{browser.process.ws_url}"
browser.quit
rescue => e
elapsed = (Time.now - start).round(2)
puts " FAILED (#{elapsed}s): #{e.class}: #{e.message}"
puts "\nDiagnostic aborted - cannot continue without browser."
exit 1
end
It isolates the layer, but on failure it prints the exception's class and message and nothing else. Hold on to that. None of the changes above was the cause. The cause was one word in the same diff.
Every flag failed
My notes from that week describe a flag hunt, but its script was never committed, so I rebuilt the failure and ran it again. The rebuild uses the Chromium 144 build Debian's security archive was serving on 12 February 2026, the fix's ferrum and ferrum_pdf versions, the initializer's production branch as it stood on the morning of the fix, and the production image's non-root user, in Docker on an Apple-silicon Mac. That is not the production host, so every timing below is mine. The hunt keeps three flags as a baseline, adds the other 25 in groups of four, then tries each flag on its own inside any group that fails:
# config.browser_args | baseline: no-sandbox, disable-dev-shm-usage, disable-gpu | 25 suspects | process_timeout=3
baseline alone FAIL ProcessTimeoutError 3.01s
group 1 (disable-setuid-sandbox disable-software-rasterizer disable-w) FAIL ProcessTimeoutError 3.01s
# …
group 7 (use-mock-keychain ) FAIL ProcessTimeoutError 3.02s
individually, within failing groups:
+ --disable-setuid-sandbox FAIL ProcessTimeoutError 3.01s
# …
+ --disable-logging FAIL ProcessTimeoutError 3.01s
+ --disable-dev-tools FAIL ProcessTimeoutError 3.01s
# …
summary: 7 of 7 groups failed; individual: 25 FAIL
Every group failed, and so did every flag on its own. Even the baseline failed, with no suspects in it at all. When taking any one thing away changes nothing, the thing you are taking away isn't there. With the key corrected, the same hunt passes at every step, baseline included, in 0.24 to 0.34 seconds. On this build no flag is poisonous.
So I looked at what Ferrum actually launched. This is the live process list a moment after launch, one flag per line:
$ ps -o pid,stat,args -ww # sampled 0.00s after launch
PID 23 STAT S
/bin/sh /usr/bin/chromium
--headless
--hide-scrollbars
# …
--remote-debugging-port=0
--remote-debugging-address=127.0.0.1
--window-size=1920,1080
# …
flag count: 39 | --no-sandbox: ABSENT
same PID 1s later: STAT Z [chromium] <defunct>
That is the command line Ferrum handed to Debian's chromium launcher script, which adds eight flags of its own before Chrome proper starts. All 39 are Ferrum's: its 35 defaults plus the debugging port, the address, the window size and a profile directory. Thirteen of my 28 flags are in there anyway, exactly as I wrote them, because Ferrum sends them by default. --no-sandbox is not one of them, and that is deliberate. In Ferrum's defaults it sits commented out, with a note:
"disable-blink-features" => "AutomationControlled"
# NOTE: --no-sandbox is not needed if you properly set up a user in the container.
# …
# "no-sandbox" => nil,
}.freeze
With the right key, the same launch carries 53 flags, --no-sandbox among them, and the process is still alive a second later.
The wire
Here is what the production branch of config/initializers/ferrum_pdf.rb said on the morning of the fix, before it landed:
elsif Rails.env.production? || Rails.env.staging?
# Progressive timeouts for ARM64 Docker environments
config.browser_timeout = 120 # Increased to match max job timeout
config.browser_wait_timeout = 60 # Doubled for better reliability
config.process_timeout = 90 # Extended for reliable Chrome startup
config.timeout = 120 # Global ferrum timeout matching max retry
# Optimized Chrome args for ARM64 Docker reliability with better memory management
config.browser_args = {
# Essential Docker flags
"no-sandbox" => nil,
"disable-dev-shm-usage" => nil,
"disable-setuid-sandbox" => nil,
The wrapper keeps its settings in an ActiveSupport::OrderedOptions: a hash whose keys you read and write as methods, with no list of allowed names:
mattr_accessor :config, default: ActiveSupport::OrderedOptions.new.merge(
window_size: [ 1920, 1080 ],
page_options: ActiveSupport::OrderedOptions.new,
pdf_options: ActiveSupport::OrderedOptions.new,
screenshot_options: ActiveSupport::OrderedOptions.new
)
So config.browser_args = {…} is stored without error. When the wrapper starts a browser, it peels off the three keys it uses itself and hands everything else to Ferrum, unchecked:
@@browser ||= Ferrum::Browser.new(config.except(:page_options, :pdf_options, :screenshot_options))
Ferrum reads its Chrome flags from exactly one key, and an absent key is an empty hash:
@browser_options = @options.fetch(:browser_options, {}).freeze
Ferrum doesn't complain about keys it doesn't know either. I asked both layers directly:
$ config.browser_args = {...} # OrderedOptions accepts it:
=> Hash, 28 entries, no error, no warning
# …
$ keys the app forwarded that Ferrum never reads: browser_timeout, browser_wait_timeout, browser_args
$ Ferrum::Browser::Options.new(... anything_at_all: 42) => no ArgumentError; browser_options = {}
Three of the keys I set were read by nothing at all: the flags, and two of the four timeouts. Until the fix, every revision of the production branch that had flags sent them to the same wrong key:
| Initializer revision | Flags written | Flags this config delivered | process_timeout |
|---|---|---|---|
| 16 Aug 2025, evening | 21 | 0 | unset (Ferrum's default, 10) |
| 16 Aug 2025, 11 minutes later | 36 | 0 | 30 |
| 27 Aug 2025 | 17 | 0 | 60 |
| 24 Sep 2025 | 28 | 0 | 90 |
| 17 Nov 2025 | 28 | 0 | 90 |
| 13 Feb 2026, the fix | 25 | 25 | 90 |
Across those revisions the list went from 21 flags to 36, then 17, then 28, and none of them reached Chrome through my config. Meanwhile browser_timeout went from 20 to 120, read by nothing. Of the timeouts, the one that mattered was process_timeout, and all it decided was how long Ferrum would wait.
The wrong word was mine, not the gem's. The wrapper's README, both the version I installed in August and the one I ran in February, shows config.browser_options = { "no-sandbox" => true } for Docker. browser_args was probably carried over from the previous PDF library, whose configuration called Chrome's flags args. It isn't a typo of the right key. It is a different word that sounds just as likely to be right.
The decisive change was that word, in both branches of the initializer:
- config.browser_args = {
+ config.browser_options = {
The fix commit did more, but the rebuild shows the word alone is enough: with the right key, all 28 original flags and the old 512 MB limit, the PDF comes out in 0.55 seconds. My page there is a one-heading invoice, so all I'll claim is that the fix didn't need the extra memory.
Why February
Which leaves the awkward question. browser_args arrived on 16 August 2025 and the fix landed on 13 February 2026, so it sat in the file for 181 days. Why did it only break in February?
Because of a deploy script. Before the move, the app went out through a hand-rolled script whose docker run had carried two extra options since June 2025, under a comment that names Chrome and PDF generation:
# Add Chrome/Chromium specific flags for PDF generation
# …
--memory=2g \
--memory-swap=4g \
--cap-add=SYS_ADMIN \
--security-opt seccomp=unconfined \
Docker's default seccomp profile denies the system calls that create new namespaces unless the container holds CAP_SYS_ADMIN, and Chrome's Linux sandbox enters new PID and network namespaces. Those two lines let Chrome build its sandbox, so it never needed the --no-sandbox that wasn't arriving. In the rebuild, the wrong key with both options renders a PDF in 0.27 seconds. Either option alone was enough on my machine (0.35 seconds with only the seccomp option, 0.32 with only SYS_ADMIN), though I wouldn't promise that on every host kernel.
On 7 February 2026 I committed the move to the new deploy tool, and the old script went with it. Its config asked for a memory limit and nothing else:
servers:
web:
hosts:
- <%= ENV[...] %>
options:
memory: 512m
Chrome was back under Docker's defaults. The bug didn't arrive in February: it had been in the file for 175 days, and with the commit of 7 February it lost its cover. I can't show that every PDF was healthy for those 175 days. What I can show is that, in my rebuild, the wrong key could not stop Chrome from starting while those two lines were there.
I'm not recommending them. SYS_ADMIN and an unconfined seccomp profile relax the whole container so that one browser can build its sandbox. The fix gets --no-sandbox to Chrome instead, which is what the wrapper's README shows for Docker, with the caveat that you should trust any sites visited. The same README recommends a Chrome seccomp profile over --no-sandbox for security, and that is the better place to end up.
The hang that was a wait
A correction, and this one is on me. My notes from February say Chromium on ARM64 hangs while it sets up its sandbox, instead of crashing. Rebuilt with the same Debian Chromium 144 build as the hunt, on arm64, as the same non-root user, it doesn't hang:
# Process.spawn(chromium + 39 flags), --no-sandbox ABSENT
0.040s Failed to move to new namespace: PID namespaces supported, Network namespace supported, but failed: errno = Operation not permitted
0.040s [8:8:…:FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc:207] Check failed: . : Operation not permitted (1)
0.105s [27:27:…:ERROR:content/zygote/zygote_linux.cc:673] write: Broken pipe (32)
0.107s EXIT: chromium exited (signal 6); pipe hit EOF
Spawned directly, it aborts in about a tenth of a second. Launched through Ferrum it does the same: polled every couple of milliseconds, the process Ferrum spawned is a zombie 0.107 seconds in. And Ferrum's note about setting up a user doesn't cover this: the rebuild runs as a non-root user and still can't create the namespaces.
So the ninety seconds were Ferrum's. It points Chrome's output at a pipe and keeps the pipe's write end open in its own process until it is done:
read_io, write_io = IO.pipe
process_options = { in: File::NULL }
process_options[:pgroup] = true unless Utils::Platform.windows?
process_options[:out] = process_options[:err] = write_io
# …
@pid = ::Process.spawn(env, *@command.to_a, process_options)
ObjectSpace.define_finalizer(self, self.class.process_killer(@pid))
parse_ws_url(read_io, @process_timeout)
Then it waits for Chrome to print its debugging address:
while (now = Utils::ElapsedTime.monotonic_time) < max_time
begin
output += read_io.read_nonblock(512)
rescue IO::WaitReadable
read_io.wait_readable(max_time - now)
else
if output.match(regexp)
self.ws_url = output.match(regexp)[1].strip
break
end
end
end
return if ws_url
@logger&.puts(output)
raise ProcessTimeoutError.new(timeout, output)
A pipe reports end-of-file only when every write end is closed. Chrome's copy closed when Chrome died. Ferrum's didn't, so wait_readable never sees end-of-file, and the loop checks the clock and the pattern but never whether the process is still alive. It waits the full process_timeout for a line from a process that is already gone.
With production's process_timeout of 90 seconds and the wrong key, the rebuild waited 90.03 seconds on a process that lived for 0.107, about 840 times longer than Chrome's whole life. With the right key, the same run rendered the PDF in 0.37 seconds. The message suggests raising :process_timeout, which here only makes the wait longer.
Where Ferrum reached the end of its own wait, in the draft preview and the diagnostic task, Chrome's own explanation (in the rebuild, Failed to move to new namespace, then a FATAL Operation not permitted) was inside the exception the whole time, in ProcessTimeoutError#output, and nothing in the app printed it. Ferrum would have written it to a logger:, had the app passed one. On the background job's path, rebuilt the same way, the job's own sixty-second timer fires first, so the error is Ruby's plain execution expired, and Chrome's output goes with it.
What I would add
A few lines at boot would have named all three dead keys before the first request. This is the guard I ran in the rebuild, against ferrum 0.17.1:
# config/initializers/ferrum_pdf.rb, after FerrumPdf.configure
ferrum_keys = %w[base_url browser_name browser_options browser_path env extensions flatten headless host
ignore_default_browser_options incognito js_errors logger pending_connection_errors port
process_timeout proxy save_path slowmo timeout url window_size ws_max_receive_size ws_url xvfb]
own_keys = %w[page_options pdf_options screenshot_options]
unknown = FerrumPdf.config.keys.map(&:to_s) - ferrum_keys - own_keys
raise ArgumentError, "FerrumPdf config keys nobody reads: #{unknown.join(', ')}" if unknown.any?
guard: ArgumentError: FerrumPdf config keys nobody reads: browser_timeout, browser_wait_timeout, browser_args
That list is Ferrum 0.17.1's. Version 0.17.2 adds dockerize and 0.18.0 adds protocol_timeout, so a hard-coded list starts rejecting valid keys after an upgrade. Derive it in a spec instead, as the rebuild does by scanning Ferrum's options.rb for the keys it reads. The second check is the one the ps listing made: build the command line Ferrum will run and check it. The rebuild uses Ferrum's own command builder, so pin that to the version you run:
forwarded = FerrumPdf.config.except(:page_options, :pdf_options, :screenshot_options)
# …
opts = Ferrum::Browser::Options.new(forwarded)
# …
cmd = Ferrum::Browser::Command.build(opts, "/tmp/probe").to_a
puts "planned command line: #{cmd.size - 1} flags; --no-sandbox #{cmd.include?('--no-sandbox') ? 'PRESENT' : 'ABSENT'}"
The third is the cheapest: when you rescue Ferrum::ProcessTimeoutError, log e.output, not just e.message. Newer Ferrum has a dockerize: option that adds --no-sandbox for you, but 0.18.0 still accepts unknown keys silently and still waits out process_timeout on a dead process.
The wrong key was mine, not either gem's; the README had the right key all along. What it shows is a shape that turns up wherever a wrapper forwards options it never checks to a library that ignores names it doesn't know: two layers that each accept anything, so a plausible wrong word is exactly as valid as the right one. For nearly six months a deploy script happened to cover for that word, and when the cover went, a launcher that never checks whether its child is alive turned a tenth-of-a-second crash into ninety seconds of silence. Check the command line your process actually got, not the one you configured. Print the output your errors carry. And when something hangs, ask what is waiting, and on whom.