bidi2pdf 0.1.17 → 0.1.19

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 +32 -2
  3. data/README.md +111 -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 +80 -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 +86 -0
  17. data/lib/bidi2pdf/session_registry.rb +77 -13
  18. data/lib/bidi2pdf/session_sweeper.rb +5 -3
  19. data/lib/bidi2pdf/session_warmer.rb +74 -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 +35 -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 +50 -0
  32. data/sig/bidi2pdf/session_registry.rbs +46 -9
  33. data/sig/bidi2pdf/session_sweeper.rbs +2 -1
  34. data/sig/bidi2pdf/session_warmer.rbs +42 -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: 60f338197fa95b778df1e96b3cb64c899e49c0a90b22ef9577b51f1a2eb85657
4
+ data.tar.gz: 13cded6812c061d65d0ac42579c29d5614007b08056094557b71902d347d6682
5
5
  SHA512:
6
- metadata.gz: 7fca9af905a088093ff4c8ac3a1c78b4f6138068c27ad4cf87616c7ded67f44b3d57aab73b40d83b6c9d8d63097f2851b10f5b0531a91bc22088ec15aab21769
7
- data.tar.gz: 835727303208ada398c0ec7456e29d49201d2b798a34edac35007c40ae5123e2a382a6e325e978237fd328ef0d58e6f573d61b1447df54bae51f9188775ab4c6
6
+ metadata.gz: c26da1531bd782b7e858eae98a53d3ec802f82f870b7649bcd40750dce747232ef2cb317a9881241e4ae49a08d70552be7e93923e84aad3837cd4dc1ce218359
7
+ data.tar.gz: 62ee4b751910b2631698797b38d91e2787f37d053d52b5af0e372fe5f76dcb27b06a10bcff43a630208916e7afadd8fee650877dfc2c6acbbfaa07814e0de025
data/CHANGELOG.md CHANGED
@@ -8,10 +8,38 @@ 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.19..HEAD
12
12
 
13
13
  <!-- generated by git-cliff end -->
14
14
 
15
+ ## [0.1.19] - 2026-09-30
16
+
17
+ ### 🐛 Fixed
18
+ - Keep lease times to the fraction of a second
19
+ - Let every lease carry its own TTL
20
+
21
+ ### 🔄 Changed
22
+ - Merge pull request #149 from dieter-medium/feat/lease-expiry
23
+
24
+ ## [0.1.18] - 2026-09-30
25
+
26
+ ### 🐛 Fixed
27
+ - Record warmer sessions for the sweeper
28
+ - Let one-shot sweeps close hung sessions
29
+
30
+ ### 🔄 Changed
31
+ - Merge pull request #146 from dieter-medium/feat/chrome-sweeper
32
+ - Merge pull request #141 from dieter-medium/dependabot/bundler/main/rubyzip-3.7.0
33
+ - Merge pull request #142 from dieter-medium/dependabot/bundler/main/simplecov-1.3.1
34
+
35
+ ### 🔧 Build
36
+ - Bump rubyzip from 3.6.0 to 3.7.0
37
+ - Bump simplecov from 1.3.0 to 1.3.1
38
+
39
+ ### 🚀 Added
40
+ - Lease sessions so sweeps spare live workers
41
+ - Add a Chrome sweeper for leaked sessions
42
+
15
43
  ## [0.1.17] - 2026-09-29
16
44
 
17
45
  ### 🐛 Fixed
@@ -495,7 +523,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
495
523
 
496
524
  ### 🔄 Released
497
525
 
498
- - [unreleased](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.17..HEAD)
526
+ - [unreleased](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.19..HEAD)
527
+ - [0.1.19](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.18..v0.1.19)
528
+ - [0.1.18](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.17..v0.1.18)
499
529
  - [0.1.17](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.16..v0.1.17)
500
530
  - [0.1.16](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.15..v0.1.16)
501
531
  - [0.1.15](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.14..v0.1.15)
data/README.md CHANGED
@@ -572,6 +572,8 @@ 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. |
576
+ | `retry_refused_sessions` | `true` | With a `sweeper`: when chromedriver refuses a new session, sweep under pressure and try once more. `false` leaves it to the caller. |
575
577
 
576
578
  #### Leftover sessions on a shared chromedriver
577
579
 
@@ -589,12 +591,121 @@ Everything here is fail-open: if the registry directory is not writable, the war
589
591
  warning, instruments `session_warmer.registry_unavailable.bidi2pdf`, and keeps rendering - only the
590
592
  cleanup is off. Closed leftovers are reported as `session_warmer.orphans_closed.bidi2pdf`.
591
593
 
594
+ With a `sweeper` configured, the warmer also sweeps in the background every `interval` seconds,
595
+ and when chromedriver refuses a new session ("session not created" - typically a container out of
596
+ room for another Chrome) it sweeps once and tries once more.
597
+
592
598
  > **Security note:** an idle warm session is an open, unauthenticated automation endpoint
593
599
  > (chromedriver's port, Chrome's debugging port - loopback only for a local chromedriver) for as
594
600
  > long as it waits. `max_idle_age` bounds that window; keep it set unless the process runs somewhere
595
601
  > nothing else can reach those ports. With `remote_browser_url`, who can reach that endpoint on the
596
602
  > network is what matters, exactly as it does without the warmer.
597
603
 
604
+ ### Leaked Chrome sessions (`Bidi2pdf::ChromeSweeper`)
605
+
606
+ The registry above only catches what a warmer recorded and only when the next warmer starts. A
607
+ `ChromeSweeper` covers the rest: sessions a crashed process never recorded, a Chrome stuck in a tab
608
+ that never returns, and too many sessions for the container. Run it once, when the application
609
+ suspects a leak, or periodically:
610
+
611
+ ```ruby
612
+ sweeper = Bidi2pdf::ChromeSweeper.new("http://remote-chrome:3000/session",
613
+ scope: :all, max_sessions: :auto, pids_limit: 1024, interval: 60)
614
+ sweeper.start # background sweeps until sweeper.stop
615
+ result = sweeper.sweep! # or one sweep right now
616
+ result.closed # => [#<data Closed id="…", age=734, why=:orphan>]
617
+
618
+ # one-shot: checks twice, 10 s apart, so the unresponsive rule applies too
619
+ Bidi2pdf::ChromeSweeper.sweep!("http://remote-chrome:3000/session", check_interval: 10, dry_run: true)
620
+
621
+ # last resort: a render failed for lack of resources - sweep under pressure, try once more
622
+ Bidi2pdf::ChromeSweeper.with_retry("http://remote-chrome:3000/session", scope: :all) do
623
+ render_the_pdf # must be safe to run twice
624
+ end
625
+ ```
626
+
627
+ It closes, in this order: sessions older than `orphan_age`; sessions that failed
628
+ `unresponsive_checks` checks in a row (no answer, or a renderer burning a whole CPU between two
629
+ sweeps - an endless loop in a page); and while more than `max_sessions` exist, the oldest ones.
630
+ It never closes a session a live bidi2pdf process holds (below), a session in `own_sessions`, or
631
+ one younger than `min_age` - not even over the limit. When the limit can only be kept by closing
632
+ those, it closes nothing more and reports `limit_exceeded`. A session is closed with chromedriver's
633
+ `DELETE /session/{id}`, which also ends a Chrome that no longer answers BiDi.
634
+
635
+ **Live sessions of other processes (leases).** Several processes often share one chromedriver - Puma
636
+ workers, a job worker - and each has renders in flight and warm spares the others know nothing
637
+ about. So every session bidi2pdf opens is recorded in the registry with a lease, and a heartbeat
638
+ thread in the owning process renews it every 20 s (`SessionRegistry::Heartbeat.interval`) while the
639
+ session is open. Every lease carries its own TTL - three heartbeats of the process that wrote it -
640
+ so a sweeper never has to guess how often another process renews. A session whose lease is still
641
+ fresh belongs to a live process: no sweeper in any process closes it, and it is not even inspected.
642
+ The warmer leases its sessions in remote mode too, with or without a sweeper. When the process dies - killed, crashed, OOM - the lease runs out and the
643
+ session becomes a leftover like any other. Only processes that share the registry directory see
644
+ each other's leases: give job workers in another container the same `registry_dir` on a shared
645
+ volume. Sessions other tools opened have no lease; under `scope: :all` only `min_age` protects them.
646
+
647
+ **Last resort: pressure.** `sweep!(pressure: true)` closes every session nobody holds that is past
648
+ `min_age`, without waiting for `orphan_age`, the unresponsive checks or the limit. `with_retry` does
649
+ that when its block fails with a resource error - chromedriver refusing a session
650
+ (`SessionNotStartedError`), or a connection or command dying or timing out (`WebsocketError`, but
651
+ not `CmdError`, which is the page's problem) - and runs the block once more; `retry_on:` takes other
652
+ error classes. It frees exactly what belongs to no live process: if the chromedriver is full of live
653
+ renders, the second attempt fails too, and that error is raised. The warmer does the same when a new
654
+ session is refused.
655
+
656
+ | Setting | Default | Description |
657
+ |-----------------------|-------------|--------------------------------------------------------------------------------------------------------------|
658
+ | `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. |
659
+ | `orphan_age` | `600` | Close sessions older than this many seconds. `nil` = off. |
660
+ | `min_age` | `60` | Never close a session younger than this. |
661
+ | `unresponsive_checks` | `2` | Close a session after this many failed checks in a row. `nil` = off. |
662
+ | `max_sessions` | `nil` | Session limit. `:auto` = floor(`pids_limit` × `pids_budget` / `threads_per_session`), e.g. 1024 → 7, 512 → 3. |
663
+ | `pids_limit` | `nil` | The chromedriver container's pids limit (Docker counts threads). `:auto` without it means no limit. |
664
+ | `pids_budget` | `0.8` | Share of `pids_limit` the Chrome sessions may use. |
665
+ | `threads_per_session` | `110` | Threads one Chrome session uses. |
666
+ | `interval` | `nil` | Seconds between background sweeps (`start`/`stop`). |
667
+ | `dry_run` | `false` | Report what would be closed, close nothing. |
668
+ | `registry_dir` | `Dir.tmpdir`| The registry to read and update - same meaning as the warmer's setting. |
669
+ | `own_sessions` | `-> { [] }` | A callable returning the caller's live session ids; they are never touched. |
670
+ | `lease_ttl` | `60` | Only for registry entries written by bidi2pdf 0.1.18, which carry no TTL of their own: renewed within this many seconds counts as live. |
671
+
672
+ How it tells a session's age: chromedriver's `GET /sessions` lists every session but no start time,
673
+ and Chrome keeps none either. So the sweeper uses the registry time when there is one and otherwise
674
+ attaches a second BiDi connection to the session and reads its first tab's
675
+ `performance.timeOrigin` - that tab is created with the session. It only asks for the tab tree,
676
+ that one value and the renderer CPU times; it never reads page content or logs a tab's URL.
677
+
678
+ A session counts as unresponsive only after `unresponsive_checks` failed checks, and every sweep is
679
+ one check. A periodic sweeper gets there by itself; a single `sweep!` does only with
680
+ `check_interval:` - it then checks `unresponsive_checks - 1` times first (`observe`, which closes
681
+ nothing), that many seconds apart. Without it a one-shot sweep closes only old sessions and those
682
+ over the limit.
683
+
684
+ Only one sweep runs at a time, across processes too (a lock file next to the registry). A sweep never
685
+ raises: failures are logged and returned in `Result#errors`. Every remote `Bidi2pdf::Bidi::Session`
686
+ is now recorded and leased in the registry, not only warmer slots, and its `close` falls back to the
687
+ HTTP `DELETE` when Chrome does not answer; a session it still could not close keeps its entry but
688
+ loses its lease, so a sweeper takes it.
689
+
690
+ Notifications: `chrome_sweeper.sweep.bidi2pdf` (every sweep: counts, reason, duration),
691
+ `chrome_sweeper.closed.bidi2pdf` (id, age, why), `chrome_sweeper.unresponsive.bidi2pdf`,
692
+ `chrome_sweeper.limit_exceeded.bidi2pdf`, `chrome_sweeper.failed.bidi2pdf`,
693
+ `chrome_sweeper.retry.bidi2pdf` (`with_retry` sweeps and tries again), and
694
+ `session_close_fallback.bidi2pdf` when a close needed the HTTP `DELETE`.
695
+
696
+ From the command line:
697
+
698
+ ```bash
699
+ # id, age, where the age comes from, tabs, responsive or live - no URLs
700
+ bidi2pdf sessions --remote-browser-url http://remote-chrome:3000/session [--scope recorded] [--json]
701
+
702
+ # exits 1 when a close failed or the limit is still exceeded; checks each session
703
+ # --unresponsive-checks times, --check-interval seconds apart (default 10; 0 = sweep at once)
704
+ bidi2pdf sweep --remote-browser-url http://remote-chrome:3000/session \
705
+ [--scope all] [--older-than 600] [--max-sessions 3] [--min-age 60] \
706
+ [--unresponsive-checks 2] [--check-interval 10] [--pressure] [--dry-run] [--json]
707
+ ```
708
+
598
709
  ### Customizing Chrome arguments (and blank PDFs from inline HTML)
599
710
 
600
711
  `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,80 @@
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] for registry entries without a TTL of their own (written by
27
+ # bidi2pdf 0.1.18): renewed within this many seconds means a live process holds it. Entries
28
+ # written since carry the TTL their owner's heartbeat promises (SessionRegistry#hold).
29
+ Settings = Data.define(:scope, :orphan_age, :min_age, :unresponsive_checks, :max_sessions, :pids_limit,
30
+ :pids_budget, :threads_per_session, :interval, :dry_run, :lease_ttl) do
31
+ # rubocop:disable-next Metrics/ParameterLists
32
+ def initialize(scope: :recorded, orphan_age: DEFAULT_ORPHAN_AGE, min_age: DEFAULT_MIN_AGE,
33
+ unresponsive_checks: DEFAULT_UNRESPONSIVE_CHECKS, max_sessions: nil, pids_limit: nil,
34
+ pids_budget: DEFAULT_PIDS_BUDGET, threads_per_session: DEFAULT_THREADS_PER_SESSION,
35
+ interval: nil, dry_run: false, lease_ttl: DEFAULT_LEASE_TTL)
36
+ super
37
+ validate_scope!
38
+ validate_numbers!
39
+ end
40
+
41
+ # The session limit in force: +max_sessions+, or for +:auto+ floor(pids_limit x pids_budget /
42
+ # threads_per_session), at least 1 - nil (no limit) when +:auto+ has no pids_limit.
43
+ def limit
44
+ return max_sessions unless max_sessions == :auto
45
+ return nil unless pids_limit
46
+
47
+ [(pids_limit * pids_budget / threads_per_session).floor, 1].max
48
+ end
49
+
50
+ private
51
+
52
+ def validate_scope!
53
+ return if SCOPES.include?(scope)
54
+
55
+ raise Bidi2pdf::InvalidConfigError, "chrome_sweeper: scope must be one of #{SCOPES.inspect}, got #{scope.inspect}"
56
+ end
57
+
58
+ def validate_numbers!
59
+ to_h.slice(:orphan_age, :interval, :pids_limit).each { |name, value| positive!(name, value, allow_nil: true) }
60
+ to_h.slice(:pids_budget, :threads_per_session, :lease_ttl).each { |name, value| positive!(name, value) }
61
+ positive!(:min_age, min_age, allow_zero: true)
62
+ count!(:unresponsive_checks, unresponsive_checks)
63
+ count!(:max_sessions, max_sessions) unless max_sessions == :auto
64
+ end
65
+
66
+ def positive!(name, value, allow_nil: false, allow_zero: false)
67
+ return if value.nil? && allow_nil
68
+ return if value.is_a?(Numeric) && (value.positive? || (allow_zero && value.zero?))
69
+
70
+ raise Bidi2pdf::InvalidConfigError, "chrome_sweeper: #{name} must be a positive number, got #{value.inspect}"
71
+ end
72
+
73
+ def count!(name, value)
74
+ return if value.nil? || (value.is_a?(Integer) && value.positive?)
75
+
76
+ raise Bidi2pdf::InvalidConfigError, "chrome_sweeper: #{name} must be nil or a positive Integer, got #{value.inspect}"
77
+ end
78
+ end
79
+ end
80
+ end