bidi2pdf 0.1.16 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -2
- data/README.md +145 -0
- data/docker/Dockerfile +7 -0
- data/docker/Dockerfile.chromedriver +7 -0
- data/docker/Dockerfile.slim +7 -0
- data/lib/bidi2pdf/bidi/buffered_web_socket_client.rb +9 -0
- data/lib/bidi2pdf/bidi/commands/browsing_context_get_tree.rb +16 -0
- data/lib/bidi2pdf/bidi/commands/cdp_send_command.rb +25 -0
- data/lib/bidi2pdf/bidi/commands.rb +2 -0
- data/lib/bidi2pdf/bidi/session.rb +57 -24
- data/lib/bidi2pdf/chrome_sweeper/inspector.rb +117 -0
- data/lib/bidi2pdf/chrome_sweeper/settings.rb +79 -0
- data/lib/bidi2pdf/chrome_sweeper.rb +398 -0
- data/lib/bidi2pdf/chromedriver_api.rb +72 -0
- data/lib/bidi2pdf/cli/session_commands.rb +82 -0
- data/lib/bidi2pdf/cli.rb +43 -0
- data/lib/bidi2pdf/dsl.rb +2 -1
- data/lib/bidi2pdf/launcher.rb +2 -1
- data/lib/bidi2pdf/session_registry/heartbeat.rb +83 -0
- data/lib/bidi2pdf/session_registry.rb +152 -0
- data/lib/bidi2pdf/session_sweeper.rb +66 -0
- data/lib/bidi2pdf/session_warmer.rb +112 -1
- data/lib/bidi2pdf/version.rb +1 -1
- data/lib/bidi2pdf.rb +4 -0
- data/sig/bidi2pdf/bidi/buffered_web_socket_client.rbs +7 -0
- data/sig/bidi2pdf/bidi/commands/browsing_context_get_tree.rbs +12 -0
- data/sig/bidi2pdf/bidi/commands/cdp_send_command.rbs +23 -0
- data/sig/bidi2pdf/bidi/session.rbs +25 -3
- data/sig/bidi2pdf/chrome_sweeper/inspector.rbs +61 -0
- data/sig/bidi2pdf/chrome_sweeper/settings.rbs +34 -0
- data/sig/bidi2pdf/chrome_sweeper.rbs +220 -0
- data/sig/bidi2pdf/chromedriver_api.rbs +44 -0
- data/sig/bidi2pdf/cli/session_commands.rbs +35 -0
- data/sig/bidi2pdf/cli.rbs +6 -0
- data/sig/bidi2pdf/session_registry/heartbeat.rbs +47 -0
- data/sig/bidi2pdf/session_registry.rbs +75 -0
- data/sig/bidi2pdf/session_sweeper.rbs +37 -0
- data/sig/bidi2pdf/session_warmer.rbs +66 -0
- data/sig/bidi2pdf/version.rbs +1 -1
- metadata +29 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa0fc469533226ce79d16bb401f4c47093dda42d4a68af67453fab79e086ad65
|
|
4
|
+
data.tar.gz: 9e4a1176ad42c03ced1544dcb9022a3dfe7d2663b0d8960f41bea863d3b0e788
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ddd7621c394b454a1df5b6071f474b1bd8699a4de4086e47c7185b01df66476ccad28d7e843b17e79aebf198489053bc237c345a3f638fb4e25e907aa4dc04f1
|
|
7
|
+
data.tar.gz: 134e15de59f8d5d7044be15308b1490988bc7eddd0134a9de05feed3accf3d56c52818125da49d36ad91263f6d16d8e58dc6549c85f5897316e9dfab7e60fb73
|
data/CHANGELOG.md
CHANGED
|
@@ -8,10 +8,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
-
[unreleased]: https://github.com/dieter-medium/bidi2pdf/compare/v0.1.
|
|
11
|
+
[unreleased]: https://github.com/dieter-medium/bidi2pdf/compare/v0.1.18..HEAD
|
|
12
12
|
|
|
13
13
|
<!-- generated by git-cliff end -->
|
|
14
14
|
|
|
15
|
+
## [0.1.18] - 2026-09-30
|
|
16
|
+
|
|
17
|
+
### 🐛 Fixed
|
|
18
|
+
- Record warmer sessions for the sweeper
|
|
19
|
+
- Let one-shot sweeps close hung sessions
|
|
20
|
+
|
|
21
|
+
### 🔄 Changed
|
|
22
|
+
- Merge pull request #146 from dieter-medium/feat/chrome-sweeper
|
|
23
|
+
- Merge pull request #141 from dieter-medium/dependabot/bundler/main/rubyzip-3.7.0
|
|
24
|
+
- Merge pull request #142 from dieter-medium/dependabot/bundler/main/simplecov-1.3.1
|
|
25
|
+
|
|
26
|
+
### 🔧 Build
|
|
27
|
+
- Bump rubyzip from 3.6.0 to 3.7.0
|
|
28
|
+
- Bump simplecov from 1.3.0 to 1.3.1
|
|
29
|
+
|
|
30
|
+
### 🚀 Added
|
|
31
|
+
- Lease sessions so sweeps spare live workers
|
|
32
|
+
- Add a Chrome sweeper for leaked sessions
|
|
33
|
+
|
|
34
|
+
## [0.1.17] - 2026-09-29
|
|
35
|
+
|
|
36
|
+
### 🐛 Fixed
|
|
37
|
+
- Let Chromium start on a read-only root
|
|
38
|
+
- Do not raise when our own close ends a write
|
|
39
|
+
|
|
40
|
+
### 📝 Docs
|
|
41
|
+
- Describe latest as the newest Chromium build
|
|
42
|
+
|
|
43
|
+
### 🔄 Changed
|
|
44
|
+
- Merge pull request #143 from dieter-medium/feat/chromedriver-sha-tags
|
|
45
|
+
- Merge pull request #140 from dieter-medium/fix/chromium-read-only-root
|
|
46
|
+
- Merge pull request #139 from dieter-medium/fix/websocket-close-during-write
|
|
47
|
+
- Merge pull request #138 from dieter-medium/feat/session-warmer-orphan-sweep
|
|
48
|
+
- Merge pull request #132 from dieter-medium/dependabot/bundler/main/rubyzip-3.6.0
|
|
49
|
+
|
|
50
|
+
### 🔧 Build
|
|
51
|
+
- Update rubyzip requirement from ~> 2.4 to >= 2.4, < 4.0
|
|
52
|
+
|
|
53
|
+
### 🚀 Added
|
|
54
|
+
- Tag every chromedriver image build by commit
|
|
55
|
+
- Close leftover warm sessions on start
|
|
56
|
+
|
|
15
57
|
## [0.1.16] - 2026-09-22
|
|
16
58
|
|
|
17
59
|
### 🐛 Fixed
|
|
@@ -472,7 +514,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
472
514
|
|
|
473
515
|
### 🔄 Released
|
|
474
516
|
|
|
475
|
-
- [unreleased](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.
|
|
517
|
+
- [unreleased](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.18..HEAD)
|
|
518
|
+
- [0.1.18](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.17..v0.1.18)
|
|
519
|
+
- [0.1.17](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.16..v0.1.17)
|
|
476
520
|
- [0.1.16](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.15..v0.1.16)
|
|
477
521
|
- [0.1.15](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.14..v0.1.15)
|
|
478
522
|
- [0.1.14](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.13..v0.1.14)
|
data/README.md
CHANGED
|
@@ -435,11 +435,30 @@ docker run -it --rm \
|
|
|
435
435
|
|
|
436
436
|
✅ Tip: Mount your local directory (e.g. ./output) to /reports in the container to easily access the generated PDFs.
|
|
437
437
|
|
|
438
|
+
✅ All images run with a read-only root filesystem (`--read-only`) as long as `/tmp` is writable,
|
|
439
|
+
e.g. `--tmpfs /tmp`: they point Chromium's `XDG_CONFIG_HOME`/`XDG_CACHE_HOME` there, without
|
|
440
|
+
which Chromium's crash handler fails to start and every launch aborts with
|
|
441
|
+
`chrome_crashpad_handler: --database is required`.
|
|
442
|
+
|
|
438
443
|
✅ Both published images also install [`pdf-reader`](https://github.com/yob/pdf-reader) - not a
|
|
439
444
|
runtime dependency of the gem itself (see [Agent and Automation Usage](#agent-and-automation-usage)) -
|
|
440
445
|
so `pages` and the `page_count`/`pdf_text_present`/`pdf_not_blank` recipe assertions work out of
|
|
441
446
|
the box in either image, no extra install step needed.
|
|
442
447
|
|
|
448
|
+
### ChromeDriver image tags
|
|
449
|
+
|
|
450
|
+
[`dieters877565/chromedriver`](https://hub.docker.com/r/dieters877565/chromedriver) is built for
|
|
451
|
+
`linux/amd64` and `linux/arm64` with these tags:
|
|
452
|
+
|
|
453
|
+
| Tag | Moves? | Use |
|
|
454
|
+
|---|---|---|
|
|
455
|
+
| `0.1.17` (a release) | no | a fixed Chromium, pinned together with the gem version |
|
|
456
|
+
| `sha-<short commit>` | only if that commit is built again by hand | a fix on `main` not released yet |
|
|
457
|
+
| `latest`, `main` | yes, every push to `main` | the newest build and its Chromium security fixes |
|
|
458
|
+
|
|
459
|
+
Chromium comes from Debian's packages at build time, so every build can carry a different
|
|
460
|
+
Chromium - for a byte-exact pin use the digest (`docker buildx imagetools inspect <image:tag>`).
|
|
461
|
+
|
|
443
462
|
### Docker Compose
|
|
444
463
|
|
|
445
464
|
```bash
|
|
@@ -551,6 +570,29 @@ Bidi2pdf::SessionWarmer.shutdown
|
|
|
551
570
|
| `headless` | `true` | Run Chrome headless. |
|
|
552
571
|
| `chrome_args` | `DEFAULT_CHROME_ARGS` | Chrome launch arguments. |
|
|
553
572
|
| `remote_browser_url` | `nil` | Connect each slot to a remote chromedriver instead of starting a local one. |
|
|
573
|
+
| `orphan_age` | `:auto` | Remote only: on start, close sessions other warmers left behind older than this (`:auto` = 2 × `max_idle_age`, `nil` = off). |
|
|
574
|
+
| `registry_dir` | `Dir.tmpdir` | Where the session registry file lives - every process that should clean up after the others must share it. |
|
|
575
|
+
| `sweeper` | `nil` | Remote only: settings for a [`ChromeSweeper`](#leaked-chrome-sessions-bidi2pdfchromesweeper) 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. With a sweeper the warmer records its sessions even when `orphan_age` is `nil`. |
|
|
576
|
+
|
|
577
|
+
#### Leftover sessions on a shared chromedriver
|
|
578
|
+
|
|
579
|
+
A remote chromedriver keeps a session - a whole Chrome - until someone deletes it. A warmer closes
|
|
580
|
+
its own sessions when they idle past `max_idle_age` and when the process shuts down cleanly, but a
|
|
581
|
+
process that is killed or crashes leaves its sessions open, and enough of them stop the container
|
|
582
|
+
from starting any new Chrome. So in remote mode each warmer records the sessions it opens in a small
|
|
583
|
+
registry file (`<registry_dir>/bidi2pdf-sessions-<hash of the URL>.json`, mode 0600), and on start
|
|
584
|
+
closes recorded sessions older than `orphan_age`. The default, twice `max_idle_age`, is beyond the
|
|
585
|
+
point where any live warmer would already have recycled its own spare, so a running process never
|
|
586
|
+
loses one. Sessions nobody recorded (other tools on the same chromedriver) are never touched.
|
|
587
|
+
chromedriver drops custom capabilities, so a session cannot carry a tag of its own - hence the file.
|
|
588
|
+
|
|
589
|
+
Everything here is fail-open: if the registry directory is not writable, the warmer logs one
|
|
590
|
+
warning, instruments `session_warmer.registry_unavailable.bidi2pdf`, and keeps rendering - only the
|
|
591
|
+
cleanup is off. Closed leftovers are reported as `session_warmer.orphans_closed.bidi2pdf`.
|
|
592
|
+
|
|
593
|
+
With a `sweeper` configured, the warmer also sweeps in the background every `interval` seconds,
|
|
594
|
+
and when chromedriver refuses a new session ("session not created" - typically a container out of
|
|
595
|
+
room for another Chrome) it sweeps once and tries once more.
|
|
554
596
|
|
|
555
597
|
> **Security note:** an idle warm session is an open, unauthenticated automation endpoint
|
|
556
598
|
> (chromedriver's port, Chrome's debugging port - loopback only for a local chromedriver) for as
|
|
@@ -558,6 +600,109 @@ Bidi2pdf::SessionWarmer.shutdown
|
|
|
558
600
|
> nothing else can reach those ports. With `remote_browser_url`, who can reach that endpoint on the
|
|
559
601
|
> network is what matters, exactly as it does without the warmer.
|
|
560
602
|
|
|
603
|
+
### Leaked Chrome sessions (`Bidi2pdf::ChromeSweeper`)
|
|
604
|
+
|
|
605
|
+
The registry above only catches what a warmer recorded and only when the next warmer starts. A
|
|
606
|
+
`ChromeSweeper` covers the rest: sessions a crashed process never recorded, a Chrome stuck in a tab
|
|
607
|
+
that never returns, and too many sessions for the container. Run it once, when the application
|
|
608
|
+
suspects a leak, or periodically:
|
|
609
|
+
|
|
610
|
+
```ruby
|
|
611
|
+
sweeper = Bidi2pdf::ChromeSweeper.new("http://remote-chrome:3000/session",
|
|
612
|
+
scope: :all, max_sessions: :auto, pids_limit: 1024, interval: 60)
|
|
613
|
+
sweeper.start # background sweeps until sweeper.stop
|
|
614
|
+
result = sweeper.sweep! # or one sweep right now
|
|
615
|
+
result.closed # => [#<data Closed id="…", age=734, why=:orphan>]
|
|
616
|
+
|
|
617
|
+
# one-shot: checks twice, 10 s apart, so the unresponsive rule applies too
|
|
618
|
+
Bidi2pdf::ChromeSweeper.sweep!("http://remote-chrome:3000/session", check_interval: 10, dry_run: true)
|
|
619
|
+
|
|
620
|
+
# last resort: a render failed for lack of resources - sweep under pressure, try once more
|
|
621
|
+
Bidi2pdf::ChromeSweeper.with_retry("http://remote-chrome:3000/session", scope: :all) do
|
|
622
|
+
render_the_pdf # must be safe to run twice
|
|
623
|
+
end
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
It closes, in this order: sessions older than `orphan_age`; sessions that failed
|
|
627
|
+
`unresponsive_checks` checks in a row (no answer, or a renderer burning a whole CPU between two
|
|
628
|
+
sweeps - an endless loop in a page); and while more than `max_sessions` exist, the oldest ones.
|
|
629
|
+
It never closes a session a live bidi2pdf process holds (below), a session in `own_sessions`, or
|
|
630
|
+
one younger than `min_age` - not even over the limit. When the limit can only be kept by closing
|
|
631
|
+
those, it closes nothing more and reports `limit_exceeded`. A session is closed with chromedriver's
|
|
632
|
+
`DELETE /session/{id}`, which also ends a Chrome that no longer answers BiDi.
|
|
633
|
+
|
|
634
|
+
**Live sessions of other processes (leases).** Several processes often share one chromedriver - Puma
|
|
635
|
+
workers, a job worker - and each has renders in flight and warm spares the others know nothing
|
|
636
|
+
about. So every session bidi2pdf opens is recorded in the registry with a lease, and a heartbeat
|
|
637
|
+
thread in the owning process renews it every 20 s while the session is open. A session whose lease
|
|
638
|
+
is younger than `lease_ttl` belongs to a live process: no sweeper in any process closes it, and it
|
|
639
|
+
is not even inspected. When the process dies - killed, crashed, OOM - the lease runs out and the
|
|
640
|
+
session becomes a leftover like any other. Only processes that share the registry directory see
|
|
641
|
+
each other's leases: give job workers in another container the same `registry_dir` on a shared
|
|
642
|
+
volume. Sessions other tools opened have no lease; under `scope: :all` only `min_age` protects them.
|
|
643
|
+
|
|
644
|
+
**Last resort: pressure.** `sweep!(pressure: true)` closes every session nobody holds that is past
|
|
645
|
+
`min_age`, without waiting for `orphan_age`, the unresponsive checks or the limit. `with_retry` does
|
|
646
|
+
that when its block fails with a resource error - chromedriver refusing a session
|
|
647
|
+
(`SessionNotStartedError`), or a connection or command dying or timing out (`WebsocketError`, but
|
|
648
|
+
not `CmdError`, which is the page's problem) - and runs the block once more; `retry_on:` takes other
|
|
649
|
+
error classes. It frees exactly what belongs to no live process: if the chromedriver is full of live
|
|
650
|
+
renders, the second attempt fails too, and that error is raised. The warmer does the same when a new
|
|
651
|
+
session is refused.
|
|
652
|
+
|
|
653
|
+
| Setting | Default | Description |
|
|
654
|
+
|-----------------------|-------------|--------------------------------------------------------------------------------------------------------------|
|
|
655
|
+
| `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. |
|
|
656
|
+
| `orphan_age` | `600` | Close sessions older than this many seconds. `nil` = off. |
|
|
657
|
+
| `min_age` | `60` | Never close a session younger than this. |
|
|
658
|
+
| `unresponsive_checks` | `2` | Close a session after this many failed checks in a row. `nil` = off. |
|
|
659
|
+
| `max_sessions` | `nil` | Session limit. `:auto` = floor(`pids_limit` × `pids_budget` / `threads_per_session`), e.g. 1024 → 7, 512 → 3. |
|
|
660
|
+
| `pids_limit` | `nil` | The chromedriver container's pids limit (Docker counts threads). `:auto` without it means no limit. |
|
|
661
|
+
| `pids_budget` | `0.8` | Share of `pids_limit` the Chrome sessions may use. |
|
|
662
|
+
| `threads_per_session` | `110` | Threads one Chrome session uses. |
|
|
663
|
+
| `interval` | `nil` | Seconds between background sweeps (`start`/`stop`). |
|
|
664
|
+
| `dry_run` | `false` | Report what would be closed, close nothing. |
|
|
665
|
+
| `registry_dir` | `Dir.tmpdir`| The registry to read and update - same meaning as the warmer's setting. |
|
|
666
|
+
| `own_sessions` | `-> { [] }` | A callable returning the caller's live session ids; they are never touched. |
|
|
667
|
+
| `lease_ttl` | `60` | A recorded session renewed within this many seconds belongs to a live process and is never touched. |
|
|
668
|
+
|
|
669
|
+
How it tells a session's age: chromedriver's `GET /sessions` lists every session but no start time,
|
|
670
|
+
and Chrome keeps none either. So the sweeper uses the registry time when there is one and otherwise
|
|
671
|
+
attaches a second BiDi connection to the session and reads its first tab's
|
|
672
|
+
`performance.timeOrigin` - that tab is created with the session. It only asks for the tab tree,
|
|
673
|
+
that one value and the renderer CPU times; it never reads page content or logs a tab's URL.
|
|
674
|
+
|
|
675
|
+
A session counts as unresponsive only after `unresponsive_checks` failed checks, and every sweep is
|
|
676
|
+
one check. A periodic sweeper gets there by itself; a single `sweep!` does only with
|
|
677
|
+
`check_interval:` - it then checks `unresponsive_checks - 1` times first (`observe`, which closes
|
|
678
|
+
nothing), that many seconds apart. Without it a one-shot sweep closes only old sessions and those
|
|
679
|
+
over the limit.
|
|
680
|
+
|
|
681
|
+
Only one sweep runs at a time, across processes too (a lock file next to the registry). A sweep never
|
|
682
|
+
raises: failures are logged and returned in `Result#errors`. Every remote `Bidi2pdf::Bidi::Session`
|
|
683
|
+
is now recorded and leased in the registry, not only warmer slots, and its `close` falls back to the
|
|
684
|
+
HTTP `DELETE` when Chrome does not answer; a session it still could not close keeps its entry but
|
|
685
|
+
loses its lease, so a sweeper takes it.
|
|
686
|
+
|
|
687
|
+
Notifications: `chrome_sweeper.sweep.bidi2pdf` (every sweep: counts, reason, duration),
|
|
688
|
+
`chrome_sweeper.closed.bidi2pdf` (id, age, why), `chrome_sweeper.unresponsive.bidi2pdf`,
|
|
689
|
+
`chrome_sweeper.limit_exceeded.bidi2pdf`, `chrome_sweeper.failed.bidi2pdf`,
|
|
690
|
+
`chrome_sweeper.retry.bidi2pdf` (`with_retry` sweeps and tries again), and
|
|
691
|
+
`session_close_fallback.bidi2pdf` when a close needed the HTTP `DELETE`.
|
|
692
|
+
|
|
693
|
+
From the command line:
|
|
694
|
+
|
|
695
|
+
```bash
|
|
696
|
+
# id, age, where the age comes from, tabs, responsive or live - no URLs
|
|
697
|
+
bidi2pdf sessions --remote-browser-url http://remote-chrome:3000/session [--scope recorded] [--json]
|
|
698
|
+
|
|
699
|
+
# exits 1 when a close failed or the limit is still exceeded; checks each session
|
|
700
|
+
# --unresponsive-checks times, --check-interval seconds apart (default 10; 0 = sweep at once)
|
|
701
|
+
bidi2pdf sweep --remote-browser-url http://remote-chrome:3000/session \
|
|
702
|
+
[--scope all] [--older-than 600] [--max-sessions 3] [--min-age 60] \
|
|
703
|
+
[--unresponsive-checks 2] [--check-interval 10] [--pressure] [--dry-run] [--json]
|
|
704
|
+
```
|
|
705
|
+
|
|
561
706
|
### Customizing Chrome arguments (and blank PDFs from inline HTML)
|
|
562
707
|
|
|
563
708
|
`Bidi2pdf::Bidi::Session::DEFAULT_CHROME_ARGS` already contains one `--disable-features=...` entry,
|
data/docker/Dockerfile
CHANGED
|
@@ -42,6 +42,13 @@ RUN gem install ./bidi2pdf-*.gem && \
|
|
|
42
42
|
chown -R appuser:appuser /app
|
|
43
43
|
|
|
44
44
|
# Switch to non-root user
|
|
45
|
+
# Chromium's crash handler gets its database path from the XDG config dir. On a read-only root
|
|
46
|
+
# (docker run --read-only) that dir cannot be created, the handler refuses to start
|
|
47
|
+
# ("--database is required") and Chrome aborts on every launch. /tmp is the one place such a
|
|
48
|
+
# container is expected to mount writable (--tmpfs /tmp).
|
|
49
|
+
ENV XDG_CONFIG_HOME=/tmp/xdg-config \
|
|
50
|
+
XDG_CACHE_HOME=/tmp/xdg-cache
|
|
51
|
+
|
|
45
52
|
USER appuser
|
|
46
53
|
|
|
47
54
|
CMD ["/usr/bin/bash"]
|
|
@@ -53,6 +53,13 @@ WORKDIR /app
|
|
|
53
53
|
RUN mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix
|
|
54
54
|
|
|
55
55
|
# Switch to non-root user
|
|
56
|
+
# Chromium's crash handler gets its database path from the XDG config dir. On a read-only root
|
|
57
|
+
# (docker run --read-only) that dir cannot be created, the handler refuses to start
|
|
58
|
+
# ("--database is required") and Chrome aborts on every launch. /tmp is the one place such a
|
|
59
|
+
# container is expected to mount writable (--tmpfs /tmp).
|
|
60
|
+
ENV XDG_CONFIG_HOME=/tmp/xdg-config \
|
|
61
|
+
XDG_CACHE_HOME=/tmp/xdg-cache
|
|
62
|
+
|
|
56
63
|
USER appuser
|
|
57
64
|
|
|
58
65
|
# RUN gem install chromedriver-binary && ruby -e 'require "chromedriver/binary"; puts Chromedriver::Binary::ChromedriverDownloader.update'
|
data/docker/Dockerfile.slim
CHANGED
|
@@ -73,6 +73,13 @@ WORKDIR /app
|
|
|
73
73
|
RUN chown -R appuser:appuser /app
|
|
74
74
|
|
|
75
75
|
# Switch to non-root user
|
|
76
|
+
# Chromium's crash handler gets its database path from the XDG config dir. On a read-only root
|
|
77
|
+
# (docker run --read-only) that dir cannot be created, the handler refuses to start
|
|
78
|
+
# ("--database is required") and Chrome aborts on every launch. /tmp is the one place such a
|
|
79
|
+
# container is expected to mount writable (--tmpfs /tmp).
|
|
80
|
+
ENV XDG_CONFIG_HOME=/tmp/xdg-config \
|
|
81
|
+
XDG_CACHE_HOME=/tmp/xdg-cache
|
|
82
|
+
|
|
76
83
|
USER appuser
|
|
77
84
|
|
|
78
85
|
CMD ["/usr/bin/bash"]
|
|
@@ -92,8 +92,17 @@ module Bidi2pdf
|
|
|
92
92
|
@listeners_mutex.synchronize { @listeners[event].dup }.each { |listener| listener.call(*) }
|
|
93
93
|
end
|
|
94
94
|
|
|
95
|
+
# The reader closes the socket from its own thread when the peer hangs up, and #close does not
|
|
96
|
+
# wait for a write in flight - so a write can fail with "stream closed in another thread" (or
|
|
97
|
+
# EPIPE) because *we* closed it. By then the connection is over and :close has been emitted;
|
|
98
|
+
# there is nothing left to report. Seen in the handshake write of #connect, which had no
|
|
99
|
+
# rescue: a server that answered and hung up at once made #connect raise although the
|
|
100
|
+
# handshake went through. Any other write failure still raises (#send and #say_goodbye
|
|
101
|
+
# handle it).
|
|
95
102
|
def write(bytes)
|
|
96
103
|
@write_mutex.synchronize { @socket&.write bytes }
|
|
104
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
|
|
105
|
+
raise unless @closed
|
|
97
106
|
end
|
|
98
107
|
|
|
99
108
|
def say_goodbye
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bidi2pdf
|
|
4
|
+
module Bidi
|
|
5
|
+
module Commands
|
|
6
|
+
# browsingContext.getTree - the session's top-level browsing contexts (tabs/windows).
|
|
7
|
+
class BrowsingContextGetTree
|
|
8
|
+
include Base
|
|
9
|
+
|
|
10
|
+
def method_name
|
|
11
|
+
"browsingContext.getTree"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bidi2pdf
|
|
4
|
+
module Bidi
|
|
5
|
+
module Commands
|
|
6
|
+
# goog:cdp.sendCommand - chromedriver's BiDi extension that runs one Chrome DevTools Protocol
|
|
7
|
+
# command; without a +session+ it runs at browser level (e.g. SystemInfo.getProcessInfo).
|
|
8
|
+
class CdpSendCommand
|
|
9
|
+
include Base
|
|
10
|
+
|
|
11
|
+
def initialize(method:, params: {}, session: nil)
|
|
12
|
+
@cdp_method = method
|
|
13
|
+
@cdp_params = params
|
|
14
|
+
@session = session
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def params = { method: @cdp_method, params: @cdp_params, session: @session }.compact
|
|
18
|
+
|
|
19
|
+
def method_name
|
|
20
|
+
"goog:cdp.sendCommand"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -4,6 +4,8 @@ module Bidi2pdf
|
|
|
4
4
|
module Bidi
|
|
5
5
|
module Commands
|
|
6
6
|
require_relative "commands/base"
|
|
7
|
+
require_relative "commands/browsing_context_get_tree"
|
|
8
|
+
require_relative "commands/cdp_send_command"
|
|
7
9
|
require_relative "commands/create_window"
|
|
8
10
|
require_relative "commands/create_tab"
|
|
9
11
|
require_relative "commands/add_intercept"
|
|
@@ -6,6 +6,7 @@ require "json"
|
|
|
6
6
|
require_relative "client"
|
|
7
7
|
require_relative "browser"
|
|
8
8
|
require_relative "user_context"
|
|
9
|
+
require_relative "../chromedriver_api"
|
|
9
10
|
|
|
10
11
|
# Represents a session for managing browser interactions and communication
|
|
11
12
|
# using the Bidi2pdf library. This class handles the setup, configuration,
|
|
@@ -74,16 +75,24 @@ module Bidi2pdf
|
|
|
74
75
|
# @return [Array<String>] The Chrome arguments for the session.
|
|
75
76
|
attr_reader :chrome_args
|
|
76
77
|
|
|
78
|
+
# @return [String, nil] chromedriver's id for this session, once it was created - what
|
|
79
|
+
# SessionWarmer records so a later process can close it if this one dies uncleanly.
|
|
80
|
+
attr_reader :session_id
|
|
81
|
+
|
|
77
82
|
# Initializes a new session.
|
|
78
83
|
#
|
|
79
84
|
# @param [String] session_url The URL for the session.
|
|
80
85
|
# @param [Boolean] headless Whether to run the browser in headless mode. Defaults to true.
|
|
81
86
|
# @param [Array<String>] chrome_args Additional Chrome arguments. Defaults to predefined arguments.
|
|
82
|
-
|
|
87
|
+
# @param [Bidi2pdf::SessionRegistry, nil] registry Records the session on a shared (remote)
|
|
88
|
+
# chromedriver while it is open, so a sweep can close it if this process dies without
|
|
89
|
+
# closing it (ChromeSweeper, SessionSweeper). nil records nothing.
|
|
90
|
+
def initialize(session_url:, headless: true, chrome_args: DEFAULT_CHROME_ARGS, registry: nil)
|
|
83
91
|
@session_uri = URI(session_url)
|
|
84
92
|
@headless = headless
|
|
85
93
|
@started = false
|
|
86
94
|
@chrome_args = chrome_args.dup
|
|
95
|
+
@registry = registry
|
|
87
96
|
end
|
|
88
97
|
|
|
89
98
|
# Starts the session and initializes the client.
|
|
@@ -113,34 +122,18 @@ module Bidi2pdf
|
|
|
113
122
|
@browser ||= create_browser
|
|
114
123
|
end
|
|
115
124
|
|
|
116
|
-
# Closes the session and cleans up resources.
|
|
117
|
-
#
|
|
125
|
+
# Closes the session and cleans up resources: BiDi +session.end+ first, and when Chrome does not
|
|
126
|
+
# answer that (a tab stuck in a loop), chromedriver's +DELETE /session/{id}+.
|
|
118
127
|
def close
|
|
119
128
|
return unless started?
|
|
120
129
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
payload[:response] = response
|
|
125
|
-
cleanup
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
true
|
|
129
|
-
rescue CmdTimeoutError => e
|
|
130
|
-
payload[:error] = e
|
|
131
|
-
payload[:retry] = attempt < 1 # whether we'll retry again
|
|
132
|
-
|
|
133
|
-
false
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
break if success
|
|
137
|
-
end
|
|
130
|
+
closed = end_via_bidi || delete_via_chromedriver
|
|
131
|
+
# Not closed: the entry stays, its lease runs out, and a sweeper takes the session.
|
|
132
|
+
closed ? @registry&.forget(session_id) : @registry&.release(session_id)
|
|
138
133
|
ensure
|
|
139
134
|
@started = false
|
|
140
135
|
end
|
|
141
136
|
|
|
142
|
-
# rubocop: enable Metrics/AbcSize
|
|
143
|
-
|
|
144
137
|
# Retrieves user contexts for the session.
|
|
145
138
|
def user_contexts
|
|
146
139
|
send_cmd(Bidi2pdf::Bidi::Commands::GetUserContexts.new) { |resp| Bidi2pdf.logger.debug "User contexts: #{resp}" }
|
|
@@ -227,10 +220,9 @@ module Bidi2pdf
|
|
|
227
220
|
value = session_data["value"]
|
|
228
221
|
handle_error(value) if value.nil? || value["error"]
|
|
229
222
|
|
|
230
|
-
|
|
223
|
+
record_session(value["sessionId"])
|
|
231
224
|
ws_url = value["capabilities"]["webSocketUrl"]
|
|
232
225
|
|
|
233
|
-
Bidi2pdf.logger.info "Created session with ID: #{session_id}"
|
|
234
226
|
Bidi2pdf.logger.info "WebSocket URL: #{ws_url}"
|
|
235
227
|
ws_url
|
|
236
228
|
end
|
|
@@ -349,6 +341,47 @@ module Bidi2pdf
|
|
|
349
341
|
"Session not started. Check logs for more details. Error: #{error} message: #{msg}"
|
|
350
342
|
end
|
|
351
343
|
|
|
344
|
+
def end_via_bidi
|
|
345
|
+
2.times.any? do |attempt|
|
|
346
|
+
Bidi2pdf.notification_service.instrument("session_close.bidi2pdf", { session_uri: session_uri.to_s, attempt: attempt + 1 }) do |payload|
|
|
347
|
+
client&.send_cmd_and_wait(Bidi2pdf::Bidi::Commands::SessionEnd.new, timeout: 1) do |response|
|
|
348
|
+
payload[:response] = response
|
|
349
|
+
cleanup
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
true
|
|
353
|
+
rescue WebsocketError => e
|
|
354
|
+
payload[:error] = e
|
|
355
|
+
payload[:retry] = attempt < 1 # whether we'll retry again
|
|
356
|
+
|
|
357
|
+
false
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def record_session(id)
|
|
363
|
+
@session_id = id
|
|
364
|
+
@registry&.hold(id)
|
|
365
|
+
Bidi2pdf.logger.info "Created session with ID: #{id}"
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# The BiDi session.end got no answer - typically a Chrome that hangs or crashed. chromedriver
|
|
369
|
+
# still ends the session (and kills its Chrome) on an HTTP DELETE, so a hung Chrome does not
|
|
370
|
+
# outlive this process. Only for a session this object created over HTTP (it knows the id).
|
|
371
|
+
#
|
|
372
|
+
# @return [Boolean] true when the session is gone.
|
|
373
|
+
def delete_via_chromedriver
|
|
374
|
+
return false unless session_id
|
|
375
|
+
|
|
376
|
+
outcome = Bidi2pdf::ChromedriverApi.new(session_uri.to_s).delete_session(session_id)
|
|
377
|
+
Bidi2pdf.notification_service.instrument("session_close_fallback.bidi2pdf", { session_uri: session_uri.to_s, outcome: outcome })
|
|
378
|
+
cleanup
|
|
379
|
+
%i[closed gone].include?(outcome)
|
|
380
|
+
rescue StandardError => e
|
|
381
|
+
Bidi2pdf.logger.warn "Closing session #{session_id} over HTTP failed: #{e.message}"
|
|
382
|
+
false
|
|
383
|
+
end
|
|
384
|
+
|
|
352
385
|
# Cleans up resources associated with the session.
|
|
353
386
|
def cleanup
|
|
354
387
|
@client&.close
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bidi2pdf
|
|
4
|
+
class ChromeSweeper
|
|
5
|
+
# Looks at one session on a chromedriver from the outside: attaches a second BiDi connection to
|
|
6
|
+
# its +webSocketUrl+ (chromedriver allows that for any session), asks for the tab tree, the first
|
|
7
|
+
# tab's +performance.timeOrigin+ (Chrome keeps no session start time anywhere - the first tab is
|
|
8
|
+
# created with the session, so its time origin is the session's age to within a few hundred ms)
|
|
9
|
+
# and the renderer CPU times (CDP +SystemInfo.getProcessInfo+ through +goog:cdp.sendCommand+).
|
|
10
|
+
#
|
|
11
|
+
# Read-only, and never reads page content: tab URLs are counted, not returned or logged (a
|
|
12
|
+
# +data:+ URL carries the whole rendered document). Closes its own connection every time.
|
|
13
|
+
class Inspector
|
|
14
|
+
# @!attribute age [Float, nil] seconds since the session started, nil when nothing tells.
|
|
15
|
+
# @!attribute source [Symbol] :registry, :tab or :unknown - where +age+ comes from.
|
|
16
|
+
# @!attribute responsive [Boolean] whether the session answered the checks.
|
|
17
|
+
# @!attribute cpu_times [Hash{Integer => Float}] renderer pid => CPU seconds.
|
|
18
|
+
# @!attribute live [Boolean] a live process holds its lease (never inspected, never closed).
|
|
19
|
+
SessionInfo = Data.define(:id, :age, :source, :tabs, :responsive, :cpu_times, :live) do
|
|
20
|
+
def initialize(live: false, **) = super
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
DEFAULT_TIMEOUT = 5
|
|
24
|
+
|
|
25
|
+
# @param clock [#call] epoch seconds as a Float.
|
|
26
|
+
# @param client_factory [#call] ws_url -> a started, open Bidi::Client; injectable for tests.
|
|
27
|
+
def initialize(timeout: DEFAULT_TIMEOUT, clock: -> { Time.now.to_f }, client_factory: nil)
|
|
28
|
+
@timeout = timeout
|
|
29
|
+
@clock = clock
|
|
30
|
+
@client_factory = client_factory || method(:connect)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param entry [ChromedriverApi::Entry]
|
|
34
|
+
# @param recorded_at [Numeric, nil] epoch seconds the registry has for it; wins over the tab.
|
|
35
|
+
# @return [SessionInfo]
|
|
36
|
+
def examine(entry, recorded_at: nil)
|
|
37
|
+
return unreachable(entry, recorded_at) if entry.websocket_url.nil?
|
|
38
|
+
|
|
39
|
+
client = @client_factory.call(entry.websocket_url)
|
|
40
|
+
probe(client, entry, recorded_at)
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
Bidi2pdf.logger.debug "chrome_sweeper: inspecting session #{entry.id} failed: #{e.message}"
|
|
43
|
+
unreachable(entry, recorded_at)
|
|
44
|
+
ensure
|
|
45
|
+
client&.close
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def probe(client, entry, recorded_at)
|
|
51
|
+
contexts = tree(client)
|
|
52
|
+
return unreachable(entry, recorded_at) if contexts.nil?
|
|
53
|
+
|
|
54
|
+
origin = contexts.empty? ? nil : time_origin(client, contexts.first["context"])
|
|
55
|
+
age, source = age_of(recorded_at, origin)
|
|
56
|
+
|
|
57
|
+
SessionInfo.new(id: entry.id, age: age, source: source, tabs: contexts.size,
|
|
58
|
+
responsive: contexts.empty? || !origin.nil?, cpu_times: cpu_times(client))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def tree(client)
|
|
62
|
+
once_more_on_timeout { command(client, Bidi2pdf::Bidi::Commands::BrowsingContextGetTree.new).dig("result", "contexts") || [] }
|
|
63
|
+
rescue Bidi2pdf::CmdTimeoutError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A tab stuck in an endless loop never evaluates anything - nil then.
|
|
68
|
+
def time_origin(client, context)
|
|
69
|
+
evaluate = Bidi2pdf::Bidi::Commands::ScriptEvaluate.new(expression: "performance.timeOrigin", context: context, await_promise: false)
|
|
70
|
+
value = once_more_on_timeout { command(client, evaluate) }.dig("result", "result", "value")
|
|
71
|
+
value.is_a?(Numeric) ? value / 1000.0 : nil
|
|
72
|
+
rescue Bidi2pdf::CmdError, Bidi2pdf::CmdTimeoutError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# On a freshly attached connection the first command to a session - and, seen once in the
|
|
77
|
+
# acceptance run, to a tab - can time out although the session is healthy (chromedriver
|
|
78
|
+
# 153/154); the second one answers. Only a second timeout counts.
|
|
79
|
+
def once_more_on_timeout
|
|
80
|
+
yield
|
|
81
|
+
rescue Bidi2pdf::CmdTimeoutError
|
|
82
|
+
yield
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def cpu_times(client)
|
|
86
|
+
processes = command(client, Bidi2pdf::Bidi::Commands::CdpSendCommand.new(method: "SystemInfo.getProcessInfo"))
|
|
87
|
+
.dig("result", "result", "processInfo") || []
|
|
88
|
+
processes.select { |process| process["type"] == "renderer" }.to_h { |process| [process["id"], process["cpuTime"].to_f] }
|
|
89
|
+
rescue Bidi2pdf::CmdError, Bidi2pdf::CmdTimeoutError
|
|
90
|
+
{}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def age_of(recorded_at, origin)
|
|
94
|
+
return [@clock.call - recorded_at, :registry] if recorded_at
|
|
95
|
+
return [@clock.call - origin, :tab] if origin
|
|
96
|
+
|
|
97
|
+
[nil, :unknown]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def unreachable(entry, recorded_at)
|
|
101
|
+
age, source = age_of(recorded_at, nil)
|
|
102
|
+
SessionInfo.new(id: entry.id, age: age, source: source, tabs: 0, responsive: false, cpu_times: {})
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def command(client, cmd)
|
|
106
|
+
client.send_cmd_and_wait(cmd, timeout: @timeout)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def connect(ws_url)
|
|
110
|
+
client = Bidi2pdf::Bidi::Client.new(ws_url)
|
|
111
|
+
client.start
|
|
112
|
+
client.wait_until_open(timeout: @timeout)
|
|
113
|
+
client
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|