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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -2
  3. data/README.md +145 -0
  4. data/docker/Dockerfile +7 -0
  5. data/docker/Dockerfile.chromedriver +7 -0
  6. data/docker/Dockerfile.slim +7 -0
  7. data/lib/bidi2pdf/bidi/buffered_web_socket_client.rb +9 -0
  8. data/lib/bidi2pdf/bidi/commands/browsing_context_get_tree.rb +16 -0
  9. data/lib/bidi2pdf/bidi/commands/cdp_send_command.rb +25 -0
  10. data/lib/bidi2pdf/bidi/commands.rb +2 -0
  11. data/lib/bidi2pdf/bidi/session.rb +57 -24
  12. data/lib/bidi2pdf/chrome_sweeper/inspector.rb +117 -0
  13. data/lib/bidi2pdf/chrome_sweeper/settings.rb +79 -0
  14. data/lib/bidi2pdf/chrome_sweeper.rb +398 -0
  15. data/lib/bidi2pdf/chromedriver_api.rb +72 -0
  16. data/lib/bidi2pdf/cli/session_commands.rb +82 -0
  17. data/lib/bidi2pdf/cli.rb +43 -0
  18. data/lib/bidi2pdf/dsl.rb +2 -1
  19. data/lib/bidi2pdf/launcher.rb +2 -1
  20. data/lib/bidi2pdf/session_registry/heartbeat.rb +83 -0
  21. data/lib/bidi2pdf/session_registry.rb +152 -0
  22. data/lib/bidi2pdf/session_sweeper.rb +66 -0
  23. data/lib/bidi2pdf/session_warmer.rb +112 -1
  24. data/lib/bidi2pdf/version.rb +1 -1
  25. data/lib/bidi2pdf.rb +4 -0
  26. data/sig/bidi2pdf/bidi/buffered_web_socket_client.rbs +7 -0
  27. data/sig/bidi2pdf/bidi/commands/browsing_context_get_tree.rbs +12 -0
  28. data/sig/bidi2pdf/bidi/commands/cdp_send_command.rbs +23 -0
  29. data/sig/bidi2pdf/bidi/session.rbs +25 -3
  30. data/sig/bidi2pdf/chrome_sweeper/inspector.rbs +61 -0
  31. data/sig/bidi2pdf/chrome_sweeper/settings.rbs +34 -0
  32. data/sig/bidi2pdf/chrome_sweeper.rbs +220 -0
  33. data/sig/bidi2pdf/chromedriver_api.rbs +44 -0
  34. data/sig/bidi2pdf/cli/session_commands.rbs +35 -0
  35. data/sig/bidi2pdf/cli.rbs +6 -0
  36. data/sig/bidi2pdf/session_registry/heartbeat.rbs +47 -0
  37. data/sig/bidi2pdf/session_registry.rbs +75 -0
  38. data/sig/bidi2pdf/session_sweeper.rbs +37 -0
  39. data/sig/bidi2pdf/session_warmer.rbs +66 -0
  40. data/sig/bidi2pdf/version.rbs +1 -1
  41. metadata +29 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3533d126bc13406c70b6931112c07952aac0c19b718f3100258fb2182da8fcea
4
- data.tar.gz: a761495802d5507fba3469405a571555547ed58891b216575f947e2b6a0c63be
3
+ metadata.gz: fa0fc469533226ce79d16bb401f4c47093dda42d4a68af67453fab79e086ad65
4
+ data.tar.gz: 9e4a1176ad42c03ced1544dcb9022a3dfe7d2663b0d8960f41bea863d3b0e788
5
5
  SHA512:
6
- metadata.gz: cb29c3bf90a6a6dbd6fcf488980c51b9f666206ea18748d0c670e6fc2a64c453ca0f20043dce113094e74eb34d4b45f6be8b7d7a433522a59ac6ebab2d6ba8bb
7
- data.tar.gz: bb9c384545c42d45630576b523c3c82025b1df2a2fc8975b8959276566173ad684b9423e7f30fc46b7834c61328ef68f9ddb1ee73d83b08e0ec8ea10b93f117f
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.16..HEAD
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.16..HEAD)
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'
@@ -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
- def initialize(session_url:, headless: true, chrome_args: DEFAULT_CHROME_ARGS)
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
- # rubocop:disable Metrics/AbcSize
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
- 2.times do |attempt|
122
- success = Bidi2pdf.notification_service.instrument("session_close.bidi2pdf", { session_uri: session_uri.to_s, attempt: attempt + 1 }) do |payload|
123
- client&.send_cmd_and_wait(Bidi2pdf::Bidi::Commands::SessionEnd.new, timeout: 1) do |response|
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
- session_id = value["sessionId"]
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