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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f000e35aacf91ebfd7691be7ce8e989ab8d384dd96075e9ef705692f5f68bc3b
4
- data.tar.gz: ef370866fad4eacca3d74a23f4ea7b83caf157f498cae29c26fcdb19757ada9e
3
+ metadata.gz: f9f6c1ea872b0d4250ca35a59990f11f3d0c3599e55b3560e6c6ba925b045dd7
4
+ data.tar.gz: 8c4ae64d0944396a7eed9dc09288747a17491a1235d9f64f3bdb2ea5eb222bcb
5
5
  SHA512:
6
- metadata.gz: 4a6025a10a1ebab333371dbf80e8d733e57052a08da043574e9b661551f02259c163afc7b0fe79580faafe5e4a1ad8bb03ab1655074eea77d77f02fd80b6c10f
7
- data.tar.gz: efa593482aba4d15785007ac2612afafbe61f04c8dc6555b6052e7cb04d775661b2d0b2d286fe64a401fe22cc7050697b734aae1a2c97f0b4c88d28856134a6f
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.25..HEAD
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.25..HEAD)
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.start
60
- session.client.on_close { Bidi2pdf.logger.info "WebSocket session closed" }
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
- browser = session.browser
63
- context = browser.create_user_context
64
- window = context.create_browser_window
65
- tab = window.create_browser_tab
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
@@ -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
- @session.start
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
- @session.start
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
- def setup_browser
92
- browser = @session.browser
93
- user_context = browser.create_user_context
94
-
95
- window = user_context.create_browser_window
96
- tab = window.create_browser_tab
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
- @window = window
99
- @tab = tab
100
- @user_context = user_context
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
- add_cookies(tab)
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, creates an isolated UserContext/Window/Tab for one render, yields the tab,
330
- # then unconditionally closes those resources and retires the underlying slot.
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
- user_context = slot[:browser].create_user_context
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bidi2pdf
4
- VERSION = "0.1.25"
4
+ VERSION = "0.1.26"
5
5
  end
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, creates an isolated UserContext/Window/Tab for one render, yields the tab,
252
- # then unconditionally closes those resources and retires the underlying slot.
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)
@@ -1,3 +1,3 @@
1
1
  module Bidi2pdf
2
- VERSION: "0.1.25"
2
+ VERSION: "0.1.26"
3
3
  end
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.25
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