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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -2
- data/README.md +108 -0
- data/lib/bidi2pdf/bidi/commands/browsing_context_get_tree.rb +16 -0
- data/lib/bidi2pdf/bidi/commands/cdp_send_command.rb +25 -0
- data/lib/bidi2pdf/bidi/commands.rb +2 -0
- data/lib/bidi2pdf/bidi/session.rb +53 -24
- data/lib/bidi2pdf/chrome_sweeper/inspector.rb +117 -0
- data/lib/bidi2pdf/chrome_sweeper/settings.rb +79 -0
- data/lib/bidi2pdf/chrome_sweeper.rb +398 -0
- data/lib/bidi2pdf/chromedriver_api.rb +72 -0
- data/lib/bidi2pdf/cli/session_commands.rb +82 -0
- data/lib/bidi2pdf/cli.rb +43 -0
- data/lib/bidi2pdf/dsl.rb +2 -1
- data/lib/bidi2pdf/launcher.rb +2 -1
- data/lib/bidi2pdf/session_registry/heartbeat.rb +83 -0
- data/lib/bidi2pdf/session_registry.rb +67 -12
- data/lib/bidi2pdf/session_sweeper.rb +4 -2
- data/lib/bidi2pdf/session_warmer.rb +68 -7
- data/lib/bidi2pdf/version.rb +1 -1
- data/lib/bidi2pdf.rb +2 -0
- data/sig/bidi2pdf/bidi/commands/browsing_context_get_tree.rbs +12 -0
- data/sig/bidi2pdf/bidi/commands/cdp_send_command.rbs +23 -0
- data/sig/bidi2pdf/bidi/session.rbs +19 -3
- data/sig/bidi2pdf/chrome_sweeper/inspector.rbs +61 -0
- data/sig/bidi2pdf/chrome_sweeper/settings.rbs +34 -0
- data/sig/bidi2pdf/chrome_sweeper.rbs +220 -0
- data/sig/bidi2pdf/chromedriver_api.rbs +44 -0
- data/sig/bidi2pdf/cli/session_commands.rbs +35 -0
- data/sig/bidi2pdf/cli.rbs +6 -0
- data/sig/bidi2pdf/session_registry/heartbeat.rbs +47 -0
- data/sig/bidi2pdf/session_registry.rbs +39 -8
- data/sig/bidi2pdf/session_sweeper.rbs +2 -1
- data/sig/bidi2pdf/session_warmer.rbs +35 -2
- data/sig/bidi2pdf/version.rbs +1 -1
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa0fc469533226ce79d16bb401f4c47093dda42d4a68af67453fab79e086ad65
|
|
4
|
+
data.tar.gz: 9e4a1176ad42c03ced1544dcb9022a3dfe7d2663b0d8960f41bea863d3b0e788
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ddd7621c394b454a1df5b6071f474b1bd8699a4de4086e47c7185b01df66476ccad28d7e843b17e79aebf198489053bc237c345a3f638fb4e25e907aa4dc04f1
|
|
7
|
+
data.tar.gz: 134e15de59f8d5d7044be15308b1490988bc7eddd0134a9de05feed3accf3d56c52818125da49d36ad91263f6d16d8e58dc6549c85f5897316e9dfab7e60fb73
|
data/CHANGELOG.md
CHANGED
|
@@ -8,10 +8,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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|