--- name: remote-chrome description: This skill should be used when bidi2pdf (or bidi2pdf-rails) should render in a shared Chrome container instead of a local browser - setting up the chromedriver container with Docker Compose, connecting to it, sizing it, keeping sessions from piling up, and printing HTML that nobody vouches for. Also when renders fail with "session not created", hang, or the container eats memory. --- # Run a shared Chrome for bidi2pdf bidi2pdf talks to chromedriver over HTTP and WebDriver BiDi. A chromedriver container that several processes share is cheaper and more predictable than a browser per process, and keeps Chrome out of the application's own container. **Does not:** cover Kubernetes or a cloud browser service, and does not make an untrusted page safe on its own (section 5 is what it takes). ## Ground rules - **bidi2pdf is the renderer.** If it fails, report its `error.code`, `error.hint` and exit code; never make the PDF or screenshot another way (Chrome's own print-to-PDF, another PDF engine). - **Install nothing unasked.** Name what is missing and suggest a way (the `render-pdf` skill's preflight); run nothing that changes the system without the user's yes. - **bidi2pdf's results first.** Read its `--json` result instead of inspecting the PDF with other tools. ## 1. The container `references/compose.remote-chrome.yml` is a working Compose service, taken from an application that runs it in development and production. The parts that matter, and why: - **Image** `dieters877565/chromedriver` (chromedriver + Chromium, hyphenation dictionaries included). It answers on port 3000; the new-session URL is `http://:3000/session`. - **No published port, an internal network only.** Whoever reaches port 3000 drives a browser: keep it to the containers that render. No VNC (`ENABLE_VNC: "false"`). - **Chrome's sandbox** needs to create namespaces, which Docker's default seccomp profile only allows with `CAP_SYS_ADMIN`. Three ways, from the narrowest: 1. The gem's seccomp profile `docker/chrome-driver/chrome.json` (`security_opt: ["seccomp=chrome.json"]`), no extra capability. It allows the namespace calls Chrome uses, but also some Docker's default refuses (`mount`, `bpf`, `ptrace`, `perf_event_open`); verify on your host that sessions start before relying on it. 2. `cap_add: [SYS_ADMIN]` - works with the default profile, and is what the reference file uses. It is a broad capability: keep the container on its internal network and give it nothing else. 3. No extra capability, and every client passes `--no-sandbox` (`DISABLE_CHROME_SANDBOX=true` for the CLI). Chrome then runs every page without its own sandbox - only for pages you trust. Some CI runners drop `--cap-add` silently; there only this works. - **Limits from measurement, not guesses:** one Chrome session runs ~110 threads (Docker counts them as pids) and a few hundred MB. `pids_limit: 1024`, `mem_limit: 2g` with `memswap_limit` equal (no swap: a Chrome out of memory is killed and replaced instead of freezing every render), `shm_size: 256m`. At 512 pids and 1 GB, four concurrent renders crashed or froze it. - **`WATCHDOG_ENABLED: "true"`** stops chromedriver when it stops answering or memory thrashes; `restart: unless-stopped` then starts a fresh container. The image's health check (`/status`) only marks it unhealthy - Docker never restarts an unhealthy container by itself. - **`LANG`/`LANGUAGE`** set the browser's locale: date and number formats in form fields follow it. In CI, wait for the container to be healthy before the first render; "Connection refused" on port 3000 right after start means it was not ready yet. ## 2. Connect ```bash bidi2pdf render --url http://app:3000/report --output report.pdf \ --remote-browser-url http://remote-chrome:3000/session ``` The page URL must be reachable **from the browser container**, not from where the command runs: `localhost` there is the browser itself. Use the application's service name, and for a Rails app make the PDF's asset host that same name, or every stylesheet fails and the PDF is blank. ## 3. Keep sessions from piling up A process that crashes or is killed mid-render leaves its Chrome session open on the shared chromedriver, and each one holds memory. bidi2pdf records the sessions it opens on this machine (`SessionRegistry`); look and clean up with: ```bash bidi2pdf sessions --remote-browser-url http://remote-chrome:3000/session bidi2pdf sweep --remote-browser-url http://remote-chrome:3000/session --dry-run bidi2pdf sweep --remote-browser-url http://remote-chrome:3000/session ``` `sweep` closes recorded sessions older than `--older-than` (600 s), unresponsive ones, and the oldest above `--max-sessions`; never one younger than `--min-age` or one a running process holds. Leave `--scope all` to a chromedriver your application owns alone - it also closes sessions other programs opened. Run `sweep` from cron or a timer, and `--dry-run` first. Code that opens its own sessions (a test driver, a script) passes a `SessionRegistry` to `Bidi2pdf::Bidi::Session.new(..., registry: Bidi2pdf::SessionRegistry.new(url))` and closes the session in an `ensure`, with a time limit on the close: a hung close is the most common leak. ## 4. Size it Renders run in parallel on one chromedriver, one session (or a tab of one) each. Measure your own pages: start with 2 GB, run the peak number of renders at once, and watch `docker stats` for memory and pids. Raise `mem_limit` and `pids_limit` together. ## 5. Printing HTML nobody vouches for A page from a user is a page that runs its scripts in your browser. Before navigating: - `tab.disable_script_execution` - the page's JavaScript does not run at all. - Give the browser container **no direct internet access**: route it through an egress proxy that refuses private and internal addresses, so a page cannot make the browser fetch your internal services. Never put it on the same network as your database. - Prefer printing HTML you built (`render_html_content`, `--html-file`) over handing Chrome a URL a user typed. **Before production, test the isolation with hostile pages**, not by reading the configuration. Render pages that try, each on its own: to fetch an internal service and your cloud metadata address, to load `file:///etc/passwd` and other `file:` URLs, to open new windows, to run scripts despite `disable_script_execution`, and to exhaust memory. Each must fail or stay contained, and the chromedriver must keep serving other renders. Repeat it after changing the image, the capabilities or the seccomp profile.