bidi2pdf 0.1.25 → 0.1.26
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 +17 -2
- data/README.md +102 -1
- data/lib/bidi2pdf/dsl.rb +11 -6
- data/lib/bidi2pdf/error_codes.rb +1 -0
- data/lib/bidi2pdf/session_gate.rb +109 -0
- data/lib/bidi2pdf/session_runner.rb +15 -16
- data/lib/bidi2pdf/session_warmer.rb +23 -15
- data/lib/bidi2pdf/version.rb +1 -1
- data/lib/bidi2pdf.rb +37 -1
- data/sig/bidi2pdf/session_gate.rbs +53 -0
- data/sig/bidi2pdf/session_runner.rbs +4 -6
- data/sig/bidi2pdf/session_warmer.rbs +10 -3
- data/sig/bidi2pdf/version.rbs +1 -1
- data/sig/bidi2pdf.rbs +30 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f9f6c1ea872b0d4250ca35a59990f11f3d0c3599e55b3560e6c6ba925b045dd7
|
|
4
|
+
data.tar.gz: 8c4ae64d0944396a7eed9dc09288747a17491a1235d9f64f3bdb2ea5eb222bcb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dd5616ed110fe4018b78ce18f8403302e1bba37a41fc470a2ed03ce76fc879042a73528ecd81afd21d2fe824c08d5f0034bcbed1bd517ff00dc05b970dfe4eec
|
|
7
|
+
data.tar.gz: b50f99a5143f851b435325763d3b4c1a3817e459170c17ea652c6dbb10ce43ef5ce7a4eec34d01d4e3f4df8c58be51b358167dfe9a84615fbddb9bc948021a3e
|
data/CHANGELOG.md
CHANGED
|
@@ -8,10 +8,24 @@ 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.26..HEAD
|
|
12
12
|
|
|
13
13
|
<!-- generated by git-cliff end -->
|
|
14
14
|
|
|
15
|
+
## [0.1.26] - 2026-10-29
|
|
16
|
+
|
|
17
|
+
### 🐛 Fixed
|
|
18
|
+
- Check screenshot widths on a page that fits
|
|
19
|
+
- Check screenshot widths on pages without Paged.js
|
|
20
|
+
- Read the session start limit once per process
|
|
21
|
+
- Keep session gate permits on every path
|
|
22
|
+
|
|
23
|
+
### 🔄 Changed
|
|
24
|
+
- Merge pull request #184 from dieter-medium/feat/session-start-gate
|
|
25
|
+
|
|
26
|
+
### 🚀 Added
|
|
27
|
+
- Limit concurrent Chrome session setups
|
|
28
|
+
|
|
15
29
|
## [0.1.25] - 2026-10-27
|
|
16
30
|
|
|
17
31
|
### 🐛 Fixed
|
|
@@ -633,7 +647,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
633
647
|
|
|
634
648
|
### 🔄 Released
|
|
635
649
|
|
|
636
|
-
- [unreleased](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.
|
|
650
|
+
- [unreleased](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.26..HEAD)
|
|
651
|
+
- [0.1.26](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.25..v0.1.26)
|
|
637
652
|
- [0.1.25](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.24..v0.1.25)
|
|
638
653
|
- [0.1.24](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.23..v0.1.24)
|
|
639
654
|
- [0.1.23](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.22..v0.1.23)
|
data/README.md
CHANGED
|
@@ -725,6 +725,8 @@ Bidi2pdf.configure do |config|
|
|
|
725
725
|
config.enable_default_logging_subscriber = true
|
|
726
726
|
config.log_truncate_limit = 200 # bytes - see below
|
|
727
727
|
config.chromedriver_log_level = "WARNING" # see below
|
|
728
|
+
config.session_start_concurrency = 2 # Chrome session setups at once in this process - see "Session start limit"
|
|
729
|
+
config.session_start_timeout = nil # seconds a setup waits for its turn; nil: default_timeout
|
|
728
730
|
end
|
|
729
731
|
```
|
|
730
732
|
|
|
@@ -734,16 +736,115 @@ end
|
|
|
734
736
|
| `enable_default_logging_subscriber` | `true` | Subscribes a default logger to the gem's internal instrumentation events. |
|
|
735
737
|
| `log_truncate_limit` | `200` | Max bytes kept when logging a value that can be large (e.g. a `data:` URL) - truncated at a byte, not character, boundary. |
|
|
736
738
|
| `chromedriver_log_level` | `nil` | ChromeDriver's own `--log-level` (`"ALL"`/`"INFO"`/`"WARNING"`/`"SEVERE"`). Unset mirrors `Bidi2pdf.logger.level`; set explicitly to quiet ChromeDriver's own (often very verbose) output independently of your app's log level. |
|
|
739
|
+
| `session_start_concurrency` | `2` | How many Chrome session setups (session request to first open tab) may run at once in this process; `nil` turns the limit off. See "Session start limit". |
|
|
740
|
+
| `session_start_timeout` | `nil` | Seconds a setup waits for its turn before it fails with `SessionStartTimeoutError` (code `BROWSER_LAUNCH_FAILED`, retryable); `nil` uses `default_timeout`. |
|
|
741
|
+
| `session_gate` | `nil` | An object of your own answering `with_permit { ... }` - e.g. a lock shared by several processes - instead of the per-process semaphore. See "Session start limit". |
|
|
737
742
|
|
|
738
743
|
`Bidi2pdf.logger`, `Bidi2pdf.network_events_logger`, `Bidi2pdf.browser_console_logger`, and
|
|
739
744
|
`Bidi2pdf.notification_service` are also configurable in the same block, for more advanced
|
|
740
745
|
logging/instrumentation needs.
|
|
741
746
|
|
|
747
|
+
### Session start limit
|
|
748
|
+
|
|
749
|
+
When several new Chrome sessions are set up at the same moment, chromedriver/Chrome sometimes leaves one of the
|
|
750
|
+
setup commands unanswered (the first `browsingContext.create` or `navigate` of a fresh session): the render waits
|
|
751
|
+
out its command timeout, and the session is left behind unanswering, which wears the Chrome down for the renders
|
|
752
|
+
after it. Measured on chromedriver/Chrome 156 against one remote chromedriver: two setups at once stayed clean in
|
|
753
|
+
720 renders; three at once failed in the first round.
|
|
754
|
+
|
|
755
|
+
So bidi2pdf lets at most `session_start_concurrency` setups run at once in a process - from the session request to
|
|
756
|
+
the first open tab, in every path (the CLI, `Bidi2pdf::DSL.with_tab`, the session warmer). Rendering is never
|
|
757
|
+
limited: eight PDFs can render side by side while their setups took turns. A setup that cannot get a turn within
|
|
758
|
+
`session_start_timeout` fails with `SessionStartTimeoutError` instead of queueing for ever. Every turn instruments
|
|
759
|
+
`session_gate.bidi2pdf` (`limit`, `wait` in seconds, `acquired`).
|
|
760
|
+
|
|
761
|
+
**The limit holds per process.** Chrome sees the setups of every process that talks to it, so processes sharing one
|
|
762
|
+
Chrome divide its budget: two processes with a limit of 1 each are as safe as one with 2 - two with 2 each failed in
|
|
763
|
+
every run. With Puma workers and a job process on one remote chromedriver, set the limit so that limit x processes
|
|
764
|
+
stays at about 2, or give the processes Chromes of their own.
|
|
765
|
+
|
|
766
|
+
It is one budget per process, whichever Chrome a setup goes to: two independent chromedrivers (a local one and a
|
|
767
|
+
remote one, or two remote endpoints) still take turns. The limit is read once per process, at its first session
|
|
768
|
+
setup (and again in a forked child): set it at boot, in an initializer. A later change is logged and takes effect
|
|
769
|
+
after a restart.
|
|
770
|
+
|
|
771
|
+
**A gate across processes.** `config.session_gate` takes an object of your own instead of the per-process
|
|
772
|
+
semaphore - anything answering `with_permit { ... }` - so several processes can share one budget. Every setup
|
|
773
|
+
path (CLI, `Bidi2pdf::DSL`, the session warmer, bidi2pdf-rails) asks it, and `session_gate.bidi2pdf` is
|
|
774
|
+
instrumented as before. Your gate has to run the block with a permit, give the permit back however the block ends,
|
|
775
|
+
raise `Bidi2pdf::SessionStartTimeoutError` when none comes free in time, and not lose permits when a process dies
|
|
776
|
+
or forks (`session_start_concurrency` and `session_start_timeout` apply only if it reads them). A sketch for
|
|
777
|
+
processes on one host - the kernel drops a `flock` when its process ends:
|
|
778
|
+
|
|
779
|
+
```ruby
|
|
780
|
+
# N lock files in a directory every process can reach: one free file is one permit.
|
|
781
|
+
class FileLockGate
|
|
782
|
+
def initialize(dir, slots: 2, timeout: 60)
|
|
783
|
+
@paths = Array.new(slots) { |index| File.join(dir, "session-start-#{index}.lock") }
|
|
784
|
+
@timeout = timeout
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
def with_permit
|
|
788
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
|
|
789
|
+
loop do
|
|
790
|
+
@paths.each do |path|
|
|
791
|
+
File.open(path, File::RDWR | File::CREAT, 0o600) do |file|
|
|
792
|
+
return yield if file.flock(File::LOCK_EX | File::LOCK_NB) # closing the file releases the lock
|
|
793
|
+
end
|
|
794
|
+
end
|
|
795
|
+
raise Bidi2pdf::SessionStartTimeoutError, "no session start permit free" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
796
|
+
|
|
797
|
+
sleep 0.05
|
|
798
|
+
end
|
|
799
|
+
end
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
Bidi2pdf.configure { |config| config.session_gate = FileLockGate.new("/var/lib/myapp/bidi2pdf", slots: 2) }
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
**The evidence.** bidi2pdf's own measurements come first (2026-10-28, chromedriver/Chrome 156, one remote
|
|
806
|
+
chromedriver, the chromedriver swept between runs): at a limit of 2, 0 of 720 renders failed at 4, 6 and 8 renders at
|
|
807
|
+
once in ten interleaved rounds; at 3, renders failed from the first round on a freshly started Chrome; ungated, 104 of
|
|
808
|
+
160 failed. Every unanswered command was traced as written to an open WebSocket, with no reply carrying its id ever
|
|
809
|
+
read and no error raised - the client loses nothing. `bin/session-start-repro` reproduces it against any chromedriver
|
|
810
|
+
(`LIMIT=none` against `LIMIT=2`).
|
|
811
|
+
|
|
812
|
+
**Related reports.** No upstream report of exactly this was found (searched 2026-10-29: chromium-bidi, ChromeDriver,
|
|
813
|
+
Selenium, Puppeteer, WebdriverIO, Mozilla). What follows is context, not confirmation - no other project has
|
|
814
|
+
reported this bug.
|
|
815
|
+
|
|
816
|
+
Related concurrency and lifecycle defects in WebDriver BiDi:
|
|
817
|
+
|
|
818
|
+
- [Mozilla bug 2019511](https://bugzilla.mozilla.org/show_bug.cgi?id=2019511) "browsingContext.setViewport can
|
|
819
|
+
timeout if several contexts are created in parallel" - the closest analogy. In Firefox's BiDi a context's active
|
|
820
|
+
state could change before a waiting command ran, so a command depending on it timed out under parallel context
|
|
821
|
+
creation; fixed in Firefox 150 by always updating the dimensions synchronously in `browsingContext.create`.
|
|
822
|
+
Another browser and another cause, but the same kind of race: parallel context creation leaving a command
|
|
823
|
+
waiting.
|
|
824
|
+
- ChromeDriver 127, [crbug 341048786](https://crbug.com/341048786) and [crbug 1503101](https://crbug.com/1503101)
|
|
825
|
+
([crrev.com/c/5575497](https://crrev.com/c/5575497)): a race between ChromeDriver and the BiDi mapper during
|
|
826
|
+
session creation, fixed by waiting for `browsingContext.getTree` to return a non-empty list. The same phase - a
|
|
827
|
+
session's start - but already fixed in the 156 measured here, so what bidi2pdf meets is a race still left there.
|
|
828
|
+
- ChromeDriver 126, [crbug 4762](https://crbug.com/4762) ([crrev.com/c/5515013](https://crrev.com/c/5515013)), and
|
|
829
|
+
144, [crbug 458027819](https://crbug.com/458027819) ([crrev.com/c/7128058](https://crrev.com/c/7128058)): Chrome
|
|
830
|
+
throttling background and occluded windows in BiDi sessions. bidi2pdf already passes
|
|
831
|
+
`--disable-background-timer-throttling`, `--disable-renderer-backgrounding` and
|
|
832
|
+
`--disable-backgrounding-occluded-windows`.
|
|
833
|
+
|
|
834
|
+
The same symptom, no shown common cause:
|
|
835
|
+
|
|
836
|
+
- [puppeteer#4039](https://github.com/puppeteer/puppeteer/issues/4039) "browser.newPage freezes (never resolves)" -
|
|
837
|
+
a new page that never comes back; no root cause was found, closed as out of scope.
|
|
838
|
+
- [webdriverio#16051](https://github.com/webdriverio/webdriverio/issues/16051) - pending BiDi commands waiting for
|
|
839
|
+
their whole timeout after the connection closed, because the client never rejected them; fixed in WebdriverIO
|
|
840
|
+
(PR #16054). A client-side cause of the same symptom, ruled out for bidi2pdf by the trace above.
|
|
841
|
+
|
|
742
842
|
### Pre-warmed sessions (`Bidi2pdf::SessionWarmer`)
|
|
743
843
|
|
|
744
844
|
Optional. Keeps a few Chrome sessions started in the background so a render skips browser startup.
|
|
745
845
|
Every slot is still used for exactly one render and then discarded - isolation is the same as
|
|
746
|
-
launching a fresh Chrome per PDF.
|
|
846
|
+
launching a fresh Chrome per PDF. A slot is warmed together with its user context, window and tab,
|
|
847
|
+
under one turn of the session start limit, so a checkout sets up nothing.
|
|
747
848
|
|
|
748
849
|
```ruby
|
|
749
850
|
Bidi2pdf::SessionWarmer.configure do |c|
|
data/lib/bidi2pdf/dsl.rb
CHANGED
|
@@ -40,6 +40,8 @@ module Bidi2pdf
|
|
|
40
40
|
def self.with_tab(remote_browser_url: nil, port: 0, headless: true, chrome_args: Bidi2pdf::Bidi::Session::DEFAULT_CHROME_ARGS.dup)
|
|
41
41
|
manager = nil
|
|
42
42
|
session = nil
|
|
43
|
+
context = nil
|
|
44
|
+
window = nil
|
|
43
45
|
tab = nil
|
|
44
46
|
|
|
45
47
|
begin
|
|
@@ -56,13 +58,16 @@ module Bidi2pdf
|
|
|
56
58
|
manager.session
|
|
57
59
|
end
|
|
58
60
|
|
|
59
|
-
session.
|
|
60
|
-
|
|
61
|
+
# Only the setup waits for the session start limit, the work in the tab does not.
|
|
62
|
+
Bidi2pdf::SessionGate.with_permit do
|
|
63
|
+
session.start
|
|
64
|
+
session.client.on_close { Bidi2pdf.logger.info "WebSocket session closed" }
|
|
61
65
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
browser = session.browser
|
|
67
|
+
context = browser.create_user_context
|
|
68
|
+
window = context.create_browser_window
|
|
69
|
+
tab = window.create_browser_tab
|
|
70
|
+
end
|
|
66
71
|
|
|
67
72
|
yield(tab)
|
|
68
73
|
ensure
|
data/lib/bidi2pdf/error_codes.rb
CHANGED
|
@@ -18,6 +18,7 @@ module Bidi2pdf
|
|
|
18
18
|
InvalidRecipeError => "INVALID_RECIPE",
|
|
19
19
|
PdfInspectionUnavailableError => "PDF_INSPECTION_UNAVAILABLE",
|
|
20
20
|
SessionNotStartedError => "BROWSER_LAUNCH_FAILED",
|
|
21
|
+
SessionStartTimeoutError => "BROWSER_LAUNCH_FAILED",
|
|
21
22
|
CmdTimeoutError => "COMMAND_TIMEOUT",
|
|
22
23
|
WebsocketError => "BROWSER_DISCONNECTED",
|
|
23
24
|
NavigationTimeoutError => "NAVIGATION_TIMEOUT",
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module Bidi2pdf
|
|
6
|
+
# At most Bidi2pdf.session_start_concurrency Chrome session setups at once in this process - from the session
|
|
7
|
+
# request to the first open tab (README "Session start limit"). When several new sessions are set up at the same
|
|
8
|
+
# moment, chromedriver/Chrome sometimes leaves a setup command unanswered and the session hangs until the command
|
|
9
|
+
# times out (measured on chromedriver/Chrome 156: three setups at once failed, two did not). Rendering is never
|
|
10
|
+
# limited, only the setup before it.
|
|
11
|
+
#
|
|
12
|
+
# The limit holds per process: processes sharing one Chrome (Puma workers, a job process) divide its budget
|
|
13
|
+
# between them. It is one budget for the process, whichever Chrome a setup goes to - two independent chromedrivers
|
|
14
|
+
# still take turns (an endpoint-scoped gate would not; not built). nil turns the gate off.
|
|
15
|
+
#
|
|
16
|
+
# The limit is read once per process - at its first setup, and again in a forked child, whose semaphore is its own
|
|
17
|
+
# so permits held by the parent's threads at the fork cannot be lost in it. A later change takes effect after a
|
|
18
|
+
# restart and is logged: set it at boot (an initializer), not while setups run. Changing it live would have to
|
|
19
|
+
# keep the setups in flight counted, which needs an API Concurrent::Semaphore does not document.
|
|
20
|
+
#
|
|
21
|
+
# Bidi2pdf.session_gate replaces the semaphore with an object of your own, to share the budget across processes
|
|
22
|
+
# (a file lock, a database or Redis lock). Its contract: +with_permit+ runs the block only while it holds a permit,
|
|
23
|
+
# gives the permit back however the block ends, raises Bidi2pdf::SessionStartTimeoutError when none comes free in
|
|
24
|
+
# time, and loses no permit when a process dies or forks. Every setup path still comes through here, so the
|
|
25
|
+
# notification below is sent either way - it trusts that the block runs under a permit. session_start_concurrency
|
|
26
|
+
# and session_start_timeout then apply only if that object reads them.
|
|
27
|
+
module SessionGate
|
|
28
|
+
LOCK = Mutex.new
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# Runs the block with a permit, released however the block ends. Waits for one at most
|
|
32
|
+
# Bidi2pdf.session_start_timeout (default: Bidi2pdf.default_timeout) seconds, then raises
|
|
33
|
+
# SessionStartTimeoutError - a queue behind a hung setup fails loudly instead of growing.
|
|
34
|
+
def with_permit(&)
|
|
35
|
+
return with_custom_gate(Bidi2pdf.session_gate, &) if Bidi2pdf.session_gate
|
|
36
|
+
|
|
37
|
+
semaphore, limit = current
|
|
38
|
+
return yield unless semaphore
|
|
39
|
+
|
|
40
|
+
acquired, waited = acquire(semaphore)
|
|
41
|
+
refuse(limit, waited) unless acquired
|
|
42
|
+
|
|
43
|
+
# Everything after the permit is taken sits inside the ensure - a raising subscriber must not keep it.
|
|
44
|
+
begin
|
|
45
|
+
Bidi2pdf.notification_service.instrument("session_gate.bidi2pdf", { limit: limit, wait: waited, acquired: true })
|
|
46
|
+
yield
|
|
47
|
+
ensure
|
|
48
|
+
semaphore.release(1)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Forgets the semaphore; the next permit builds one for the configured limit. For specs.
|
|
53
|
+
def reset!
|
|
54
|
+
LOCK.synchronize { @semaphore = @limit = @pid = @warned_limit = nil }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# The wait is the time until the custom gate runs the block - it reports a timeout itself, by raising.
|
|
60
|
+
def with_custom_gate(gate)
|
|
61
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
62
|
+
gate.with_permit do
|
|
63
|
+
waited = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(3)
|
|
64
|
+
Bidi2pdf.notification_service.instrument("session_gate.bidi2pdf", { limit: nil, wait: waited, acquired: true, gate: gate.class.name })
|
|
65
|
+
yield
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The semaphore of this process, built for the limit configured at its first setup (and in a forked child).
|
|
70
|
+
def current
|
|
71
|
+
LOCK.synchronize do
|
|
72
|
+
if @pid == Process.pid
|
|
73
|
+
warn_changed_limit
|
|
74
|
+
else
|
|
75
|
+
@limit = Bidi2pdf.session_start_concurrency
|
|
76
|
+
@semaphore = @limit && Concurrent::Semaphore.new(@limit)
|
|
77
|
+
@pid = Process.pid
|
|
78
|
+
end
|
|
79
|
+
[@semaphore, @limit]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Once per new value: the limit in force stays until a restart.
|
|
84
|
+
def warn_changed_limit
|
|
85
|
+
configured = Bidi2pdf.session_start_concurrency
|
|
86
|
+
return if [@limit, @warned_limit].include?(configured)
|
|
87
|
+
|
|
88
|
+
@warned_limit = configured
|
|
89
|
+
Bidi2pdf.logger.warn "session_gate: session_start_concurrency changed to #{configured.inspect} after the first session " \
|
|
90
|
+
"setup - #{@limit.inspect} stays in force until the process restarts"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def refuse(limit, waited)
|
|
94
|
+
Bidi2pdf.notification_service.instrument("session_gate.bidi2pdf", { limit: limit, wait: waited, acquired: false })
|
|
95
|
+
raise SessionStartTimeoutError.new(timeout_message(limit, waited), details: { limit: limit, waited: waited })
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def acquire(semaphore)
|
|
99
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
100
|
+
acquired = semaphore.try_acquire(1, Bidi2pdf.session_start_timeout || Bidi2pdf.default_timeout)
|
|
101
|
+
[acquired, (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(3)]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def timeout_message(limit, waited)
|
|
105
|
+
"No Chrome session could start within #{waited}s: #{limit} other session setup(s) in this process held every permit"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -56,9 +56,7 @@ module Bidi2pdf
|
|
|
56
56
|
# rubocop: enable Metrics/ParameterLists
|
|
57
57
|
|
|
58
58
|
def run
|
|
59
|
-
|
|
60
|
-
@session.client.on_close { Bidi2pdf.logger.info "WebSocket closed" }
|
|
61
|
-
|
|
59
|
+
open_tab
|
|
62
60
|
setup_browser
|
|
63
61
|
run_flow
|
|
64
62
|
end
|
|
@@ -69,9 +67,7 @@ module Bidi2pdf
|
|
|
69
67
|
#
|
|
70
68
|
# @return [Bidi2pdf::Bidi::BrowserTab] the navigated tab.
|
|
71
69
|
def run_diagnose
|
|
72
|
-
|
|
73
|
-
@session.client.on_close { Bidi2pdf.logger.info "WebSocket closed" }
|
|
74
|
-
|
|
70
|
+
open_tab
|
|
75
71
|
setup_browser
|
|
76
72
|
navigate_and_wait
|
|
77
73
|
|
|
@@ -88,18 +84,21 @@ module Bidi2pdf
|
|
|
88
84
|
|
|
89
85
|
private
|
|
90
86
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
87
|
+
# The session's setup, up to its first tab, waits for the session start limit (Bidi2pdf::SessionGate);
|
|
88
|
+
# everything after it does not.
|
|
89
|
+
def open_tab
|
|
90
|
+
Bidi2pdf::SessionGate.with_permit do
|
|
91
|
+
@session.start
|
|
92
|
+
@session.client.on_close { Bidi2pdf.logger.info "WebSocket closed" }
|
|
97
93
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
94
|
+
@user_context = @session.browser.create_user_context
|
|
95
|
+
@window = @user_context.create_browser_window
|
|
96
|
+
@tab = @window.create_browser_tab
|
|
97
|
+
end
|
|
98
|
+
end
|
|
101
99
|
|
|
102
|
-
|
|
100
|
+
def setup_browser
|
|
101
|
+
add_cookies(@tab)
|
|
103
102
|
|
|
104
103
|
add_headers
|
|
105
104
|
add_basic_auth
|
|
@@ -326,23 +326,17 @@ module Bidi2pdf
|
|
|
326
326
|
@sweeper&.sweep!(reason: reason)
|
|
327
327
|
end
|
|
328
328
|
|
|
329
|
-
# Checks out a slot,
|
|
330
|
-
# then unconditionally closes
|
|
329
|
+
# Checks out a slot, yields its tab - in a UserContext of its own, created with the slot (#new_slot) - for one
|
|
330
|
+
# render, then unconditionally closes the tab, window and user context and retires the slot.
|
|
331
331
|
def with_tab
|
|
332
332
|
slot = checkout
|
|
333
|
-
user_context = nil
|
|
334
|
-
window = nil
|
|
335
|
-
tab = nil
|
|
336
333
|
|
|
337
334
|
begin
|
|
338
|
-
|
|
339
|
-
window = user_context.create_browser_window
|
|
340
|
-
tab = window.create_browser_tab
|
|
341
|
-
yield tab
|
|
335
|
+
yield slot[:tab]
|
|
342
336
|
ensure
|
|
343
|
-
safe_close("tab") { tab&.close }
|
|
344
|
-
safe_close("window") { window&.close }
|
|
345
|
-
safe_close("user context") { user_context&.close }
|
|
337
|
+
safe_close("tab") { slot[:tab]&.close }
|
|
338
|
+
safe_close("window") { slot[:window]&.close }
|
|
339
|
+
safe_close("user context") { slot[:user_context]&.close }
|
|
346
340
|
retire(slot)
|
|
347
341
|
end
|
|
348
342
|
end
|
|
@@ -446,14 +440,28 @@ module Bidi2pdf
|
|
|
446
440
|
end
|
|
447
441
|
|
|
448
442
|
# A chromedriver out of room for another Chrome refuses the session; with a sweeper, close
|
|
449
|
-
# everything nobody holds (a pressure sweep) and try once more.
|
|
443
|
+
# everything nobody holds (a pressure sweep) and try once more. Each attempt waits for the session
|
|
444
|
+
# start limit (Bidi2pdf::SessionGate), the sweep between them does not.
|
|
450
445
|
def new_slot
|
|
451
|
-
@slot_factory.call
|
|
446
|
+
Bidi2pdf::SessionGate.with_permit { with_first_tab(@slot_factory.call) }
|
|
452
447
|
rescue Bidi2pdf::SessionNotStartedError
|
|
453
448
|
raise unless @sweeper && @config.retry_refused_sessions
|
|
454
449
|
|
|
455
450
|
@sweeper.sweep!(reason: :create_failed, pressure: true)
|
|
456
|
-
@slot_factory.call
|
|
451
|
+
Bidi2pdf::SessionGate.with_permit { with_first_tab(@slot_factory.call) }
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# The slot's user context, window and tab, opened together with its session under one permit: what the limit
|
|
455
|
+
# has to bound is the number of sessions between their request and their first tab - split into two permits,
|
|
456
|
+
# sessions started in between waited for their tab side by side, and chromedriver left setups unanswered
|
|
457
|
+
# (docs: README "Session start limit"). A slot whose tab cannot be opened is retired at once.
|
|
458
|
+
def with_first_tab(slot)
|
|
459
|
+
user_context = slot[:browser].create_user_context
|
|
460
|
+
window = user_context.create_browser_window
|
|
461
|
+
slot.merge(user_context: user_context, window: window, tab: window.create_browser_tab)
|
|
462
|
+
rescue StandardError
|
|
463
|
+
self.class.retire_slot(session: slot[:session], manager: slot[:manager])
|
|
464
|
+
raise
|
|
457
465
|
end
|
|
458
466
|
|
|
459
467
|
def start_background_work
|
data/lib/bidi2pdf/version.rb
CHANGED
data/lib/bidi2pdf.rb
CHANGED
|
@@ -15,6 +15,7 @@ require_relative "bidi2pdf/session_registry"
|
|
|
15
15
|
require_relative "bidi2pdf/session_sweeper"
|
|
16
16
|
require_relative "bidi2pdf/chromedriver_api"
|
|
17
17
|
require_relative "bidi2pdf/chrome_sweeper"
|
|
18
|
+
require_relative "bidi2pdf/session_gate"
|
|
18
19
|
require_relative "bidi2pdf/session_warmer"
|
|
19
20
|
require_relative "bidi2pdf/verbose_logger"
|
|
20
21
|
|
|
@@ -56,6 +57,14 @@ module Bidi2pdf # rubocop:disable Metrics/ModuleLength
|
|
|
56
57
|
def hint = "Check Chrome/chromedriver are installed and reachable, or that --remote-browser-url points at a running instance"
|
|
57
58
|
end
|
|
58
59
|
|
|
60
|
+
# No permit of the session start limit (Bidi2pdf::SessionGate) came free in time: other session setups in this
|
|
61
|
+
# process held them all. Reported like a session that did not start (BROWSER_LAUNCH_FAILED).
|
|
62
|
+
class SessionStartTimeoutError < Error
|
|
63
|
+
def retryable? = true
|
|
64
|
+
|
|
65
|
+
def hint = "Start fewer renders at once, or raise session_start_concurrency / session_start_timeout"
|
|
66
|
+
end
|
|
67
|
+
|
|
59
68
|
class WebsocketError < Error
|
|
60
69
|
def retryable? = true
|
|
61
70
|
end
|
|
@@ -176,7 +185,29 @@ module Bidi2pdf # rubocop:disable Metrics/ModuleLength
|
|
|
176
185
|
|
|
177
186
|
class << self
|
|
178
187
|
attr_accessor :default_timeout, :enable_default_logging_subscriber, :log_truncate_limit, :chromedriver_log_level
|
|
179
|
-
attr_reader :logging_subscriber, :logger, :network_events_logger, :browser_console_logger, :notification_service
|
|
188
|
+
attr_reader :session_start_concurrency, :session_start_timeout, :session_gate, :logging_subscriber, :logger, :network_events_logger, :browser_console_logger, :notification_service
|
|
189
|
+
|
|
190
|
+
# How many Chrome session setups may run at once in this process (Bidi2pdf::SessionGate); nil: no limit.
|
|
191
|
+
def session_start_concurrency=(limit)
|
|
192
|
+
raise InvalidConfigError, "session_start_concurrency must be nil or a positive Integer, got #{limit.inspect}" unless limit.nil? || (limit.is_a?(Integer) && limit.positive?)
|
|
193
|
+
|
|
194
|
+
@session_start_concurrency = limit
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# An object of your own for the session start limit - anything answering +with_permit { ... }+, e.g. a lock shared
|
|
198
|
+
# by several processes (Bidi2pdf::SessionGate); nil: the built-in per-process semaphore.
|
|
199
|
+
def session_gate=(gate)
|
|
200
|
+
raise InvalidConfigError, "session_gate must be nil or respond to with_permit, got #{gate.inspect}" unless gate.nil? || gate.respond_to?(:with_permit)
|
|
201
|
+
|
|
202
|
+
@session_gate = gate
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Seconds a setup waits for a permit; nil: default_timeout.
|
|
206
|
+
def session_start_timeout=(seconds)
|
|
207
|
+
raise InvalidConfigError, "session_start_timeout must be nil or a positive number, got #{seconds.inspect}" unless seconds.nil? || (seconds.is_a?(Numeric) && seconds.positive?)
|
|
208
|
+
|
|
209
|
+
@session_start_timeout = seconds
|
|
210
|
+
end
|
|
180
211
|
|
|
181
212
|
# Allow configuration through a block
|
|
182
213
|
def configure
|
|
@@ -271,6 +302,11 @@ module Bidi2pdf # rubocop:disable Metrics/ModuleLength
|
|
|
271
302
|
|
|
272
303
|
config.default_timeout = 60
|
|
273
304
|
|
|
305
|
+
# Two setups at once: measured clean on chromedriver/Chrome 156, three were not (README "Session start limit").
|
|
306
|
+
config.session_start_concurrency = 2
|
|
307
|
+
config.session_start_timeout = nil
|
|
308
|
+
config.session_gate = nil
|
|
309
|
+
|
|
274
310
|
config.log_truncate_limit = 200
|
|
275
311
|
|
|
276
312
|
config.notification_service = Notifications
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Bidi2pdf
|
|
2
|
+
# At most Bidi2pdf.session_start_concurrency Chrome session setups at once in this process - from the session
|
|
3
|
+
# request to the first open tab (README "Session start limit"). When several new sessions are set up at the same
|
|
4
|
+
# moment, chromedriver/Chrome sometimes leaves a setup command unanswered and the session hangs until the command
|
|
5
|
+
# times out (measured on chromedriver/Chrome 156: three setups at once failed, two did not). Rendering is never
|
|
6
|
+
# limited, only the setup before it.
|
|
7
|
+
#
|
|
8
|
+
# The limit holds per process: processes sharing one Chrome (Puma workers, a job process) divide its budget
|
|
9
|
+
# between them. It is one budget for the process, whichever Chrome a setup goes to - two independent chromedrivers
|
|
10
|
+
# still take turns (an endpoint-scoped gate would not; not built). nil turns the gate off.
|
|
11
|
+
#
|
|
12
|
+
# The limit is read once per process - at its first setup, and again in a forked child, whose semaphore is its own
|
|
13
|
+
# so permits held by the parent's threads at the fork cannot be lost in it. A later change takes effect after a
|
|
14
|
+
# restart and is logged: set it at boot (an initializer), not while setups run. Changing it live would have to
|
|
15
|
+
# keep the setups in flight counted, which needs an API Concurrent::Semaphore does not document.
|
|
16
|
+
#
|
|
17
|
+
# Bidi2pdf.session_gate replaces the semaphore with an object of your own, to share the budget across processes
|
|
18
|
+
# (a file lock, a database or Redis lock). Its contract: +with_permit+ runs the block only while it holds a permit,
|
|
19
|
+
# gives the permit back however the block ends, raises Bidi2pdf::SessionStartTimeoutError when none comes free in
|
|
20
|
+
# time, and loses no permit when a process dies or forks. Every setup path still comes through here, so the
|
|
21
|
+
# notification below is sent either way - it trusts that the block runs under a permit. session_start_concurrency
|
|
22
|
+
# and session_start_timeout then apply only if that object reads them.
|
|
23
|
+
module SessionGate
|
|
24
|
+
self.@warned_limit: untyped
|
|
25
|
+
|
|
26
|
+
LOCK: untyped
|
|
27
|
+
|
|
28
|
+
# Runs the block with a permit, released however the block ends. Waits for one at most
|
|
29
|
+
# Bidi2pdf.session_start_timeout (default: Bidi2pdf.default_timeout) seconds, then raises
|
|
30
|
+
# SessionStartTimeoutError - a queue behind a hung setup fails loudly instead of growing.
|
|
31
|
+
def self.with_permit: () { () -> untyped } -> untyped
|
|
32
|
+
|
|
33
|
+
# Forgets the semaphore; the next permit builds one for the configured limit. For specs.
|
|
34
|
+
def self.reset!: () -> untyped
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# The wait is the time until the custom gate runs the block - it reports a timeout itself, by raising.
|
|
39
|
+
def self.with_custom_gate: (untyped gate) { () -> untyped } -> untyped
|
|
40
|
+
|
|
41
|
+
# The semaphore of this process, built for the limit configured at its first setup (and in a forked child).
|
|
42
|
+
def self.current: () -> untyped
|
|
43
|
+
|
|
44
|
+
# Once per new value: the limit in force stays until a restart.
|
|
45
|
+
def self.warn_changed_limit: () -> (nil | untyped)
|
|
46
|
+
|
|
47
|
+
def self.refuse: (untyped limit, untyped waited) -> untyped
|
|
48
|
+
|
|
49
|
+
def self.acquire: (untyped semaphore) -> ::Array[untyped]
|
|
50
|
+
|
|
51
|
+
def self.timeout_message: (untyped limit, untyped waited) -> ::String
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -58,12 +58,6 @@ module Bidi2pdf
|
|
|
58
58
|
|
|
59
59
|
@emulate_media: untyped
|
|
60
60
|
|
|
61
|
-
@window: untyped
|
|
62
|
-
|
|
63
|
-
@tab: untyped
|
|
64
|
-
|
|
65
|
-
@user_context: untyped
|
|
66
|
-
|
|
67
61
|
@uri: untyped
|
|
68
62
|
|
|
69
63
|
# rubocop: disable Metrics/ParameterLists
|
|
@@ -84,6 +78,10 @@ module Bidi2pdf
|
|
|
84
78
|
|
|
85
79
|
private
|
|
86
80
|
|
|
81
|
+
# The session's setup, up to its first tab, waits for the session start limit (Bidi2pdf::SessionGate);
|
|
82
|
+
# everything after it does not.
|
|
83
|
+
def open_tab: () -> untyped
|
|
84
|
+
|
|
87
85
|
def setup_browser: () -> untyped
|
|
88
86
|
|
|
89
87
|
def add_cookies: (untyped tab) -> untyped
|
|
@@ -248,8 +248,8 @@ module Bidi2pdf
|
|
|
248
248
|
# @return [ChromeSweeper::Result, nil] nil when no sweeper is configured.
|
|
249
249
|
def sweep!: (?reason: ::Symbol) -> untyped
|
|
250
250
|
|
|
251
|
-
# Checks out a slot,
|
|
252
|
-
# then unconditionally closes
|
|
251
|
+
# Checks out a slot, yields its tab - in a UserContext of its own, created with the slot (#new_slot) - for one
|
|
252
|
+
# render, then unconditionally closes the tab, window and user context and retires the slot.
|
|
253
253
|
def with_tab: () { (untyped) -> untyped } -> untyped
|
|
254
254
|
|
|
255
255
|
# Retires every currently-warm spare and waits for any in-flight background replenishment to
|
|
@@ -297,9 +297,16 @@ module Bidi2pdf
|
|
|
297
297
|
def create_slot: () -> untyped
|
|
298
298
|
|
|
299
299
|
# A chromedriver out of room for another Chrome refuses the session; with a sweeper, close
|
|
300
|
-
# everything nobody holds (a pressure sweep) and try once more.
|
|
300
|
+
# everything nobody holds (a pressure sweep) and try once more. Each attempt waits for the session
|
|
301
|
+
# start limit (Bidi2pdf::SessionGate), the sweep between them does not.
|
|
301
302
|
def new_slot: () -> untyped
|
|
302
303
|
|
|
304
|
+
# The slot's user context, window and tab, opened together with its session under one permit: what the limit
|
|
305
|
+
# has to bound is the number of sessions between their request and their first tab - split into two permits,
|
|
306
|
+
# sessions started in between waited for their tab side by side, and chromedriver left setups unanswered
|
|
307
|
+
# (docs: README "Session start limit"). A slot whose tab cannot be opened is retired at once.
|
|
308
|
+
def with_first_tab: (untyped slot) -> untyped
|
|
309
|
+
|
|
303
310
|
def start_background_work: () -> untyped
|
|
304
311
|
|
|
305
312
|
def build_sweeper: () -> (nil | untyped)
|
data/sig/bidi2pdf/version.rbs
CHANGED
data/sig/bidi2pdf.rbs
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
module Bidi2pdf
|
|
2
|
+
self.@session_start_concurrency: untyped
|
|
3
|
+
|
|
4
|
+
self.@session_gate: untyped
|
|
5
|
+
|
|
6
|
+
self.@session_start_timeout: untyped
|
|
7
|
+
|
|
2
8
|
self.@logger: untyped
|
|
3
9
|
|
|
4
10
|
self.@network_events_logger: untyped
|
|
@@ -33,6 +39,14 @@ module Bidi2pdf
|
|
|
33
39
|
def hint: () -> "Check Chrome/chromedriver are installed and reachable, or that --remote-browser-url points at a running instance"
|
|
34
40
|
end
|
|
35
41
|
|
|
42
|
+
# No permit of the session start limit (Bidi2pdf::SessionGate) came free in time: other session setups in this
|
|
43
|
+
# process held them all. Reported like a session that did not start (BROWSER_LAUNCH_FAILED).
|
|
44
|
+
class SessionStartTimeoutError < Error
|
|
45
|
+
def retryable?: () -> true
|
|
46
|
+
|
|
47
|
+
def hint: () -> "Start fewer renders at once, or raise session_start_concurrency / session_start_timeout"
|
|
48
|
+
end
|
|
49
|
+
|
|
36
50
|
class WebsocketError < Error
|
|
37
51
|
def retryable?: () -> true
|
|
38
52
|
end
|
|
@@ -155,6 +169,12 @@ module Bidi2pdf
|
|
|
155
169
|
|
|
156
170
|
attr_accessor self.chromedriver_log_level: untyped
|
|
157
171
|
|
|
172
|
+
attr_reader self.session_start_concurrency: untyped
|
|
173
|
+
|
|
174
|
+
attr_reader self.session_start_timeout: untyped
|
|
175
|
+
|
|
176
|
+
attr_reader self.session_gate: untyped
|
|
177
|
+
|
|
158
178
|
attr_reader self.logging_subscriber: untyped
|
|
159
179
|
|
|
160
180
|
attr_reader self.logger: untyped
|
|
@@ -165,6 +185,16 @@ module Bidi2pdf
|
|
|
165
185
|
|
|
166
186
|
attr_reader self.notification_service: untyped
|
|
167
187
|
|
|
188
|
+
# How many Chrome session setups may run at once in this process (Bidi2pdf::SessionGate); nil: no limit.
|
|
189
|
+
def self.session_start_concurrency=: (untyped limit) -> untyped
|
|
190
|
+
|
|
191
|
+
# An object of your own for the session start limit - anything answering +with_permit { ... }+, e.g. a lock shared
|
|
192
|
+
# by several processes (Bidi2pdf::SessionGate); nil: the built-in per-process semaphore.
|
|
193
|
+
def self.session_gate=: (untyped gate) -> untyped
|
|
194
|
+
|
|
195
|
+
# Seconds a setup waits for a permit; nil: default_timeout.
|
|
196
|
+
def self.session_start_timeout=: (untyped seconds) -> untyped
|
|
197
|
+
|
|
168
198
|
# Allow configuration through a block
|
|
169
199
|
def self.configure: () ?{ (untyped) -> untyped } -> untyped
|
|
170
200
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bidi2pdf
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.26
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dieter S.
|
|
@@ -497,6 +497,7 @@ files:
|
|
|
497
497
|
- lib/bidi2pdf/result.rb
|
|
498
498
|
- lib/bidi2pdf/result_collector.rb
|
|
499
499
|
- lib/bidi2pdf/schema.rb
|
|
500
|
+
- lib/bidi2pdf/session_gate.rb
|
|
500
501
|
- lib/bidi2pdf/session_registry.rb
|
|
501
502
|
- lib/bidi2pdf/session_registry/heartbeat.rb
|
|
502
503
|
- lib/bidi2pdf/session_runner.rb
|
|
@@ -605,6 +606,7 @@ files:
|
|
|
605
606
|
- sig/bidi2pdf/result.rbs
|
|
606
607
|
- sig/bidi2pdf/result_collector.rbs
|
|
607
608
|
- sig/bidi2pdf/schema.rbs
|
|
609
|
+
- sig/bidi2pdf/session_gate.rbs
|
|
608
610
|
- sig/bidi2pdf/session_registry.rbs
|
|
609
611
|
- sig/bidi2pdf/session_registry/heartbeat.rbs
|
|
610
612
|
- sig/bidi2pdf/session_runner.rbs
|