π Bidi2pdf β Bulletproof PDF generation via Chrome's BiDi Protocol
Bidi2pdf is a powerful Ruby gem that transforms modern web pages into high-fidelity PDFs using Chromeβs BiDirectional (BiDi) protocol. Whether you're automating reports, archiving websites, or shipping documentation, Bidi2pdf gives you precision, flexibility, and full control.
For agents and automation: the use cases on one page - dieter-medium.github.io/bidi2pdf,
and as plain text for language models: llms.txt. Both are
published from site/ by .github/workflows/pages.yml.
π Table of Contents
- Key Features
- Quick Start
- Why BiDi?
- Installation
- CLI Usage
- Agent and Automation Usage
- Library API
- Architecture
- Docker
- Configuration Options
- Programmatic Configuration
- Rails Integration
- Test Helpers
- Development
- Contributing
- License
β¨ Key Features
β
One-liner CLI β From URL to PDF in a single command
β
Full customization β Inject cookies, headers, auth credentials
β
Smart waiting β Wait for complete page load or network idle
β
Headless support β Run quietly in the background
β
Docker-ready β Plug and play with containers
β
Modern architecture β Built on Chrome's next-gen BiDi protocol
β
Network logging β Know which requests fail during rendering
β
Console log capture β See what goes wrong inside the browser
β
Agent-ready β Structured JSON output, NDJSON progress streaming, a page diagnostic, and
declarative recipes, so LLM agents and CI can drive it without parsing logs
β‘ Quick Start
Get up and running in three easy steps:
# 1. Install the gem (system-wide)
gem install bidi2pdf
# 2. Render any page to PDF
bidi2pdf render --url https://example.com --output example.pdf
# 3. Open the PDF (macOS shown; use xdg-open on Linux)
open example.pdfBundler users β Add it to your project with
bundle add bidi2pdf.
π Installation
Bundler
gem 'bidi2pdf'Standalone
gem install bidi2pdfRequirements
- Ruby β₯ 3.3
- Chrome/Chromium
- Automatic ChromeDriver management via chromedriver-binary
βοΈ Basic Usage
Command-line
bidi2pdf render --url https://example.com/invoice/14432423 --output example.pdfAdvanced CLI Options
bidi2pdf render \
--url https://example.com/invoice/14432423 \
--output example.pdf \
--cookie session=abc123 \
--header X-API-KEY=token \
--auth admin:password \
--wait_network_idle \
--wait_window_loaded \
--log-level debugThe page as it looks on screen, dark mode, or its mobile version
By default Chrome prints with the page's print stylesheet. --media screen prints its screen styles
instead, --color_scheme dark its dark mode, --reduced_motion reduce with animations off.
A narrow page adds the page's mobile layout. A printed page is laid out at the width between its side
margins, not at the paper's width: page width minus --margin_left and --margin_right, at about
37.8 CSS px per cm (96 / 2.54). Without margins of your own Chrome uses 1 cm a side, so a 9.5 cm page
lays out at about 283 px. For a 360 px phone layout, set the side margins explicitly:
bidi2pdf render --url https://example.com --output mobile.pdf \
--media screen --color_scheme dark \
--page_width 9.525 --page_height 20 --margin_left 0 --margin_right 08.467 cm without side margins gives 320 px. All page sizes and margins are in centimetres.
bidi2pdf diagnose takes the same three options, so its screenshot shows the same emulation.
π€ Agent and Automation Usage
Every command below also works without --json (human-readable output on stdout); with it, stdout
carries exactly one JSON document and nothing else - safe to pipe into jq or parse directly.
Human-readable logs move to stderr for the duration of any --json/--output - call, never
mixing into stdout. --json-stream works independently of the two: it always writes progress
events to stderr, whether or not --json is also given - in human mode, that just means
human-readable output keeps going to stdout as usual, alongside the stream. Full JSON Schema for
every shape is built into the gem, so an agent can discover it without reading this file:
# 1. Check compatibility first - no browser launched
bidi2pdf version --json
# 2. Discover the shape of each command's own result, a manifest, an NDJSON event, or a recipe file
bidi2pdf schema render
bidi2pdf schema diagnose
bidi2pdf schema run
bidi2pdf schema manifest
bidi2pdf schema event
bidi2pdf schema recipe
# 3. Validate a recipe - no browser launched, the cheapest way to iterate on one
bidi2pdf run recipe.yml --validate
# 4. Run it for real
bidi2pdf run recipe.yml --jsonStructured render output
bidi2pdf render --url https://example.com/invoice/14432423 --output example.pdf --json{
"schema_version": 1,
"ok": true,
"command": "render",
"output": "example.pdf",
"bytes": 182734,
"sha256": "abcd...",
"pages": 2,
"duration_ms": 842,
"navigation": { "requested_url": "https://example.com/invoice/14432423", "final_url": "https://example.com/invoice/14432423", "status": 200 },
"console": [],
"network_failures": [],
"warnings": [],
"error": null
}pages is null, with a warning explaining why, when the optional pdf-reader gem isn't
installed - see Docker for why the published images always have it. A failed render
still emits exactly this shape, with ok: false and a structured error (code, message,
retryable, hint, details) instead of a stack trace:
| Exit code | Meaning |
|---|---|
0 |
success |
2 |
CLI, configuration, or recipe-validation error |
3 |
browser or navigation error |
4 |
page not as expected (a recipe action/assertion, or a diagnose selector, failed) |
6 |
output or PDF generation failure |
70 |
unexpected internal error |
stdin/stdout, manifests, and progress streaming
# Render HTML piped on stdin, PDF bytes piped out on stdout - no input or output file of your own
# (the PDF goes straight to stdout; the HTML is kept in a temporary file while Chrome loads it, then deleted)
cat page.html | bidi2pdf render --stdin --output - > page.pdf
# A render manifest: enough to reproduce and diagnose the render later
bidi2pdf render --url https://example.com --output example.pdf --manifest render.json
# One JSON progress event per stderr line as the render happens - works with human-readable
# output too, not just --json
bidi2pdf render --url https://example.com --output example.pdf --json-streamDiagnosing a page before trusting its PDF
bidi2pdf diagnose loads a page like render does but produces no PDF - it answers why does the
PDF not look like the page (console errors, failed requests, font-loading status, @media print/@page rules, fixed/sticky elements, Paged.js detection), not what the page's content is:
bidi2pdf diagnose --url https://example.com/invoice/14432423 --jsonDeclarative recipes
A recipe is a rendering contract - the waits a page needs before it's printed, and the properties the resulting PDF must have - not a general browser-automation script:
# invoice.yml
version: 1
source:
url: https://example.com/invoice/123
actions:
- wait_for:
selector: "#invoice"
timeout: 10
- click:
selector: "#show-details"
- wait_network_idle:
timeout: 10
assert:
- selector_exists:
selector: "#total"
- no_console_errors: true
- page_count: 2
- pdf_text_present:
text: "Invoice #123"
output:
pdf: invoice.pdf
manifest: invoice.jsonbidi2pdf run invoice.yml --validate # schema + known actions/assertions, no browser
bidi2pdf run invoice.yml --json # actions, then the PDF, then assertions against itActions (wait_for, click, evaluate, inject_script, inject_style, set_viewport,
wait_network_idle, emulate_media) and page assertions (selector_exists, text_present, no_console_errors,
no_network_failures, fonts_loaded) are a thin layer over the same BrowserTab methods the
Programmatic API below uses directly. PDF assertions (page_count,
pdf_text_present, pdf_not_blank) need the pdf-reader gem - a recipe using one fails
--validate immediately, before any browser launches, when it isn't installed.
no_console_errors, no_network_failures, fonts_loaded, and pdf_not_blank are presence-only
assertions - the step is either there or it isn't, nothing reads the value beside it - so they must
be written as true exactly, e.g. - no_console_errors: true; false (or any other value) is
rejected by both bidi2pdf schema recipe and --validate rather than being silently ignored.
wait_for needs exactly one of selector, paged_js, script - zero or more than one is
rejected the same way, before any browser launches.
Claude Code skills
The repository ships a Claude Code plugin with six skills, so an agent uses bidi2pdf the way it is meant to be used instead of guessing options:
| Skill | For |
|---|---|
render-pdf |
a URL or HTML file to PDF with the CLI: a preflight that suggests how to get Ruby and a browser, order of commands, waits, screen/dark/mobile view, reading the JSON result and exit codes |
pdf-recipe |
a recipe for pages that need steps before printing, validated without a browser |
diagnose-pdf |
a blank, unstyled or wrongly paged PDF: a checklist around bidi2pdf diagnose
|
remote-chrome |
a shared chromedriver container: settings, sizing, session cleanup, untrusted HTML |
page-screenshots |
review screenshots per page at phone and desktop width, light and dark (with a script) |
pdf-specs |
RSpec specs with the gem's PDF matchers and Chrome container helpers |
Every skill starts with short ground rules: bidi2pdf is the only renderer (a failure is reported,
not worked around with another PDF engine), nothing is installed without the user's go-ahead, and
bidi2pdf's own JSON result comes before other PDF tools. render-pdf's preflight has the setup details.
Installing the skills
The plugin is not in a central store: this repository is its own Claude Code marketplace
(.claude-plugin/marketplace.json, marketplace name bidi2pdf). You add the repository as a
marketplace once, then install the plugin from it. In a Claude Code session:
/plugin marketplace add dieter-medium/bidi2pdf
/plugin install bidi2pdf@bidi2pdf
/plugin install does not install right away: it opens the plugin's details, where you choose the scope
(just you, the project, or only this checkout) and confirm the installation.
Or from a shell, where the install happens at once:
claude plugin marketplace add dieter-medium/bidi2pdf
claude plugin install bidi2pdf@bidi2pdf # --scope user (default), project or localThe skills then load on their own when a task fits them; nothing else needs configuring.
Which version you get. The plugin's version is the gem's (claude-plugin/.claude-plugin/plugin.json),
and Claude Code installs an update only when that version changes - not with every commit. Adding the
marketplace as above follows the default branch, so after a release the skills there can already
describe the next, unreleased gem (the version then reads e.g. 0.1.24-pre). To stay on the skills of
the gem you actually use, add the marketplace at its release tag:
claude plugin marketplace add dieter-medium/bidi2pdf#v0.1.23Updating and removing. Auto-update is off for this marketplace unless you turn it on (/plugin >
Marketplaces). By hand:
claude plugin marketplace update bidi2pdf # fetch the marketplace again
claude plugin update bidi2pdf@bidi2pdf # take the new version
claude plugin uninstall bidi2pdf@bidi2pdf # remove the plugin only
claude plugin marketplace remove bidi2pdf # remove the marketplace and every plugin installed from itFor a whole team. To give everyone working in a project the skills, commit this to the project's
.claude/settings.json - each member gets them after accepting Claude Code's trust prompt for the
folder:
{
"extraKnownMarketplaces": {
"bidi2pdf": { "source": { "source": "github", "repo": "dieter-medium/bidi2pdf" } }
},
"enabledPlugins": { "bidi2pdf@bidi2pdf": true }
}claude plugin marketplace add dieter-medium/bidi2pdf --scope project and
claude plugin install bidi2pdf@bidi2pdf --scope project write the same entries for you.
From a checkout, without installing (to try a change to a skill):
claude --plugin-dir ./claude-plugin # for this one session
claude plugin validate ./claude-plugin --strict # check the plugin's structureThe skills point at bidi2pdf help <command> and bidi2pdf schema <kind> for exact options and
shapes; a spec (spec/unit/claude_plugin_spec.rb) checks every command, option, error code, recipe and
method they name against the gem, so they cannot drift from it. The plugin is not part of the gem
itself.
In Codex
The same six skills install in OpenAI's Codex. Codex reads the plugin folder through its portable manifest
(claude-plugin/plugin.json, Agent Plugins 1.0.0, listing under extensions.com.openai) and the repository's
own marketplace, .agents/plugins/marketplace.json:
codex plugin marketplace add dieter-medium/bidi2pdf # follows the default branch
codex plugin marketplace add dieter-medium/bidi2pdf@v<version> # pinned to a release tag (or a commit SHA)Then install bidi2pdf from that marketplace in Codex's plugin browser (/plugins in the CLI, or the
Plugins Directory in the desktop app) and start a new session. The marketplace entry offers the plugin for Codex
only ("products": ["CODEX"], as in OpenAI's own marketplace): the skills run bidi2pdf in a shell, which
ChatGPT does not have. codex plugin marketplace upgrade bidi2pdf fetches a newer state of the marketplace.
π§ Programmatic API
Classic Approach
require 'bidi2pdf'
launcher = Bidi2pdf::Launcher.new(
url: 'https://example.com/invoice/14432423',
output: 'example.pdf',
cookies: { 'session' => 'abc123' },
headers: { 'X-API-KEY' => 'token' },
auth: { username: 'admin', password: 'password' },
wait_window_loaded: true,
wait_network_idle: true
)
launcher.launchDSL β Quick & Clean
require "bidi2pdf"
Bidi2pdf::DSL.with_tab(headless: true) do |tab|
tab.navigate_to("https://example.com/invoice/14432423")
tab.wait_until_network_idle
tab.print("example.pdf")
end𧬠Deep Integration Example
Get fine-grained control using Chrome sessions, tabs, and BiDi commands:
require "bidi2pdf"
# 1. Remote or local session?
session = Bidi2pdf::Bidi::Session.new(
session_url: "http://localhost:9092/session",
headless: true,
)
# Alternative: local session via ChromeDriver
# manager = Bidi2pdf::ChromedriverManager.new(headless: false)
# manager.start
# session = manager.session
session.start
session.client.on_close { puts "WebSocket session closed" }
# 2. Create browser/tab
browser = session.browser
context = browser.create_user_context
window = context.create_browser_window
tab = window.create_browser_tab
# 3. Inject configuration
tab.set_cookie(name: "auth", value: "secret", domain: "example.com", secure: true)
tab.add_headers(url_patterns: [{ type: "pattern", protocol: "https", hostname: "example.com", port: "443" }],
headers: [{ name: "X-API-KEY", value: "12345678" }])
tab.basic_auth(url_patterns: [{ type: "pattern", protocol: "https", hostname: "example.com", port: "443" }],
username: "username", password: "secret")
# 4. Render PDF
tab.navigate_to "https://example.com/invoice/14432423"
# Alternative: send html code to the browser
# tab.render_html_content("<html>...</html>")
# Inject JavaScript if, needed
# as an url
# tab.inject_script "https://example.com/script.js"
# or inline
# tab.inject_script "console.log('Hello from injected script!')"
# Inject CSS if needed
# as an url
# tab.inject_style url: "https://example.com/simple.css"
# or inline
# tab.inject_style content: "body { background-color: red; }"
# Printing HTML you do not trust? Switch its scripts off before navigating:
# tab.disable_script_execution
# Print the screen styles in dark mode instead of the print stylesheet (holds for later
# navigations in this tab; tab.emulate_media without arguments ends it):
# tab.emulate_media(media: "screen", color_scheme: "dark")
tab.wait_until_network_idle
tab.print("my.pdf")
# 5. Cleanup
tab.close
window.close
context.close
session.closeπ Architecture
%%{ init: {
"theme": "base",
"themeVariables": {
"primaryColor": "#E0E7FF",
"secondaryColor":"#FEF9C3",
"edgeLabelBackground":"#FFFFFF",
"fontSize":"14px",
"nodeBorderRadius":"6"
}
}
}%%
flowchart LR
%% ----- Ruby side ---------
A["fa:fa-gem Ruby Application"]
B["fa:fa-gem bidi2pdf<br/>Library"]
%% ----Chrome environment -----------
subgraph C["fa:fa-chrome Chrome Environment"]
direction TB
C1["fa:fa-chrome Local Chrome<br/>(sub-process)"]
C2["fa:fa-docker Docker Chrome<br/>(remote)"]
end
D[[PDF File]]
%% ---- Data / control flows ------
A -- " HTML / URL + JS / CSS " --> B
B -- " WebDriver BiDi " --> C1
B -- " WebDriver BiDi " --> C2
C1 -- " PDF bytes " --> B
C2 -- " PDF bytes " --> B
B -- " PDF " --> D
%% --- Optional extra styling classes (for future tweaks) ---
classDef ruby fill:#E0E7FF,stroke:#6366F1,color:#1E1B4B;
classDef chrome fill:#FEF9C3,stroke:#F59E0B,color:#78350F;
class A,B ruby;
class C1,C2 chrome;
π³ Docker Support
π οΈ Build & Run Locally
# Prepare the environment
rake build
# Build the Docker image
docker build -t bidi2pdf -f docker/Dockerfile .
# Run the container and generate a PDF
docker run -it --rm \
-v ./output:/reports \
bidi2pdf \
bidi2pdf render --url=https://example.com/invoice/14432423 --output /reports/example.pdf
β‘ Use the Prebuilt Image (Recommended for Fast Start)
Grab it directly from Docker Hub
docker run -it --rm \
-v ./output:/reports \
dieters877565/bidi2pdf:main-slim \
bidi2pdf render --url=https://example.com/invoice/14432423 --output /reports/example.pdfβ Tip: Mount your local directory (e.g. ./output) to /reports in the container to easily access the generated PDFs.
β
All images run with a read-only root filesystem (--read-only) as long as /tmp is writable,
e.g. --tmpfs /tmp: they point Chromium's XDG_CONFIG_HOME/XDG_CACHE_HOME there, without
which Chromium's crash handler fails to start and every launch aborts with
chrome_crashpad_handler: --database is required.
β
Both published images also install pdf-reader - not a
runtime dependency of the gem itself (see Agent and Automation Usage) -
so pages and the page_count/pdf_text_present/pdf_not_blank recipe assertions work out of
the box in either image, no extra install step needed.
ChromeDriver image tags
dieters877565/chromedriver is built for
linux/amd64 and linux/arm64 with these tags:
| Tag | Moves? | Use |
|---|---|---|
0.1.17 (a release) |
no | a fixed Chromium, pinned together with the gem version |
sha-<short commit> |
only if that commit is built again by hand | a fix on main not released yet |
latest, main
|
yes, every push to main
|
the newest build and its Chromium security fixes |
Chromium comes from Debian's packages at build time, so every build can carry a different
Chromium - for a byte-exact pin use the digest (docker buildx imagetools inspect <image:tag>).
Hyphenation
The images (chromedriver, bidi2pdf and its slim variant) ship Chromium's hyphenation dictionaries
in /usr/lib/chromium/hyphen-data/, so CSS hyphens: auto breaks long words in the page's language
(lang="de" on the element or the document) - for exactly these languages, by the lang tag Blink
maps to each dictionary (case does not matter; Blink drops trailing subtags until one matches):
lang |
Dictionary |
|---|---|
de, de-DE, de-AT, de-CH (and de-DE-1901 - the region wins) |
German, 1996 spelling (de-1996) |
de-1901 |
German, traditional spelling |
de-CH-1901, de-LI-1901
|
Swiss German, traditional spelling |
en, en-US
|
US English |
en-GB, en-AU, en-IE, ... (every other en-*) |
British English |
fr, es, it, nl, pt (with any region, e.g. pt-BR) |
French, Spanish, Italian, Dutch, Portuguese |
The hyphenation spec prints one word in each of these, against a control without hyphenation.
Debian's Chromium has none, and the component it would download needs a profile that outlives the
session. docker/install-hyphen-data.sh fetches them at build time from Chromium's source, pinned by
the HYPHEN_DATA_REF build argument - deliberately independent of the Debian Chromium the image
installs, since the pattern data and its format change far more rarely; the hyphenation spec catches a
Chromium that stops reading them - with their licence notice (LICENSE next to them). Do not pass --disable-component-update in
chrome_args: Chromium then skips the dictionaries.
ChromeDriver health check and watchdog
The image has a Docker HEALTHCHECK (chromedriver's /status), so docker ps shows a
chromedriver that stopped answering as unhealthy. It does not see a Chrome that is wedged behind
a chromedriver that still answers - memory thrashing shows only to the watchdog. And Docker does
not restart a container for being unhealthy - only when its main process exits.
That is what the opt-in watchdog does. With WATCHDOG_ENABLED=true it checks every
WATCHDOG_INTERVAL seconds and, after WATCHDOG_FAILURES failed checks in a row, stops
chromedriver; the container exits non-zero and its restart policy starts a fresh one - any policy
but no (--restart unless-stopped or on-failure, restart: in Compose; Kamal sets
unless-stopped for accessories). Without one, a watchdog restart just stops the container. A
check fails when chromedriver does not answer /status, or when the
container's memory thrashes: Linux's pressure stall information (memory.pressure, "full") shows
every task stalled on memory for at least WATCHDOG_MEMORY_PRESSURE percent of the last 10 s - a
Chrome swapping at its memory limit, which answers no command but never crashes.
Renders still running on that Chrome fail when it restarts; a client with a sweeper retries them.
| Variable | Default | Description |
|---|---|---|
WATCHDOG_ENABLED |
false |
true starts the watchdog. |
WATCHDOG_INTERVAL |
15 |
Seconds between checks. |
WATCHDOG_FAILURES |
3 |
Failed checks in a row before the restart. |
WATCHDOG_STATUS_TIMEOUT |
5 |
Seconds /status may take. |
WATCHDOG_MEMORY_PRESSURE |
50 |
"full avg10" percent that fails a check; 0 turns it off. |
WATCHDOG_STOP_GRACE |
10 |
Seconds between stopping chromedriver (TERM) and killing it. |
WATCHDOG_PRESSURE_FILE |
/sys/fs/cgroup/memory.pressure |
The PSI file; without one (cgroup v1, PSI off) only /status counts. |
All values are whole numbers (seconds, percent). A malformed value stops the watchdog from starting - chromedriver keeps running unwatched, and the container log says why.
Picking WATCHDOG_MEMORY_PRESSURE: "full" counts only the time every task waited on memory, so it
stays near 0 while renders merely run; 50 % of the last 10 s is a container that is stuck, not busy.
Before lowering it, watch /sys/fs/cgroup/memory.pressure inside the container under your usual
render load and stay well above what you see there.
Docker Compose
rake build
docker compose -f docker/docker-compose.yml up -d
# simple example
docker compose -f docker/docker-compose.yml exec app bidi2pdf render --url=http://nginx/sample.html --wait_window_loaded --wait_network_idle --output /reports/simple.pdf
# with a local file
docker compose -f docker/docker-compose.yml exec app bidi2pdf render --url=file:///reports/sample.html--wait_network_idle --output /reports/simple.pdf
# basic auth example
docker compose -f docker/docker-compose.yml exec app bidi2pdf render --url=http://nginx/basic/sample.html --auth admin:secret --wait_window_loaded --wait_network_idle --output /reports/basic.pdf
# header example
docker compose -f docker/docker-compose.yml exec app bidi2pdf render --url=http://nginx/header/sample.html --header "X-API-KEY=secret" --wait_window_loaded --wait_network_idle --output /reports/header.pdf
# cookie example
docker compose -f docker/docker-compose.yml exec app bidi2pdf render --url=http://nginx/cookie/sample.html --cookie "auth=secret" --wait_window_loaded --wait_network_idle --output /reports/cookie.pdf
# remote chrome example
docker compose -f docker/docker-compose.yml exec app bidi2pdf render --url=http://nginx/cookie/sample.html --remote_browser_url http://remote-chrome:3000/session --cookie "auth=secret" --wait_window_loaded --wait_network_idle --output /reports/remote.pdf
docker compose -f docker/docker-compose.yml downπ§© Configuration Options
| Flag | Description |
|---|---|
--url |
Target URL (required) |
--output |
Output PDF file (default: output.pdf) |
--cookie |
Set cookie in name=value format |
--header |
Inject custom header name=value
|
--auth |
Basic auth as user:pass
|
--headless |
Run Chrome headless (default: true) |
--port |
ChromeDriver port (0 = auto) |
--wait_window_loaded |
Wait until window.loaded is set to true |
--wait_network_idle |
Wait until network is idle |
--log_level |
Log level: debug, info, warn, error, fatal |
--remote_browser_url |
Connect to remote Chrome session |
--default_timeout |
Operation timeout (default: 60s) |
--json |
Emit a single structured JSON result document (see bidi2pdf schema render) |
--json_stream |
Emit one JSON progress event per line to stderr (see bidi2pdf schema event) - works with or without --json
|
--stdin |
Read the HTML document from stdin, instead of --url/--html-file
|
--output - |
Write raw PDF bytes to stdout instead of a file |
--manifest FILE |
Write a render manifest (see bidi2pdf schema manifest) to FILE
|
See Agent and Automation Usage above for bidi2pdf diagnose,
bidi2pdf run recipe.yml, bidi2pdf schema <kind>, and bidi2pdf version --json - a separate
set of commands with their own options, not additional flags on render.
π§ Programmatic Configuration
Beyond the per-render CLI flags above, a few gem-wide defaults are set once via Bidi2pdf.configure:
Bidi2pdf.configure do |config|
config.default_timeout = 60 # seconds - default BiDi command timeout
config.enable_default_logging_subscriber = true
config.log_truncate_limit = 200 # bytes - see below
config.chromedriver_log_level = "WARNING" # see below
config.session_start_concurrency = 2 # Chrome session setups at once in this process - see "Session start limit"
config.session_start_timeout = nil # seconds a setup waits for its turn; nil: default_timeout
end| Setting | Default | Description |
|---|---|---|
default_timeout |
60 |
Default timeout (seconds) for BiDi commands that don't specify their own. |
enable_default_logging_subscriber |
true |
Subscribes a default logger to the gem's internal instrumentation events. |
log_truncate_limit |
200 |
Max bytes kept when logging a value that can be large (e.g. a data: URL) - truncated at a byte, not character, boundary. |
chromedriver_log_level |
nil |
ChromeDriver's own --log-level ("ALL"/"INFO"/"WARNING"/"SEVERE"). Unset mirrors Bidi2pdf.logger.level; set explicitly to quiet ChromeDriver's own (often very verbose) output independently of your app's log level. |
session_start_concurrency |
2 |
How many Chrome session setups (session request to first open tab) may run at once in this process; nil turns the limit off. See "Session start limit". |
session_start_timeout |
nil |
Seconds a setup waits for its turn before it fails with SessionStartTimeoutError (code BROWSER_LAUNCH_FAILED, retryable); nil uses default_timeout. |
session_gate |
nil |
An object of your own answering with_permit { ... } - e.g. a lock shared by several processes - instead of the per-process semaphore. See "Session start limit". |
Bidi2pdf.logger, Bidi2pdf.network_events_logger, Bidi2pdf.browser_console_logger, and
Bidi2pdf.notification_service are also configurable in the same block, for more advanced
logging/instrumentation needs.
Session start limit
When several new Chrome sessions are set up at the same moment, chromedriver/Chrome sometimes leaves one of the
setup commands unanswered (the first browsingContext.create or navigate of a fresh session): the render waits
out its command timeout, and the session is left behind unanswering, which wears the Chrome down for the renders
after it. Measured on chromedriver/Chrome 156 against one remote chromedriver: two setups at once stayed clean in
720 renders; three at once failed in the first round.
So bidi2pdf lets at most session_start_concurrency setups run at once in a process - from the session request to
the first open tab, in every path (the CLI, Bidi2pdf::DSL.with_tab, the session warmer). Rendering is never
limited: eight PDFs can render side by side while their setups took turns. A setup that cannot get a turn within
session_start_timeout fails with SessionStartTimeoutError instead of queueing for ever. Every turn instruments
session_gate.bidi2pdf (limit, wait in seconds, acquired).
The limit holds per process. Chrome sees the setups of every process that talks to it, so processes sharing one Chrome divide its budget: two processes with a limit of 1 each are as safe as one with 2 - two with 2 each failed in every run. With Puma workers and a job process on one remote chromedriver, set the limit so that limit x processes stays at about 2, or give the processes Chromes of their own.
It is one budget per process, whichever Chrome a setup goes to: two independent chromedrivers (a local one and a remote one, or two remote endpoints) still take turns. The limit is read once per process, at its first session setup (and again in a forked child): set it at boot, in an initializer. A later change is logged and takes effect after a restart.
A gate across processes. config.session_gate takes an object of your own instead of the per-process
semaphore - anything answering with_permit { ... } - so several processes can share one budget. Every setup
path (CLI, Bidi2pdf::DSL, the session warmer, bidi2pdf-rails) asks it, and session_gate.bidi2pdf is
instrumented as before. Your gate has to run the block with a permit, give the permit back however the block ends,
raise Bidi2pdf::SessionStartTimeoutError when none comes free in time, and not lose permits when a process dies
or forks (session_start_concurrency and session_start_timeout apply only if it reads them). A sketch for
processes on one host - the kernel drops a flock when its process ends:
# N lock files in a directory every process can reach: one free file is one permit.
class FileLockGate
def initialize(dir, slots: 2, timeout: 60)
@paths = Array.new(slots) { |index| File.join(dir, "session-start-#{index}.lock") }
@timeout = timeout
end
def with_permit
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
loop do
@paths.each do |path|
File.open(path, File::RDWR | File::CREAT, 0o600) do |file|
return yield if file.flock(File::LOCK_EX | File::LOCK_NB) # closing the file releases the lock
end
end
raise Bidi2pdf::SessionStartTimeoutError, "no session start permit free" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
sleep 0.05
end
end
end
Bidi2pdf.configure { |config| config.session_gate = FileLockGate.new("/var/lib/myapp/bidi2pdf", slots: 2) }The evidence. bidi2pdf's own measurements come first (2026-10-28, chromedriver/Chrome 156, one remote
chromedriver, the chromedriver swept between runs): at a limit of 2, 0 of 720 renders failed at 4, 6 and 8 renders at
once in ten interleaved rounds; at 3, renders failed from the first round on a freshly started Chrome; ungated, 104 of
160 failed. Every unanswered command was traced as written to an open WebSocket, with no reply carrying its id ever
read and no error raised - the client loses nothing. bin/session-start-repro reproduces it against any chromedriver
(LIMIT=none against LIMIT=2).
Related reports. No upstream report of exactly this was found (searched 2026-10-29: chromium-bidi, ChromeDriver, Selenium, Puppeteer, WebdriverIO, Mozilla). What follows is context, not confirmation - no other project has reported this bug.
Related concurrency and lifecycle defects in WebDriver BiDi:
-
Mozilla bug 2019511 "browsingContext.setViewport can
timeout if several contexts are created in parallel" - the closest analogy. In Firefox's BiDi a context's active
state could change before a waiting command ran, so a command depending on it timed out under parallel context
creation; fixed in Firefox 150 by always updating the dimensions synchronously in
browsingContext.create. Another browser and another cause, but the same kind of race: parallel context creation leaving a command waiting. - ChromeDriver 127, crbug 341048786 and crbug 1503101
(crrev.com/c/5575497): a race between ChromeDriver and the BiDi mapper during
session creation, fixed by waiting for
browsingContext.getTreeto return a non-empty list. The same phase - a session's start - but already fixed in the 156 measured here, so what bidi2pdf meets is a race still left there. - ChromeDriver 126, crbug 4762 (crrev.com/c/5515013), and
144, crbug 458027819 (crrev.com/c/7128058): Chrome
throttling background and occluded windows in BiDi sessions. bidi2pdf already passes
--disable-background-timer-throttling,--disable-renderer-backgroundingand--disable-backgrounding-occluded-windows.
The same symptom, no shown common cause:
- puppeteer#4039 "browser.newPage freezes (never resolves)" - a new page that never comes back; no root cause was found, closed as out of scope.
- webdriverio#16051 - pending BiDi commands waiting for their whole timeout after the connection closed, because the client never rejected them; fixed in WebdriverIO (PR #16054). A client-side cause of the same symptom, ruled out for bidi2pdf by the trace above.
Pre-warmed sessions (Bidi2pdf::SessionWarmer)
Optional. Keeps a few Chrome sessions started in the background so a render skips browser startup. Every slot is still used for exactly one render and then discarded - isolation is the same as launching a fresh Chrome per PDF. A slot is warmed together with its user context, window and tab, under one turn of the session start limit, so a checkout sets up nothing.
Bidi2pdf::SessionWarmer.configure do |c|
# warms c.size sessions right here, e.g. at boot
c.size = 2
c.max_idle_age = 300
# c.remote_browser_url = "http://remote-chrome:3000/session"
end
Bidi2pdf::SessionWarmer.with_tab do |tab|
tab.navigate_to(url)
tab.print("invoice.pdf")
end
Bidi2pdf::SessionWarmer.shutdown| Setting | Default | Description |
|---|---|---|
size |
1 |
Number of sessions kept warm. With none ready, a render starts its own session as usual - it never waits. |
max_idle_age |
300 |
Seconds a warm session may sit unused before it is retired and replaced. nil disables the limit. |
headless |
true |
Run Chrome headless. |
chrome_args |
DEFAULT_CHROME_ARGS |
Chrome launch arguments. |
remote_browser_url |
nil |
Connect each slot to a remote chromedriver instead of starting a local one. |
orphan_age |
:auto |
Remote only: on start, close sessions other warmers left behind older than this (:auto = 2 Γ max_idle_age, nil = off). |
registry_dir |
Dir.tmpdir |
Where the session registry file lives - every process that should clean up after the others must share it. |
sweeper |
nil |
Remote only: settings for a ChromeSweeper on the same chromedriver, e.g. { scope: :all, max_sessions: :auto, pids_limit: 1024, interval: 60 }. The warmer's own sessions are never touched; Bidi2pdf::SessionWarmer.sweep! sweeps on demand. |
retry_refused_sessions |
true |
With a sweeper: when chromedriver refuses a new session, sweep under pressure and try once more. false leaves it to the caller. |
shutdown_timeout |
10 |
Seconds shutdown waits, all in all, for its background threads and for closing the warm sessions. nil waits for ever. |
Leftover sessions on a shared chromedriver
A remote chromedriver keeps a session - a whole Chrome - until someone deletes it. A warmer closes
its own sessions when they idle past max_idle_age and when the process shuts down cleanly, but a
process that is killed or crashes leaves its sessions open, and enough of them stop the container
from starting any new Chrome. So in remote mode each warmer records the sessions it opens in a small
registry file (<registry_dir>/bidi2pdf-sessions-<hash of the URL>.json, mode 0600), and on start
closes recorded sessions older than orphan_age. The default, twice max_idle_age, is beyond the
point where any live warmer would already have recycled its own spare, so a running process never
loses one. Sessions nobody recorded (other tools on the same chromedriver) are never touched.
chromedriver drops custom capabilities, so a session cannot carry a tag of its own - hence the file.
Everything here is fail-open: if the registry directory is not writable, the warmer logs one
warning, instruments session_warmer.registry_unavailable.bidi2pdf, and keeps rendering - only the
cleanup is off. Closed leftovers are reported as session_warmer.orphans_closed.bidi2pdf.
With a sweeper configured, the warmer also sweeps in the background every interval seconds,
and when chromedriver refuses a new session ("session not created" - typically a container out of
room for another Chrome) it sweeps once and tries once more.
shutdown is bounded by shutdown_timeout. A Chrome that hangs or ran out of memory answers no
command, and closing each of its sessions would wait out every command timeout in turn - long
enough that a stopping server looks hung. At the deadline the warmer stops waiting, logs what is
still running and instruments session_warmer.shutdown_timeout.bidi2pdf (timeout, pending);
shutdown returns the same tally, e.g. { "spare" => 1 }, and {} when everything finished.
Sessions it could not close stay in the registry, so a later sweep closes them.
Security note: an idle warm session is an open, unauthenticated automation endpoint (chromedriver's port, Chrome's debugging port - loopback only for a local chromedriver) for as long as it waits.
max_idle_agebounds that window; keep it set unless the process runs somewhere nothing else can reach those ports. Withremote_browser_url, who can reach that endpoint on the network is what matters, exactly as it does without the warmer.
Leaked Chrome sessions (Bidi2pdf::ChromeSweeper)
The registry above only catches what a warmer recorded and only when the next warmer starts. A
ChromeSweeper covers the rest: sessions a crashed process never recorded, a Chrome stuck in a tab
that never returns, and too many sessions for the container. Run it once, when the application
suspects a leak, or periodically:
sweeper = Bidi2pdf::ChromeSweeper.new("http://remote-chrome:3000/session",
scope: :all, max_sessions: :auto, pids_limit: 1024, interval: 60)
sweeper.start # background sweeps until sweeper.stop
result = sweeper.sweep! # or one sweep right now
result.closed # => [#<data Closed id="β¦", age=734, why=:orphan>]
# one-shot: checks twice, 10 s apart, so the unresponsive rule applies too
Bidi2pdf::ChromeSweeper.sweep!("http://remote-chrome:3000/session", check_interval: 10, dry_run: true)
# last resort: a render failed for lack of resources - sweep under pressure, try once more
Bidi2pdf::ChromeSweeper.with_retry("http://remote-chrome:3000/session", scope: :all) do
render_the_pdf # must be safe to run twice
endIt closes, in this order: sessions older than orphan_age; sessions that failed
unresponsive_checks checks in a row (no answer, or a renderer burning a whole CPU between two
sweeps - an endless loop in a page); and while more than max_sessions exist, the oldest ones.
It never closes a session a live bidi2pdf process holds (below), a session in own_sessions, or
one younger than min_age - not even over the limit. When the limit can only be kept by closing
those, it closes nothing more and reports limit_exceeded. A session is closed with chromedriver's
DELETE /session/{id}, which also ends a Chrome that no longer answers BiDi.
Live sessions of other processes (leases). Several processes often share one chromedriver - Puma
workers, a job worker - and each has renders in flight and warm spares the others know nothing
about. So every session bidi2pdf opens is recorded in the registry with a lease, and a heartbeat
thread in the owning process renews it every 20 s (SessionRegistry::Heartbeat.interval) while the
session is open. Every lease carries its own TTL - three heartbeats of the process that wrote it -
so a sweeper never has to guess how often another process renews. A session whose lease is still
fresh belongs to a live process: no sweeper in any process closes it, and it is not even inspected.
The warmer leases its sessions in remote mode too, with or without a sweeper. When the process dies - killed, crashed, OOM - the lease runs out and the
session becomes a leftover like any other. Only processes that share the registry directory see
each other's leases: give job workers in another container the same registry_dir on a shared
volume. Sessions other tools opened have no lease; under scope: :all only min_age protects them.
Forked processes (Puma workers). The warmer belongs to the process that configured it. A child forked after that - Rails boots the app, and with it the warmer, in Puma's master before it forks its workers - does not use the copy it inherits: its threads did not survive the fork, and its slots, WebSocket connections included, are the parent's (a render on one used to time out while the parent took the answers). The child drops that copy - it closes only its own copies of the spare slots' sockets, locally, with no WebSocket close frame and no session end, so nothing of its parent's is touched - and builds its own warmer. The parent keeps its warmer.
Without further setup the child builds it on its first render, and that render then waits for the
whole warm-up: size new Chrome sessions, one after the other. To warm each worker before it takes
requests:
# config/puma.rb
on_worker_boot do
Bidi2pdf::SessionWarmer.after_fork
endEvery worker then holds size spares of its own, besides the master's: with W workers, size * (W + 1)
idle sessions after boot - 2 workers at size = 1 keep 3 Chromes waiting - and during renders one more
per render in flight plus the replacements warming. Count them in when sizing the chromedriver
(max_sessions, its memory and its process limit).
Puma's master renders nothing. To keep it from holding warm sessions nobody uses, end its warmer before it forks - at that point no worker exists yet, so no child holds a copy of those sessions:
# config/puma.rb
before_fork do
Bidi2pdf::SessionWarmer.shutdown
endWith fork_worker, worker 0 serves requests and forks the other workers itself; before_fork runs only
in the master and does not cover those forks. Puma runs before_refork in worker 0 before it forks and
after_refork once the cycle is done, so end worker 0's warmer before and give it a new one after:
# config/puma.rb, with fork_worker
before_refork do
Bidi2pdf::SessionWarmer.shutdown
end
after_refork do
Bidi2pdf::SessionWarmer.after_fork # builds worker 0 a warmer of its own again
endThe workers worker 0 forks run on_worker_boot like any other. A render still in flight in worker 0 when
it forks is fine: a child releases every slot it inherited - warm, out for a render, or being checked out
or retired - and a release that fails is logged while the others go on. Not covered is a session still
being built in the forking process at that instant: its socket copy in the child is left to garbage
collection (a file descriptor, nothing the child or the parent uses).
Last resort: pressure. sweep!(pressure: true) closes every session nobody holds that is past
min_age, without waiting for orphan_age, the unresponsive checks or the limit. with_retry does
that when its block fails with a resource error - chromedriver refusing a session
(SessionNotStartedError), or a connection or command dying or timing out (WebsocketError, but
not CmdError, which is the page's problem) - and runs the block once more; retry_on: takes other
error classes. It frees exactly what belongs to no live process: if the chromedriver is full of live
renders, the second attempt fails too, and that error is raised. The warmer does the same when a new
session is refused.
| Setting | Default | Description |
|---|---|---|
scope |
:recorded |
:recorded: only sessions a bidi2pdf process recorded in the registry. :all: every session on that chromedriver - only for a chromedriver your application owns, since on a shared one it closes other clients' old sessions. |
orphan_age |
600 |
Close sessions older than this many seconds. nil = off. |
min_age |
60 |
Never close a session younger than this. |
unresponsive_checks |
2 |
Close a session after this many failed checks in a row. nil = off. |
max_sessions |
nil |
Session limit. :auto = floor(pids_limit Γ pids_budget / threads_per_session), e.g. 1024 β 7, 512 β 3. |
pids_limit |
nil |
The chromedriver container's pids limit (Docker counts threads). :auto without it means no limit. |
pids_budget |
0.8 |
Share of pids_limit the Chrome sessions may use. |
threads_per_session |
110 |
Threads one Chrome session uses. |
interval |
nil |
Seconds between background sweeps (start/stop). stop(timeout: 10) waits that long for a sweep in progress, then stops waiting and returns false (nil waits for ever). |
dry_run |
false |
Report what would be closed, close nothing. |
registry_dir |
Dir.tmpdir |
The registry to read and update - same meaning as the warmer's setting. |
own_sessions |
-> { [] } |
A callable returning the caller's live session ids; they are never touched. |
lease_ttl |
60 |
Only for registry entries written by bidi2pdf 0.1.18, which carry no TTL of their own: renewed within this many seconds counts as live. |
How it tells a session's age: chromedriver's GET /sessions lists every session but no start time,
and Chrome keeps none either. So the sweeper uses the registry time when there is one and otherwise
attaches a second BiDi connection to the session and reads its first tab's
performance.timeOrigin - that tab is created with the session. It only asks for the tab tree,
that one value and the renderer CPU times; it never reads page content or logs a tab's URL.
A session counts as unresponsive only after unresponsive_checks failed checks, and every sweep is
one check. A periodic sweeper gets there by itself; a single sweep! does only with
check_interval: - it then checks unresponsive_checks - 1 times first (observe, which closes
nothing), that many seconds apart. Without it a one-shot sweep closes only old sessions and those
over the limit.
Only one sweep runs at a time, across processes too (a lock file next to the registry). A sweep never
raises: failures are logged and returned in Result#errors. Every remote Bidi2pdf::Bidi::Session
is now recorded and leased in the registry, not only warmer slots, and its close falls back to the
HTTP DELETE when Chrome does not answer; a session it still could not close keeps its entry but
loses its lease, so a sweeper takes it.
Notifications: chrome_sweeper.sweep.bidi2pdf (every sweep: counts, reason, duration),
chrome_sweeper.closed.bidi2pdf (id, age, why), chrome_sweeper.unresponsive.bidi2pdf,
chrome_sweeper.limit_exceeded.bidi2pdf, chrome_sweeper.failed.bidi2pdf,
chrome_sweeper.retry.bidi2pdf (with_retry sweeps and tries again), and
session_close_fallback.bidi2pdf when a close needed the HTTP DELETE.
From the command line:
# id, age, where the age comes from, tabs, responsive or live - no URLs
bidi2pdf sessions --remote-browser-url http://remote-chrome:3000/session [--scope recorded] [--json]
# exits 1 when a close failed or the limit is still exceeded; checks each session
# --unresponsive-checks times, --check-interval seconds apart (default 10; 0 = sweep at once)
bidi2pdf sweep --remote-browser-url http://remote-chrome:3000/session \
[--scope all] [--older-than 600] [--max-sessions 3] [--min-age 60] \
[--unresponsive-checks 2] [--check-interval 10] [--pressure] [--dry-run] [--json]Customizing Chrome arguments (and blank PDFs from inline HTML)
Bidi2pdf::Bidi::Session::DEFAULT_CHROME_ARGS already contains one --disable-features=... entry,
and Chrome only honors the last occurrence of that switch. To turn another feature off, extend
that entry instead of appending a second switch, which would silently drop the defaults:
chrome_args = Bidi2pdf::Bidi::Session::DEFAULT_CHROME_ARGS.map do |arg|
arg.start_with?("--disable-features=") ? "#{arg},LocalNetworkAccessChecks" : arg
endLocalNetworkAccessChecks is the one you are most likely to need. HTML rendered through
BrowserTab#render_html_content is loaded as a data: URL, which Chrome treats as a public origin;
if that HTML references assets on a private address (localhost, a Docker hostname, ...), recent
Chrome versions (confirmed with Chrome 153) block those requests before they are sent. Nothing
raises - the assets show up as failed network events, the server never sees a request, and the PDF
comes out unstyled or blank. Disable the check only where the asset host really is private, and keep
it on when rendering pages you do not control.
The bidi2pdf-rails README has the full
walkthrough under "Blank PDFs: Chrome's Local Network Access Check".
π Rails Integration
Rails integration is available as an additional gem:
# In your Gemfile
gem 'bidi2pdf-rails'For full documentation and usage examples, visit: https://github.com/dieter-medium/bidi2pdf-rails
π§ͺ Test Helpers
Bidi2pdf provides a suite of RSpec helpers (activated with pdf: true) to
simplify PDF-related testing:
SpecPathsHelper
β spec_dir β returns your spec directory
β tmp_dir β returns your tmp directory
β tmp_file(*parts) β builds a tmp file path
β random_tmp_dir(*dirs, prefix:) β builds a random tmp directory
-
fixture_file(*parts)β returns the path to a fixture file
PdfFileHelper
β with_pdf_debug(pdf_data) { |data| β¦ } β on failure, writes PDF to disk
β store_pdf_file(pdf_data, filename_prefix = "test") β saves PDF and returns path
Rspec Matchers
-
have_pdf_page_countβ checks if the PDF has a specific number of pages -
match_pdf_textβ checks if the PDF equals a specific text, after stripping whitespace and normalizing characters -
contains_pdf_textβ checks if the PDF contains a specific text, after stripping whitespace and normalizing characters, supporting regex -
contains_pdf_imageβ checks if the PDF contains a specific image
ChromedriverContainer
require "bidi2pdf/test_helpers/testcontainers" you can use the chromedriver_container helper to
start a ChromeDriver container for your tests. This is useful if you don't want to run ChromeDriver locally
or if you want to ensure a clean environment for your tests.
This also provides the helper methods:
-
session_urlβ returns the session URL for the ChromeDriver container -
chromedriver_containerβ returns the Testcontainers container object -
create_session-> creates aBidi2pdf::Bidi::Sessionobject for the ChromeDriver container
With the environment variable DISABLE_CHROME_SANDBOX set to true, the container will run Chrome without
the sandbox. This is useful for CI environments where the sandbox may cause issues.
Container URLs (session_url and similar) are built from the Docker host's address plus the container's
mapped port. That address is resolved by the testcontainers gem β which already handles a tcp:/ssh:
DOCKER_HOST, a native local daemon, and a sibling container reaching the daemon over the bridge gateway β
falling back to "localhost" only if the gem can't resolve a host at all (a known gap in
testcontainers-core 0.2.0 when the test process itself runs in a container on a custom network).
To override the Docker host address explicitly, set TC_HOST (e.g. TC_HOST=localhost for a test process
running in a container that shares the Docker daemon's network namespace β true docker-in-docker). TC_HOST
is a single global setting for where the daemon's published ports are reachable; it is not the name, URL, or
IP of an individual container. To reach one container from another container, join the shared network and
address it by alias instead (see nginx_url(use_alias: true) in the spec helpers).
Example
require "bidi2pdf/test_helpers"
require "bidi2pdf/test_helpers/images" # <= for image matching, requires lib-vips
require "bidi2pdf/test_helpers/testcontainers" # <= requires testcontainers gem
RSpec.describe "PDF generation", :pdf, :chromedriver do
it "generates a PDF with the correct content" do
pdf_data = generate_pdf("https://example.com/invoice/14432423")
expect(pdf_data).to have_pdf_page_count(1)
expect(pdf_data).to match_pdf_text("Hello, world!")
expect(pdf_data).to contain_pdf_image(fixture_file("logo.png"))
end
endπ Development
# Setup
bin/setup
# Run tests
rake spec
# Open interactive console
bin/consoleπ License
This project is licensed under the MIT License.