bidi2pdf 0.1.17 → 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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -2
  3. data/README.md +108 -0
  4. data/lib/bidi2pdf/bidi/commands/browsing_context_get_tree.rb +16 -0
  5. data/lib/bidi2pdf/bidi/commands/cdp_send_command.rb +25 -0
  6. data/lib/bidi2pdf/bidi/commands.rb +2 -0
  7. data/lib/bidi2pdf/bidi/session.rb +53 -24
  8. data/lib/bidi2pdf/chrome_sweeper/inspector.rb +117 -0
  9. data/lib/bidi2pdf/chrome_sweeper/settings.rb +79 -0
  10. data/lib/bidi2pdf/chrome_sweeper.rb +398 -0
  11. data/lib/bidi2pdf/chromedriver_api.rb +72 -0
  12. data/lib/bidi2pdf/cli/session_commands.rb +82 -0
  13. data/lib/bidi2pdf/cli.rb +43 -0
  14. data/lib/bidi2pdf/dsl.rb +2 -1
  15. data/lib/bidi2pdf/launcher.rb +2 -1
  16. data/lib/bidi2pdf/session_registry/heartbeat.rb +83 -0
  17. data/lib/bidi2pdf/session_registry.rb +67 -12
  18. data/lib/bidi2pdf/session_sweeper.rb +4 -2
  19. data/lib/bidi2pdf/session_warmer.rb +68 -7
  20. data/lib/bidi2pdf/version.rb +1 -1
  21. data/lib/bidi2pdf.rb +2 -0
  22. data/sig/bidi2pdf/bidi/commands/browsing_context_get_tree.rbs +12 -0
  23. data/sig/bidi2pdf/bidi/commands/cdp_send_command.rbs +23 -0
  24. data/sig/bidi2pdf/bidi/session.rbs +19 -3
  25. data/sig/bidi2pdf/chrome_sweeper/inspector.rbs +61 -0
  26. data/sig/bidi2pdf/chrome_sweeper/settings.rbs +34 -0
  27. data/sig/bidi2pdf/chrome_sweeper.rbs +220 -0
  28. data/sig/bidi2pdf/chromedriver_api.rbs +44 -0
  29. data/sig/bidi2pdf/cli/session_commands.rbs +35 -0
  30. data/sig/bidi2pdf/cli.rbs +6 -0
  31. data/sig/bidi2pdf/session_registry/heartbeat.rbs +47 -0
  32. data/sig/bidi2pdf/session_registry.rbs +39 -8
  33. data/sig/bidi2pdf/session_sweeper.rbs +2 -1
  34. data/sig/bidi2pdf/session_warmer.rbs +35 -2
  35. data/sig/bidi2pdf/version.rbs +1 -1
  36. metadata +17 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b879e9c84ccd1584f5b2eb1b406972624cd8b64a50c92466e78f7275ef229441
4
- data.tar.gz: 50d517dac875995c2a06ca39dcd8c521c0b18fd238b6950d5e0d9674187941dd
3
+ metadata.gz: fa0fc469533226ce79d16bb401f4c47093dda42d4a68af67453fab79e086ad65
4
+ data.tar.gz: 9e4a1176ad42c03ced1544dcb9022a3dfe7d2663b0d8960f41bea863d3b0e788
5
5
  SHA512:
6
- metadata.gz: 7fca9af905a088093ff4c8ac3a1c78b4f6138068c27ad4cf87616c7ded67f44b3d57aab73b40d83b6c9d8d63097f2851b10f5b0531a91bc22088ec15aab21769
7
- data.tar.gz: 835727303208ada398c0ec7456e29d49201d2b798a34edac35007c40ae5123e2a382a6e325e978237fd328ef0d58e6f573d61b1447df54bae51f9188775ab4c6
6
+ metadata.gz: ddd7621c394b454a1df5b6071f474b1bd8699a4de4086e47c7185b01df66476ccad28d7e843b17e79aebf198489053bc237c345a3f638fb4e25e907aa4dc04f1
7
+ data.tar.gz: 134e15de59f8d5d7044be15308b1490988bc7eddd0134a9de05feed3accf3d56c52818125da49d36ad91263f6d16d8e58dc6549c85f5897316e9dfab7e60fb73
data/CHANGELOG.md CHANGED
@@ -8,10 +8,29 @@ 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.17..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
+
15
34
  ## [0.1.17] - 2026-09-29
16
35
 
17
36
  ### 🐛 Fixed
@@ -495,7 +514,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
495
514
 
496
515
  ### 🔄 Released
497
516
 
498
- - [unreleased](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.17..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)
499
519
  - [0.1.17](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.16..v0.1.17)
500
520
  - [0.1.16](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.15..v0.1.16)
501
521
  - [0.1.15](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.14..v0.1.15)
data/README.md CHANGED
@@ -572,6 +572,7 @@ Bidi2pdf::SessionWarmer.shutdown
572
572
  | `remote_browser_url` | `nil` | Connect each slot to a remote chromedriver instead of starting a local one. |
573
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
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`. |
575
576
 
576
577
  #### Leftover sessions on a shared chromedriver
577
578
 
@@ -589,12 +590,119 @@ Everything here is fail-open: if the registry directory is not writable, the war
589
590
  warning, instruments `session_warmer.registry_unavailable.bidi2pdf`, and keeps rendering - only the
590
591
  cleanup is off. Closed leftovers are reported as `session_warmer.orphans_closed.bidi2pdf`.
591
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.
596
+
592
597
  > **Security note:** an idle warm session is an open, unauthenticated automation endpoint
593
598
  > (chromedriver's port, Chrome's debugging port - loopback only for a local chromedriver) for as
594
599
  > long as it waits. `max_idle_age` bounds that window; keep it set unless the process runs somewhere
595
600
  > nothing else can reach those ports. With `remote_browser_url`, who can reach that endpoint on the
596
601
  > network is what matters, exactly as it does without the warmer.
597
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
+
598
706
  ### Customizing Chrome arguments (and blank PDFs from inline HTML)
599
707
 
600
708
  `Bidi2pdf::Bidi::Session::DEFAULT_CHROME_ARGS` already contains one `--disable-features=...` entry,
@@ -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,
@@ -83,11 +84,15 @@ module Bidi2pdf
83
84
  # @param [String] session_url The URL for the session.
84
85
  # @param [Boolean] headless Whether to run the browser in headless mode. Defaults to true.
85
86
  # @param [Array<String>] chrome_args Additional Chrome arguments. Defaults to predefined arguments.
86
- 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)
87
91
  @session_uri = URI(session_url)
88
92
  @headless = headless
89
93
  @started = false
90
94
  @chrome_args = chrome_args.dup
95
+ @registry = registry
91
96
  end
92
97
 
93
98
  # Starts the session and initializes the client.
@@ -117,34 +122,18 @@ module Bidi2pdf
117
122
  @browser ||= create_browser
118
123
  end
119
124
 
120
- # Closes the session and cleans up resources.
121
- # 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}+.
122
127
  def close
123
128
  return unless started?
124
129
 
125
- 2.times do |attempt|
126
- success = Bidi2pdf.notification_service.instrument("session_close.bidi2pdf", { session_uri: session_uri.to_s, attempt: attempt + 1 }) do |payload|
127
- client&.send_cmd_and_wait(Bidi2pdf::Bidi::Commands::SessionEnd.new, timeout: 1) do |response|
128
- payload[:response] = response
129
- cleanup
130
- end
131
-
132
- true
133
- rescue CmdTimeoutError => e
134
- payload[:error] = e
135
- payload[:retry] = attempt < 1 # whether we'll retry again
136
-
137
- false
138
- end
139
-
140
- break if success
141
- 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)
142
133
  ensure
143
134
  @started = false
144
135
  end
145
136
 
146
- # rubocop: enable Metrics/AbcSize
147
-
148
137
  # Retrieves user contexts for the session.
149
138
  def user_contexts
150
139
  send_cmd(Bidi2pdf::Bidi::Commands::GetUserContexts.new) { |resp| Bidi2pdf.logger.debug "User contexts: #{resp}" }
@@ -231,10 +220,9 @@ module Bidi2pdf
231
220
  value = session_data["value"]
232
221
  handle_error(value) if value.nil? || value["error"]
233
222
 
234
- @session_id = value["sessionId"]
223
+ record_session(value["sessionId"])
235
224
  ws_url = value["capabilities"]["webSocketUrl"]
236
225
 
237
- Bidi2pdf.logger.info "Created session with ID: #{session_id}"
238
226
  Bidi2pdf.logger.info "WebSocket URL: #{ws_url}"
239
227
  ws_url
240
228
  end
@@ -353,6 +341,47 @@ module Bidi2pdf
353
341
  "Session not started. Check logs for more details. Error: #{error} message: #{msg}"
354
342
  end
355
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
+
356
385
  # Cleans up resources associated with the session.
357
386
  def cleanup
358
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
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bidi2pdf
4
+ class ChromeSweeper
5
+ SCOPES = %i[recorded all].freeze
6
+ DEFAULT_ORPHAN_AGE = 600
7
+ DEFAULT_MIN_AGE = 60
8
+ DEFAULT_UNRESPONSIVE_CHECKS = 2
9
+ DEFAULT_PIDS_BUDGET = 0.8
10
+ DEFAULT_THREADS_PER_SESSION = 110
11
+ DEFAULT_LEASE_TTL = SessionRegistry::DEFAULT_LEASE_TTL
12
+
13
+ # What a ChromeSweeper closes and when - validated on construction; an unknown setting raises
14
+ # ArgumentError.
15
+ #
16
+ # @!attribute scope [Symbol] :recorded (sessions a SessionRegistry knows) or :all.
17
+ # @!attribute orphan_age [Numeric, nil] close sessions older than this; nil turns the rule off.
18
+ # @!attribute min_age [Numeric] never close a session younger than this.
19
+ # @!attribute unresponsive_checks [Integer, nil] close after this many failed checks in a row.
20
+ # @!attribute max_sessions [Integer, Symbol, nil] the limit; +:auto+ derives it from pids_limit.
21
+ # @!attribute pids_limit [Integer, nil] the chromedriver container's pids limit, for +:auto+.
22
+ # @!attribute pids_budget [Float] share of +pids_limit+ Chrome sessions may use.
23
+ # @!attribute threads_per_session [Integer] threads (Docker counts them as pids) of one session.
24
+ # @!attribute interval [Numeric, nil] seconds between sweeps of ChromeSweeper#start's thread.
25
+ # @!attribute dry_run [Boolean] report what would be closed, close nothing.
26
+ # @!attribute lease_ttl [Numeric] a recorded session renewed within this many seconds belongs to
27
+ # a live process and is never closed (SessionRegistry#hold).
28
+ Settings = Data.define(:scope, :orphan_age, :min_age, :unresponsive_checks, :max_sessions, :pids_limit,
29
+ :pids_budget, :threads_per_session, :interval, :dry_run, :lease_ttl) do
30
+ # rubocop:disable-next Metrics/ParameterLists
31
+ def initialize(scope: :recorded, orphan_age: DEFAULT_ORPHAN_AGE, min_age: DEFAULT_MIN_AGE,
32
+ unresponsive_checks: DEFAULT_UNRESPONSIVE_CHECKS, max_sessions: nil, pids_limit: nil,
33
+ pids_budget: DEFAULT_PIDS_BUDGET, threads_per_session: DEFAULT_THREADS_PER_SESSION,
34
+ interval: nil, dry_run: false, lease_ttl: DEFAULT_LEASE_TTL)
35
+ super
36
+ validate_scope!
37
+ validate_numbers!
38
+ end
39
+
40
+ # The session limit in force: +max_sessions+, or for +:auto+ floor(pids_limit x pids_budget /
41
+ # threads_per_session), at least 1 - nil (no limit) when +:auto+ has no pids_limit.
42
+ def limit
43
+ return max_sessions unless max_sessions == :auto
44
+ return nil unless pids_limit
45
+
46
+ [(pids_limit * pids_budget / threads_per_session).floor, 1].max
47
+ end
48
+
49
+ private
50
+
51
+ def validate_scope!
52
+ return if SCOPES.include?(scope)
53
+
54
+ raise Bidi2pdf::InvalidConfigError, "chrome_sweeper: scope must be one of #{SCOPES.inspect}, got #{scope.inspect}"
55
+ end
56
+
57
+ def validate_numbers!
58
+ to_h.slice(:orphan_age, :interval, :pids_limit).each { |name, value| positive!(name, value, allow_nil: true) }
59
+ to_h.slice(:pids_budget, :threads_per_session, :lease_ttl).each { |name, value| positive!(name, value) }
60
+ positive!(:min_age, min_age, allow_zero: true)
61
+ count!(:unresponsive_checks, unresponsive_checks)
62
+ count!(:max_sessions, max_sessions) unless max_sessions == :auto
63
+ end
64
+
65
+ def positive!(name, value, allow_nil: false, allow_zero: false)
66
+ return if value.nil? && allow_nil
67
+ return if value.is_a?(Numeric) && (value.positive? || (allow_zero && value.zero?))
68
+
69
+ raise Bidi2pdf::InvalidConfigError, "chrome_sweeper: #{name} must be a positive number, got #{value.inspect}"
70
+ end
71
+
72
+ def count!(name, value)
73
+ return if value.nil? || (value.is_a?(Integer) && value.positive?)
74
+
75
+ raise Bidi2pdf::InvalidConfigError, "chrome_sweeper: #{name} must be nil or a positive Integer, got #{value.inspect}"
76
+ end
77
+ end
78
+ end
79
+ end