bidi2pdf 0.1.17 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -2
  3. data/README.md +108 -0
  4. data/lib/bidi2pdf/bidi/commands/browsing_context_get_tree.rb +16 -0
  5. data/lib/bidi2pdf/bidi/commands/cdp_send_command.rb +25 -0
  6. data/lib/bidi2pdf/bidi/commands.rb +2 -0
  7. data/lib/bidi2pdf/bidi/session.rb +53 -24
  8. data/lib/bidi2pdf/chrome_sweeper/inspector.rb +117 -0
  9. data/lib/bidi2pdf/chrome_sweeper/settings.rb +79 -0
  10. data/lib/bidi2pdf/chrome_sweeper.rb +398 -0
  11. data/lib/bidi2pdf/chromedriver_api.rb +72 -0
  12. data/lib/bidi2pdf/cli/session_commands.rb +82 -0
  13. data/lib/bidi2pdf/cli.rb +43 -0
  14. data/lib/bidi2pdf/dsl.rb +2 -1
  15. data/lib/bidi2pdf/launcher.rb +2 -1
  16. data/lib/bidi2pdf/session_registry/heartbeat.rb +83 -0
  17. data/lib/bidi2pdf/session_registry.rb +67 -12
  18. data/lib/bidi2pdf/session_sweeper.rb +4 -2
  19. data/lib/bidi2pdf/session_warmer.rb +68 -7
  20. data/lib/bidi2pdf/version.rb +1 -1
  21. data/lib/bidi2pdf.rb +2 -0
  22. data/sig/bidi2pdf/bidi/commands/browsing_context_get_tree.rbs +12 -0
  23. data/sig/bidi2pdf/bidi/commands/cdp_send_command.rbs +23 -0
  24. data/sig/bidi2pdf/bidi/session.rbs +19 -3
  25. data/sig/bidi2pdf/chrome_sweeper/inspector.rbs +61 -0
  26. data/sig/bidi2pdf/chrome_sweeper/settings.rbs +34 -0
  27. data/sig/bidi2pdf/chrome_sweeper.rbs +220 -0
  28. data/sig/bidi2pdf/chromedriver_api.rbs +44 -0
  29. data/sig/bidi2pdf/cli/session_commands.rbs +35 -0
  30. data/sig/bidi2pdf/cli.rbs +6 -0
  31. data/sig/bidi2pdf/session_registry/heartbeat.rbs +47 -0
  32. data/sig/bidi2pdf/session_registry.rbs +39 -8
  33. data/sig/bidi2pdf/session_sweeper.rbs +2 -1
  34. data/sig/bidi2pdf/session_warmer.rbs +35 -2
  35. data/sig/bidi2pdf/version.rbs +1 -1
  36. metadata +17 -1
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bidi2pdf
4
+ class SessionRegistry
5
+ # Keeps the leases of every session this process holds fresh: one thread per process renews
6
+ # them in their registry files every +interval+ seconds, so a sweeper in another process (a
7
+ # second Puma worker, a job worker sharing the registry directory) can tell a live session
8
+ # from one whose process died. The thread runs only while something is held.
9
+ #
10
+ # Fork-aware: a child process (Puma forks its workers) starts with nothing held and without the
11
+ # parent's thread - the parent's sessions are the parent's to renew.
12
+ module Heartbeat
13
+ DEFAULT_INTERVAL = DEFAULT_LEASE_TTL / 3.0
14
+
15
+ @mutex = Mutex.new
16
+
17
+ class << self
18
+ # @return [Numeric] seconds between renewals - keep it well below the sweeper's lease_ttl.
19
+ attr_writer :interval
20
+
21
+ def interval = @interval || DEFAULT_INTERVAL
22
+
23
+ def hold(registry, session_id)
24
+ synchronize do
25
+ (held[registry.path] ||= [registry, Set.new])[1] << session_id.to_s
26
+ @thread = Thread.new { beat_loop } unless @thread&.alive?
27
+ end
28
+ end
29
+
30
+ def release(registry, session_id)
31
+ synchronize do
32
+ ids = held.dig(registry.path, 1)
33
+ next unless ids
34
+
35
+ ids.delete(session_id.to_s)
36
+ held.delete(registry.path) if ids.empty?
37
+ end
38
+ end
39
+
40
+ # @return [Array<String>] the session ids this process holds in +registry+'s file.
41
+ def held_ids(registry)
42
+ synchronize { held.dig(registry.path, 1).to_a }
43
+ end
44
+
45
+ # Renews every held lease now.
46
+ def beat!(now: Time.now.to_i)
47
+ synchronize { held.values.map { |registry, ids| [registry, ids.to_a] } }
48
+ .each { |registry, ids| registry.renew(ids, at: now) }
49
+ end
50
+
51
+ private
52
+
53
+ def beat_loop
54
+ loop do
55
+ sleep interval
56
+ break if synchronize { held.empty? }
57
+
58
+ beat!
59
+ end
60
+ rescue StandardError => e
61
+ Bidi2pdf.logger.warn "session registry heartbeat stopped: #{e.message}"
62
+ end
63
+
64
+ def held
65
+ reset_after_fork
66
+ @held ||= {}
67
+ end
68
+
69
+ def reset_after_fork
70
+ return if @pid == Process.pid
71
+
72
+ @pid = Process.pid
73
+ @held = {}
74
+ @thread = nil
75
+ end
76
+
77
+ def synchronize(&)
78
+ @mutex.synchronize(&)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -2,48 +2,94 @@
2
2
 
3
3
  require "digest"
4
4
  require "json"
5
+ require "socket"
5
6
  require "tmpdir"
6
7
 
7
8
  module Bidi2pdf
8
- # Remembers which sessions a SessionWarmer opened on a shared (remote) chromedriver, and when, in a
9
- # small JSON file per chromedriver URL - so a process started later can find and close the ones a
10
- # process that died without a clean shutdown left behind (see SessionSweeper). chromedriver itself
11
- # keeps a session until someone deletes it, and it drops any tag we could attach to one (a custom
12
- # capability is accepted but echoed nowhere - checked against chromedriver 153), hence a file.
9
+ # Remembers which sessions bidi2pdf opened on a shared (remote) chromedriver, when, by which
10
+ # process, and whether that process is still alive - in a small JSON file per chromedriver URL - so
11
+ # a process started later, or a ChromeSweeper, can find and close the ones a process that died
12
+ # without a clean shutdown left behind (see SessionSweeper). chromedriver itself keeps a session
13
+ # until someone deletes it, and it drops any tag we could attach to one (a custom capability is
14
+ # accepted but echoed nowhere - checked against chromedriver 153), hence a file.
15
+ #
16
+ # Liveness is a lease: a session the process #hold s is renewed by its Heartbeat every
17
+ # Heartbeat.interval seconds. A session whose lease is fresh belongs to a live process - a render
18
+ # in flight or a warm spare - and no sweeper closes it; one whose lease ran out belongs to a dead
19
+ # process. #record alone writes an entry nobody renews.
13
20
  #
14
21
  # Everything here is best effort and fail-open: a directory that is not writable, a locked-out or
15
22
  # corrupt file only switches orphan tracking off for this process (one warning, one
16
23
  # +session_warmer.registry_unavailable.bidi2pdf+ notification) - rendering never depends on it.
17
24
  # The file is created 0600 and every change happens under an exclusive lock, so processes sharing
18
- # the directory (a dev server, a job worker, a spec run) can record at the same time.
25
+ # the directory (Puma workers, a job worker, a spec run) can record at the same time.
19
26
  class SessionRegistry
20
27
  FILE_PREFIX = "bidi2pdf-sessions-"
28
+ # Seconds a lease stays fresh without a renewal.
29
+ DEFAULT_LEASE_TTL = 60
21
30
 
22
31
  attr_reader :path
23
32
 
33
+ def self.owner = "#{Socket.gethostname}:#{Process.pid}"
34
+
24
35
  def initialize(session_url, dir: nil)
25
36
  @path = File.join(dir || Dir.tmpdir, "#{FILE_PREFIX}#{Digest::SHA256.hexdigest(session_url.to_s)[0, 16]}.json")
26
37
  @warned = false
27
38
  end
28
39
 
29
- # Records a session the caller just opened.
40
+ # Records a session the caller opened and keeps its lease fresh while this process lives.
41
+ def hold(session_id)
42
+ return false if session_id.nil?
43
+
44
+ recorded = record(session_id)
45
+ Heartbeat.hold(self, session_id)
46
+ recorded
47
+ end
48
+
49
+ # Stops renewing the lease but keeps the entry - for a session this process could not close, so
50
+ # a sweeper takes it once the lease ran out.
51
+ def release(session_id)
52
+ Heartbeat.release(self, session_id) unless session_id.nil?
53
+ end
54
+
55
+ # Writes an entry, leased from +created_at+ on but not renewed (see #hold).
30
56
  def record(session_id, created_at: Time.now.to_i)
31
57
  return false if session_id.nil?
32
58
 
33
- update { |entries| entries[session_id.to_s] = created_at.to_i }
59
+ update do |entries|
60
+ entries[session_id.to_s] = { "created_at" => created_at.to_i, "renewed_at" => created_at.to_i, "owner" => self.class.owner }
61
+ end
34
62
  end
35
63
 
36
64
  # Forgets a session the caller closed itself (or found gone).
37
65
  def forget(session_id)
38
66
  return false if session_id.nil?
39
67
 
68
+ release(session_id)
40
69
  update { |entries| entries.delete(session_id.to_s) }
41
70
  end
42
71
 
72
+ # Renews the leases of +session_ids+ that are still recorded.
73
+ def renew(session_ids, at: Time.now.to_i)
74
+ update do |entries|
75
+ session_ids.each { |id| entries[id]["renewed_at"] = at.to_i if entries.key?(id) }
76
+ end
77
+ end
78
+
79
+ # Every recorded session: { id => opened at (epoch seconds) }.
80
+ def recorded
81
+ read.transform_values { |entry| entry["created_at"] }
82
+ end
83
+
84
+ # The recorded session ids whose lease was renewed within +ttl+ seconds before +now+ - sessions
85
+ # of a live process.
86
+ def leased(now: Time.now.to_i, ttl: DEFAULT_LEASE_TTL)
87
+ read.select { |_, entry| entry["renewed_at"].to_i >= now - ttl }.keys
88
+ end
89
+
43
90
  # The recorded session ids opened at or before +cutoff+ (epoch seconds).
44
91
  def recorded_before(cutoff)
45
- entries = read
46
- entries.select { |_, created_at| created_at <= cutoff }.keys
92
+ recorded.select { |_, created_at| created_at <= cutoff }.keys
47
93
  end
48
94
 
49
95
  private
@@ -76,16 +122,23 @@ module Bidi2pdf
76
122
  false
77
123
  end
78
124
 
79
- # A corrupt or foreign file counts as empty rather than as an error.
125
+ # A corrupt or foreign file counts as empty rather than as an error; an entry of the first
126
+ # format (just the creation time) has no lease.
80
127
  def parse(text)
81
128
  data = text.to_s.empty? ? {} : JSON.parse(text)
82
129
  return {} unless data.is_a?(Hash)
83
130
 
84
- data.select { |id, created_at| id.is_a?(String) && created_at.is_a?(Integer) }
131
+ data.filter_map { |id, entry| [id, normalize(entry)] if id.is_a?(String) && normalize(entry) }.to_h
85
132
  rescue JSON::ParserError
86
133
  {}
87
134
  end
88
135
 
136
+ def normalize(entry)
137
+ return { "created_at" => entry } if entry.is_a?(Integer)
138
+
139
+ entry if entry.is_a?(Hash) && entry["created_at"].is_a?(Integer)
140
+ end
141
+
89
142
  def unavailable!(error)
90
143
  return if @warned
91
144
 
@@ -95,3 +148,5 @@ module Bidi2pdf
95
148
  end
96
149
  end
97
150
  end
151
+
152
+ require_relative "session_registry/heartbeat"
@@ -9,7 +9,8 @@ module Bidi2pdf
9
9
  # whole Chrome each until someone deletes them. Run once when a warmer starts.
10
10
  #
11
11
  # Only recorded sessions are ever touched - another tool's sessions on the same chromedriver never
12
- # are. A recorded session that is already gone ("invalid session id" / 404) is just forgotten.
12
+ # are, and neither is one whose lease a live process keeps renewing (SessionRegistry#hold). A
13
+ # recorded session that is already gone ("invalid session id" / 404) is just forgotten.
13
14
  # Fail-open: any error is logged and ends the sweep; it never raises.
14
15
  class SessionSweeper
15
16
  # @param session_url [String] the chromedriver's new-session URL (".../session").
@@ -25,7 +26,8 @@ module Bidi2pdf
25
26
  #
26
27
  # @return [Integer] how many sessions were actually closed.
27
28
  def sweep(older_than:, now: Time.now.to_i)
28
- closed = @registry.recorded_before(now - older_than).count { |id| closed_now?(id) }
29
+ leftovers = @registry.recorded_before(now - older_than) - @registry.leased(now: now)
30
+ closed = leftovers.count { |id| closed_now?(id) }
29
31
  report(closed)
30
32
  closed
31
33
  rescue StandardError => e
@@ -61,6 +61,13 @@ module Bidi2pdf
61
61
  # Dir.tmpdir. Every process that should clean up after the others must share it.
62
62
  attr_accessor :registry_dir
63
63
 
64
+ # @return [Hash, nil] Options for a ChromeSweeper on the remote chromedriver (+scope+,
65
+ # +orphan_age+, +min_age+, +unresponsive_checks+, +max_sessions+, +pids_limit+, +interval+,
66
+ # ...), or +nil+ (default) for none. This warmer's own sessions are never touched by it; with
67
+ # an +interval+ it sweeps in the background, and a session that fails to start triggers one
68
+ # sweep and one retry. Remote mode only.
69
+ attr_accessor :sweeper
70
+
64
71
  DEFAULT_MAX_IDLE_AGE = 300
65
72
 
66
73
  def initialize
@@ -72,6 +79,7 @@ module Bidi2pdf
72
79
  @max_idle_age = DEFAULT_MAX_IDLE_AGE
73
80
  @orphan_age = :auto
74
81
  @registry_dir = nil
82
+ @sweeper = nil
75
83
  end
76
84
 
77
85
  # The orphan age in seconds, or nil when the sweep is off (also when +:auto+ has no
@@ -85,6 +93,7 @@ module Bidi2pdf
85
93
  validate_size!
86
94
  validate_max_idle_age!
87
95
  validate_orphan_age!
96
+ validate_sweeper!
88
97
  end
89
98
 
90
99
  private
@@ -106,6 +115,12 @@ module Bidi2pdf
106
115
 
107
116
  raise ArgumentError, "orphan_age must be :auto, nil or a positive number of seconds, got #{orphan_age.inspect}"
108
117
  end
118
+
119
+ def validate_sweeper!
120
+ return if sweeper.nil? || sweeper.is_a?(Hash)
121
+
122
+ raise ArgumentError, "sweeper must be nil or a Hash of ChromeSweeper options, got #{sweeper.inspect}"
123
+ end
109
124
  end
110
125
 
111
126
  class << self
@@ -166,6 +181,12 @@ module Bidi2pdf
166
181
  instance.with_tab(&)
167
182
  end
168
183
 
184
+ # Sweeps the remote chromedriver with the configured sweeper; nil when there is none (or no
185
+ # warmer yet - this never creates one).
186
+ def sweep!(reason: :manual)
187
+ @instance&.sweep!(reason: reason)
188
+ end
189
+
169
190
  # Retires every currently-warm spare and resets the singleton.
170
191
  def shutdown
171
192
  @instance&.shutdown
@@ -207,10 +228,19 @@ module Bidi2pdf
207
228
  @warming = 0
208
229
  @shutdown = false
209
230
  @reaper_wakeup = Thread::Queue.new
231
+ @own_sessions = []
210
232
  @registry = build_registry
211
233
  sweep_leftovers
234
+ @sweeper = build_sweeper
212
235
  prewarm
213
- @reaper = Thread.new { reap_loop } if @config.max_idle_age
236
+ start_background_work
237
+ end
238
+
239
+ # Sweeps the remote chromedriver now - for an application that suspects leaked sessions.
240
+ #
241
+ # @return [ChromeSweeper::Result, nil] nil when no sweeper is configured.
242
+ def sweep!(reason: :manual)
243
+ @sweeper&.sweep!(reason: reason)
214
244
  end
215
245
 
216
246
  # Checks out a slot, creates an isolated UserContext/Window/Tab for one render, yields the tab,
@@ -246,6 +276,7 @@ module Bidi2pdf
246
276
 
247
277
  @reaper_wakeup << :stop
248
278
  @reaper&.join
279
+ @sweeper&.stop
249
280
  threads.each(&:join)
250
281
  spares.each { |slot| retire(slot) }
251
282
  end
@@ -263,19 +294,47 @@ module Bidi2pdf
263
294
  end
264
295
 
265
296
  def create_slot
266
- @slot_factory.call.tap { |slot| @registry&.record(session_id_of(slot)) }
297
+ slot = new_slot
298
+ id = session_id_of(slot)
299
+ @registry&.hold(id)
300
+ @mutex.synchronize { @own_sessions << id } if id
301
+ slot
302
+ end
303
+
304
+ # A chromedriver out of room for another Chrome refuses the session; with a sweeper, close
305
+ # everything nobody holds (a pressure sweep) and try once more.
306
+ def new_slot
307
+ @slot_factory.call
308
+ rescue Bidi2pdf::SessionNotStartedError
309
+ raise unless @sweeper
310
+
311
+ @sweeper.sweep!(reason: :create_failed, pressure: true)
312
+ @slot_factory.call
313
+ end
314
+
315
+ def start_background_work
316
+ @reaper = Thread.new { reap_loop } if @config.max_idle_age
317
+ @sweeper.start if @sweeper&.interval
318
+ end
319
+
320
+ def build_sweeper
321
+ return unless @config.remote_browser_url && @config.sweeper
322
+
323
+ options = { registry: @registry }.merge(@config.sweeper)
324
+ Bidi2pdf::ChromeSweeper.new(@config.remote_browser_url, own_sessions: -> { @mutex.synchronize { @own_sessions.dup } }, **options)
267
325
  end
268
326
 
269
- # Remote mode with an orphan age: sessions are recorded, so a later start can close the ones
270
- # this process leaves behind if it dies uncleanly - and this start closes others' leftovers.
327
+ # Remote mode with an orphan age or a sweeper: sessions are recorded, so a later start (or the
328
+ # sweeper, whose default scope is :recorded) can close the ones this process leaves behind if it
329
+ # dies uncleanly. The start-up sweep itself needs the orphan age - see #sweep_leftovers.
271
330
  def build_registry
272
- return unless @config.remote_browser_url && @config.effective_orphan_age
331
+ return unless @config.remote_browser_url && (@config.effective_orphan_age || @config.sweeper)
273
332
 
274
333
  Bidi2pdf::SessionRegistry.new(@config.remote_browser_url, dir: @config.registry_dir)
275
334
  end
276
335
 
277
336
  def sweep_leftovers
278
- return unless @registry
337
+ return unless @registry && @config.effective_orphan_age
279
338
 
280
339
  Bidi2pdf::SessionSweeper.new(@config.remote_browser_url, @registry).sweep(older_than: @config.effective_orphan_age)
281
340
  end
@@ -417,7 +476,9 @@ module Bidi2pdf
417
476
  # already gone - the next sweep just drops it - never an open session nobody knows about.
418
477
  def retire(slot)
419
478
  self.class.retire_slot(session: slot[:session], manager: slot[:manager])
420
- @registry&.forget(session_id_of(slot))
479
+ id = session_id_of(slot)
480
+ @registry&.forget(id)
481
+ @mutex.synchronize { @own_sessions.delete(id) }
421
482
  end
422
483
 
423
484
  def safe_close(label, &)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bidi2pdf
4
- VERSION = "0.1.17"
4
+ VERSION = "0.1.18"
5
5
  end
data/lib/bidi2pdf.rb CHANGED
@@ -12,6 +12,8 @@ require_relative "bidi2pdf/notifications/logging_subscriber"
12
12
  require_relative "bidi2pdf/notifications/json_subscriber"
13
13
  require_relative "bidi2pdf/session_registry"
14
14
  require_relative "bidi2pdf/session_sweeper"
15
+ require_relative "bidi2pdf/chromedriver_api"
16
+ require_relative "bidi2pdf/chrome_sweeper"
15
17
  require_relative "bidi2pdf/session_warmer"
16
18
  require_relative "bidi2pdf/verbose_logger"
17
19
 
@@ -0,0 +1,12 @@
1
+ module Bidi2pdf
2
+ module Bidi
3
+ module Commands
4
+ # browsingContext.getTree - the session's top-level browsing contexts (tabs/windows).
5
+ class BrowsingContextGetTree
6
+ include Base
7
+
8
+ def method_name: () -> "browsingContext.getTree"
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,23 @@
1
+ module Bidi2pdf
2
+ module Bidi
3
+ module Commands
4
+ # goog:cdp.sendCommand - chromedriver's BiDi extension that runs one Chrome DevTools Protocol
5
+ # command; without a +session+ it runs at browser level (e.g. SystemInfo.getProcessInfo).
6
+ class CdpSendCommand
7
+ @cdp_method: untyped
8
+
9
+ @cdp_params: untyped
10
+
11
+ @session: untyped
12
+
13
+ include Base
14
+
15
+ def initialize: (method: untyped, ?params: ::Hash[untyped, untyped], ?session: untyped?) -> void
16
+
17
+ def params: () -> untyped
18
+
19
+ def method_name: () -> "goog:cdp.sendCommand"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -27,6 +27,8 @@ module Bidi2pdf
27
27
 
28
28
  @chrome_args: untyped
29
29
 
30
+ @registry: untyped
31
+
30
32
  @client: untyped
31
33
 
32
34
  @browser: untyped
@@ -59,7 +61,10 @@ module Bidi2pdf
59
61
  # @param [String] session_url The URL for the session.
60
62
  # @param [Boolean] headless Whether to run the browser in headless mode. Defaults to true.
61
63
  # @param [Array<String>] chrome_args Additional Chrome arguments. Defaults to predefined arguments.
62
- def initialize: (session_url: untyped, ?headless: bool, ?chrome_args: untyped) -> void
64
+ # @param [Bidi2pdf::SessionRegistry, nil] registry Records the session on a shared (remote)
65
+ # chromedriver while it is open, so a sweep can close it if this process dies without
66
+ # closing it (ChromeSweeper, SessionSweeper). nil records nothing.
67
+ def initialize: (session_url: untyped, ?headless: bool, ?chrome_args: untyped, ?registry: untyped?) -> void
63
68
 
64
69
  # Starts the session and initializes the client.
65
70
  #
@@ -76,8 +81,8 @@ module Bidi2pdf
76
81
  # @return [Bidi2pdf::Bidi::Browser] The browser instance.
77
82
  def browser: () -> untyped
78
83
 
79
- # Closes the session and cleans up resources.
80
- # rubocop:disable Metrics/AbcSize
84
+ # Closes the session and cleans up resources: BiDi +session.end+ first, and when Chrome does not
85
+ # answer that (a tab stuck in a loop), chromedriver's +DELETE /session/{id}+.
81
86
  def close: () -> untyped
82
87
 
83
88
  # Retrieves user contexts for the session.
@@ -164,6 +169,17 @@ module Bidi2pdf
164
169
  # @raise [SessionNotStartedError] If the session could not be started.
165
170
  def handle_error: (untyped value) -> (nil | untyped)
166
171
 
172
+ def end_via_bidi: () -> untyped
173
+
174
+ def record_session: (untyped id) -> untyped
175
+
176
+ # The BiDi session.end got no answer - typically a Chrome that hangs or crashed. chromedriver
177
+ # still ends the session (and kills its Chrome) on an HTTP DELETE, so a hung Chrome does not
178
+ # outlive this process. Only for a session this object created over HTTP (it knows the id).
179
+ #
180
+ # @return [Boolean] true when the session is gone.
181
+ def delete_via_chromedriver: () -> untyped
182
+
167
183
  # Cleans up resources associated with the session.
168
184
  def cleanup: () -> untyped
169
185
  end
@@ -0,0 +1,61 @@
1
+ module Bidi2pdf
2
+ class ChromeSweeper
3
+ # Looks at one session on a chromedriver from the outside: attaches a second BiDi connection to
4
+ # its +webSocketUrl+ (chromedriver allows that for any session), asks for the tab tree, the first
5
+ # tab's +performance.timeOrigin+ (Chrome keeps no session start time anywhere - the first tab is
6
+ # created with the session, so its time origin is the session's age to within a few hundred ms)
7
+ # and the renderer CPU times (CDP +SystemInfo.getProcessInfo+ through +goog:cdp.sendCommand+).
8
+ #
9
+ # Read-only, and never reads page content: tab URLs are counted, not returned or logged (a
10
+ # +data:+ URL carries the whole rendered document). Closes its own connection every time.
11
+ class Inspector
12
+ @timeout: untyped
13
+
14
+ @clock: untyped
15
+
16
+ @client_factory: untyped
17
+
18
+ # @!attribute age [Float, nil] seconds since the session started, nil when nothing tells.
19
+ # @!attribute source [Symbol] :registry, :tab or :unknown - where +age+ comes from.
20
+ # @!attribute responsive [Boolean] whether the session answered the checks.
21
+ # @!attribute cpu_times [Hash{Integer => Float}] renderer pid => CPU seconds.
22
+ # @!attribute live [Boolean] a live process holds its lease (never inspected, never closed).
23
+ SessionInfo: untyped
24
+
25
+ DEFAULT_TIMEOUT: 5
26
+
27
+ # @param clock [#call] epoch seconds as a Float.
28
+ # @param client_factory [#call] ws_url -> a started, open Bidi::Client; injectable for tests.
29
+ def initialize: (?timeout: untyped, ?clock: untyped, ?client_factory: untyped?) -> void
30
+
31
+ # @param entry [ChromedriverApi::Entry]
32
+ # @param recorded_at [Numeric, nil] epoch seconds the registry has for it; wins over the tab.
33
+ # @return [SessionInfo]
34
+ def examine: (untyped entry, ?recorded_at: untyped?) -> untyped
35
+
36
+ private
37
+
38
+ def probe: (untyped client, untyped entry, untyped recorded_at) -> untyped
39
+
40
+ def tree: (untyped client) -> untyped
41
+
42
+ # A tab stuck in an endless loop never evaluates anything - nil then.
43
+ def time_origin: (untyped client, untyped context) -> untyped
44
+
45
+ # On a freshly attached connection the first command to a session - and, seen once in the
46
+ # acceptance run, to a tab - can time out although the session is healthy (chromedriver
47
+ # 153/154); the second one answers. Only a second timeout counts.
48
+ def once_more_on_timeout: () { () -> untyped } -> untyped
49
+
50
+ def cpu_times: (untyped client) -> untyped
51
+
52
+ def age_of: (untyped recorded_at, untyped origin) -> (::Array[untyped | :registry] | ::Array[untyped | :tab] | ::Array[nil | :unknown])
53
+
54
+ def unreachable: (untyped entry, untyped recorded_at) -> untyped
55
+
56
+ def command: (untyped client, untyped cmd) -> untyped
57
+
58
+ def connect: (untyped ws_url) -> untyped
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,34 @@
1
+ module Bidi2pdf
2
+ class ChromeSweeper
3
+ SCOPES: ::Array[:recorded | :all]
4
+
5
+ DEFAULT_ORPHAN_AGE: 600
6
+
7
+ DEFAULT_MIN_AGE: 60
8
+
9
+ DEFAULT_UNRESPONSIVE_CHECKS: 2
10
+
11
+ DEFAULT_PIDS_BUDGET: ::Float
12
+
13
+ DEFAULT_THREADS_PER_SESSION: 110
14
+
15
+ DEFAULT_LEASE_TTL: untyped
16
+
17
+ # What a ChromeSweeper closes and when - validated on construction; an unknown setting raises
18
+ # ArgumentError.
19
+ #
20
+ # @!attribute scope [Symbol] :recorded (sessions a SessionRegistry knows) or :all.
21
+ # @!attribute orphan_age [Numeric, nil] close sessions older than this; nil turns the rule off.
22
+ # @!attribute min_age [Numeric] never close a session younger than this.
23
+ # @!attribute unresponsive_checks [Integer, nil] close after this many failed checks in a row.
24
+ # @!attribute max_sessions [Integer, Symbol, nil] the limit; +:auto+ derives it from pids_limit.
25
+ # @!attribute pids_limit [Integer, nil] the chromedriver container's pids limit, for +:auto+.
26
+ # @!attribute pids_budget [Float] share of +pids_limit+ Chrome sessions may use.
27
+ # @!attribute threads_per_session [Integer] threads (Docker counts them as pids) of one session.
28
+ # @!attribute interval [Numeric, nil] seconds between sweeps of ChromeSweeper#start's thread.
29
+ # @!attribute dry_run [Boolean] report what would be closed, close nothing.
30
+ # @!attribute lease_ttl [Numeric] a recorded session renewed within this many seconds belongs to
31
+ # a live process and is never closed (SessionRegistry#hold).
32
+ Settings: untyped
33
+ end
34
+ end