bidi2pdf 0.1.14 → 0.1.16
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/.rubocop.yml +20 -0
- data/CHANGELOG.md +69 -2
- data/README.md +258 -10
- data/docker/Dockerfile +4 -0
- data/docker/Dockerfile.slim +5 -1
- data/lib/bidi2pdf/bidi/browser_tab.rb +45 -4
- data/lib/bidi2pdf/bidi/buffered_web_socket_client.rb +178 -0
- data/lib/bidi2pdf/bidi/client.rb +12 -5
- data/lib/bidi2pdf/bidi/commands/base.rb +2 -0
- data/lib/bidi2pdf/bidi/network_event.rb +12 -3
- data/lib/bidi2pdf/bidi/network_events.rb +9 -1
- data/lib/bidi2pdf/bidi/session.rb +1 -1
- data/lib/bidi2pdf/chromedriver_manager.rb +10 -6
- data/lib/bidi2pdf/cli/json_output.rb +30 -0
- data/lib/bidi2pdf/cli.rb +559 -17
- data/lib/bidi2pdf/diagnose.rb +119 -0
- data/lib/bidi2pdf/error_codes.rb +59 -0
- data/lib/bidi2pdf/exit_codes.rb +44 -0
- data/lib/bidi2pdf/launcher.rb +23 -0
- data/lib/bidi2pdf/manifest.rb +65 -0
- data/lib/bidi2pdf/notifications/json_subscriber.rb +78 -0
- data/lib/bidi2pdf/notifications/logging_subscriber.rb +2 -0
- data/lib/bidi2pdf/pdf_inspection.rb +62 -0
- data/lib/bidi2pdf/recipe/loader.rb +44 -0
- data/lib/bidi2pdf/recipe/runner.rb +229 -0
- data/lib/bidi2pdf/recipe/schema_shape.rb +228 -0
- data/lib/bidi2pdf/recipe/validator.rb +138 -0
- data/lib/bidi2pdf/recipe.rb +83 -0
- data/lib/bidi2pdf/result.rb +67 -0
- data/lib/bidi2pdf/result_collector.rb +150 -0
- data/lib/bidi2pdf/schema.rb +382 -0
- data/lib/bidi2pdf/session_runner.rb +42 -0
- data/lib/bidi2pdf/session_warmer.rb +377 -0
- data/lib/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rb +4 -2
- data/lib/bidi2pdf/version.rb +1 -1
- data/lib/bidi2pdf.rb +114 -9
- data/sig/bidi2pdf/bidi/browser_tab.rbs +17 -0
- data/sig/bidi2pdf/bidi/buffered_web_socket_client.rbs +81 -0
- data/sig/bidi2pdf/bidi/client.rbs +10 -2
- data/sig/bidi2pdf/bidi/network_event.rbs +11 -1
- data/sig/bidi2pdf/bidi/session.rbs +1 -1
- data/sig/bidi2pdf/chromedriver_manager.rbs +4 -0
- data/sig/bidi2pdf/cli/json_output.rbs +19 -0
- data/sig/bidi2pdf/cli.rbs +112 -2
- data/sig/bidi2pdf/diagnose.rbs +30 -0
- data/sig/bidi2pdf/error_codes.rbs +21 -0
- data/sig/bidi2pdf/exit_codes.rbs +21 -0
- data/sig/bidi2pdf/launcher.rbs +9 -0
- data/sig/bidi2pdf/manifest.rbs +38 -0
- data/sig/bidi2pdf/notifications/json_subscriber.rbs +45 -0
- data/sig/bidi2pdf/pdf_inspection.rbs +33 -0
- data/sig/bidi2pdf/recipe/loader.rbs +20 -0
- data/sig/bidi2pdf/recipe/runner.rbs +92 -0
- data/sig/bidi2pdf/recipe/schema_shape.rbs +85 -0
- data/sig/bidi2pdf/recipe/validator.rbs +54 -0
- data/sig/bidi2pdf/recipe.rbs +72 -0
- data/sig/bidi2pdf/result.rbs +71 -0
- data/sig/bidi2pdf/result_collector.rbs +106 -0
- data/sig/bidi2pdf/schema.rbs +45 -0
- data/sig/bidi2pdf/session_runner.rbs +17 -0
- data/sig/bidi2pdf/session_warmer.rbs +221 -0
- data/sig/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rbs +1 -1
- data/sig/bidi2pdf/version.rbs +1 -1
- data/sig/bidi2pdf.rbs +84 -0
- data/tasks/release_credentials_check.rake +97 -0
- metadata +39 -4
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bidi2pdf
|
|
4
|
+
# Keeps a small number of Chrome sessions pre-warmed (chromedriver started, WebSocket connected,
|
|
5
|
+
# browser ready) so a render can skip that startup latency on the request path. Isolation matches
|
|
6
|
+
# today's one-Chrome-per-render model exactly: every checked-out slot is used for exactly one
|
|
7
|
+
# +with_tab+ block and then retired (never returned to the cache) - only a fresh replacement is
|
|
8
|
+
# warmed in its place, off the request path. Checkout never waits for a warm slot: one is used if
|
|
9
|
+
# it is ready, otherwise a slot is created synchronously on the spot, i.e. today's exact behavior
|
|
10
|
+
# for that one render - including its failure mode: if that cold start fails, the error propagates
|
|
11
|
+
# out of +with_tab+ exactly as it would without the warmer.
|
|
12
|
+
#
|
|
13
|
+
# @example Rails initializer
|
|
14
|
+
# Bidi2pdf::SessionWarmer.configure do |c|
|
|
15
|
+
# c.size = 2
|
|
16
|
+
# c.headless = true
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# @example Per-request usage
|
|
20
|
+
# Bidi2pdf::SessionWarmer.with_tab do |tab|
|
|
21
|
+
# tab.navigate_to(url)
|
|
22
|
+
# tab.print("invoice.pdf")
|
|
23
|
+
# end
|
|
24
|
+
class SessionWarmer
|
|
25
|
+
# Configuration for the session warmer.
|
|
26
|
+
class Configuration
|
|
27
|
+
# @return [Integer] Number of Chrome slots to keep pre-warmed.
|
|
28
|
+
attr_accessor :size
|
|
29
|
+
|
|
30
|
+
# @return [Boolean] Whether to run Chrome in headless mode.
|
|
31
|
+
attr_accessor :headless
|
|
32
|
+
|
|
33
|
+
# @return [Array<String>] Chrome launch arguments.
|
|
34
|
+
attr_accessor :chrome_args
|
|
35
|
+
|
|
36
|
+
# @return [String, nil] A remote chromedriver session URL (e.g. a `remote-chrome` sidecar). When
|
|
37
|
+
# set, a slot connects directly to it instead of spawning a local ChromedriverManager -
|
|
38
|
+
# mirrors Launcher#session's own local/remote branch.
|
|
39
|
+
attr_accessor :remote_browser_url
|
|
40
|
+
|
|
41
|
+
# @return [#call, nil] Optional factory callable that returns a slot hash - injectable for tests.
|
|
42
|
+
# A custom factory owns the cleanup of anything it half-built before raising: the warmer can
|
|
43
|
+
# only retire a slot it was actually handed.
|
|
44
|
+
attr_accessor :slot_factory
|
|
45
|
+
|
|
46
|
+
# @return [Numeric, nil] Seconds a warm slot may sit unused before it is retired and replaced.
|
|
47
|
+
# A warm slot is an open, unauthenticated automation endpoint (chromedriver's port, Chrome's
|
|
48
|
+
# debugging port) for as long as it idles, so that window is bounded by default. A slot older
|
|
49
|
+
# than this is never handed out, and a background reaper recycles idle ones within roughly
|
|
50
|
+
# 1.25x this value even when no render ever comes. +nil+ disables the limit.
|
|
51
|
+
attr_accessor :max_idle_age
|
|
52
|
+
|
|
53
|
+
DEFAULT_MAX_IDLE_AGE = 300
|
|
54
|
+
|
|
55
|
+
def initialize
|
|
56
|
+
@size = 1
|
|
57
|
+
@headless = true
|
|
58
|
+
@chrome_args = Bidi2pdf::Bidi::Session::DEFAULT_CHROME_ARGS
|
|
59
|
+
@remote_browser_url = nil
|
|
60
|
+
@slot_factory = nil
|
|
61
|
+
@max_idle_age = DEFAULT_MAX_IDLE_AGE
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @raise [ArgumentError] if a setting can't be honored - checked once, at construction.
|
|
65
|
+
def validate!
|
|
66
|
+
validate_size!
|
|
67
|
+
validate_max_idle_age!
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def validate_size!
|
|
73
|
+
return if size.is_a?(Integer) && !size.negative?
|
|
74
|
+
|
|
75
|
+
raise ArgumentError, "size must be a non-negative Integer, got #{size.inspect}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validate_max_idle_age!
|
|
79
|
+
return if max_idle_age.nil? || (max_idle_age.is_a?(Numeric) && max_idle_age.positive?)
|
|
80
|
+
|
|
81
|
+
raise ArgumentError, "max_idle_age must be nil or a positive number of seconds, got #{max_idle_age.inspect}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
class << self
|
|
86
|
+
# Configures the warmer and eagerly (re)creates the singleton, warming config.size slots right
|
|
87
|
+
# here - at boot/configuration time, off the request path - instead of lazily on whichever
|
|
88
|
+
# request happens to trigger the first #with_tab (which would otherwise pay for every
|
|
89
|
+
# configured slot, serially, on that one unlucky request).
|
|
90
|
+
def configure
|
|
91
|
+
@config = Configuration.new
|
|
92
|
+
yield @config if block_given?
|
|
93
|
+
@instance&.shutdown
|
|
94
|
+
# Cleared first: if the constructor below raises, a stale, already-shut-down instance must
|
|
95
|
+
# not stay registered (it would keep serving cold slots but never warm again).
|
|
96
|
+
@instance = nil
|
|
97
|
+
@instance = new(@config, slot_factory: @config.slot_factory)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Returns the warmer's configuration, initializing defaults if needed.
|
|
101
|
+
def config
|
|
102
|
+
@config ||= Configuration.new
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Returns a callable that creates one real, fully-warmed Chrome slot from +config+. If building
|
|
106
|
+
# fails part-way (chromedriver up, but the session or its browser never came ready), whatever
|
|
107
|
+
# already exists is retired before the error propagates - no caller ever gets a reference to a
|
|
108
|
+
# half-built slot, so nobody else could clean it up.
|
|
109
|
+
def default_slot_factory(config)
|
|
110
|
+
lambda do
|
|
111
|
+
parts = {}
|
|
112
|
+
build_slot(config, parts)
|
|
113
|
+
rescue StandardError
|
|
114
|
+
retire_slot(session: parts[:session], manager: parts[:manager])
|
|
115
|
+
raise
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Closes a slot's session and stops its chromedriver (nil for a remote slot, or for a part that
|
|
120
|
+
# was never created). Each step is independent: a failure in one is logged, never raised, and
|
|
121
|
+
# never skips the other.
|
|
122
|
+
def retire_slot(session:, manager:)
|
|
123
|
+
safe_close("session") { session&.close }
|
|
124
|
+
safe_close("manager") { manager&.stop }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def safe_close(label)
|
|
128
|
+
yield
|
|
129
|
+
rescue StandardError => e
|
|
130
|
+
Bidi2pdf.logger.warn "session_warmer: error closing #{label}: #{e.message}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Returns the shared singleton warmer instance, creating it (and pre-warming it) on first call.
|
|
134
|
+
def instance
|
|
135
|
+
@instance ||= new(config, slot_factory: config.slot_factory)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Checks out a slot, yields a fresh tab for one render, then retires the slot.
|
|
139
|
+
def with_tab(&)
|
|
140
|
+
instance.with_tab(&)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Retires every currently-warm spare and resets the singleton.
|
|
144
|
+
def shutdown
|
|
145
|
+
@instance&.shutdown
|
|
146
|
+
@instance = nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
# Records each part in +parts+ the moment it exists, so #default_slot_factory's rescue can
|
|
152
|
+
# retire exactly what was created so far. Mirrors Launcher#session's local/remote branch.
|
|
153
|
+
def build_slot(config, parts)
|
|
154
|
+
config.remote_browser_url ? connect_remote(config, parts) : start_local(config, parts)
|
|
155
|
+
|
|
156
|
+
{ session: parts[:session], browser: parts[:session].browser, manager: parts[:manager] }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def connect_remote(config, parts)
|
|
160
|
+
parts[:session] = Bidi2pdf::Bidi::Session.new(
|
|
161
|
+
session_url: config.remote_browser_url,
|
|
162
|
+
headless: config.headless,
|
|
163
|
+
chrome_args: config.chrome_args
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def start_local(config, parts)
|
|
168
|
+
parts[:manager] = Bidi2pdf::ChromedriverManager.new(port: 0, headless: config.headless, chrome_args: config.chrome_args)
|
|
169
|
+
parts[:manager].start
|
|
170
|
+
parts[:session] = parts[:manager].session
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def initialize(config, slot_factory: nil)
|
|
175
|
+
config.validate!
|
|
176
|
+
@config = config
|
|
177
|
+
@slot_factory = slot_factory || self.class.default_slot_factory(config)
|
|
178
|
+
@mutex = Mutex.new
|
|
179
|
+
@available = []
|
|
180
|
+
@replenish_threads = []
|
|
181
|
+
@warming = 0
|
|
182
|
+
@shutdown = false
|
|
183
|
+
@reaper_wakeup = Thread::Queue.new
|
|
184
|
+
prewarm
|
|
185
|
+
@reaper = Thread.new { reap_loop } if @config.max_idle_age
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Checks out a slot, creates an isolated UserContext/Window/Tab for one render, yields the tab,
|
|
189
|
+
# then unconditionally closes those resources and retires the underlying slot.
|
|
190
|
+
def with_tab
|
|
191
|
+
slot = checkout
|
|
192
|
+
user_context = nil
|
|
193
|
+
window = nil
|
|
194
|
+
tab = nil
|
|
195
|
+
|
|
196
|
+
begin
|
|
197
|
+
user_context = slot[:browser].create_user_context
|
|
198
|
+
window = user_context.create_browser_window
|
|
199
|
+
tab = window.create_browser_tab
|
|
200
|
+
yield tab
|
|
201
|
+
ensure
|
|
202
|
+
safe_close("tab") { tab&.close }
|
|
203
|
+
safe_close("window") { window&.close }
|
|
204
|
+
safe_close("user context") { user_context&.close }
|
|
205
|
+
retire(slot)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Retires every currently-warm spare and waits for any in-flight background replenishment to
|
|
210
|
+
# finish (each of those, seeing @shutdown, retires its own result instead of stashing it - see
|
|
211
|
+
# #stash_or_retire). A subsequent #with_tab still works - it just falls back to a synchronous
|
|
212
|
+
# slot, since checkout never depends on a warm one being there.
|
|
213
|
+
def shutdown
|
|
214
|
+
spares, threads = @mutex.synchronize do
|
|
215
|
+
@shutdown = true
|
|
216
|
+
[@available.dup.tap { @available.clear }, @replenish_threads.dup.tap { @replenish_threads.clear }]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
@reaper_wakeup << :stop
|
|
220
|
+
@reaper&.join
|
|
221
|
+
threads.each(&:join)
|
|
222
|
+
spares.each { |slot| retire(slot) }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private
|
|
226
|
+
|
|
227
|
+
# Fail-fast on purpose (a Chrome that can't start at boot should be loud), but not leaky: if slot
|
|
228
|
+
# N fails, no instance is returned to own slots 1..N-1, so they are retired here first.
|
|
229
|
+
def prewarm
|
|
230
|
+
@config.size.times { @available << stamp(create_slot) }
|
|
231
|
+
rescue StandardError
|
|
232
|
+
@available.each { |slot| retire(slot) }
|
|
233
|
+
@available.clear
|
|
234
|
+
raise
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def create_slot
|
|
238
|
+
@slot_factory.call
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Session#started? is just an internal flag set once at startup - it never flips back if Chrome,
|
|
242
|
+
# ChromeDriver, or the WebSocket dies externally while a slot sits idle in the cache. The
|
|
243
|
+
# client's own #open? is kept live by the reader thread noticing a real socket error, so
|
|
244
|
+
# checking it too catches that case - cheap (no network round trip), though still a heuristic,
|
|
245
|
+
# not a full liveness guarantee (a stuck-but-not-yet-disconnected socket still reads healthy).
|
|
246
|
+
def healthy?(slot)
|
|
247
|
+
slot[:session].started? && slot[:session].client&.open? == true
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def now
|
|
251
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Marks the moment a slot entered the cache - idle age counts from here, not from when its
|
|
255
|
+
# Chrome started, since it is the unattended waiting that max_idle_age bounds.
|
|
256
|
+
def stamp(slot)
|
|
257
|
+
slot.merge(warmed_at: now)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def expired?(slot)
|
|
261
|
+
!@config.max_idle_age.nil? && now - slot[:warmed_at] > @config.max_idle_age
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Checkout alone can't bound idle time: with no traffic nothing would ever look at a spare. This
|
|
265
|
+
# thread does, four times per max_idle_age, so an unused slot is recycled within ~1.25x of it.
|
|
266
|
+
# Thread::Queue#pop(timeout:) doubles as an interruptible sleep - #shutdown pushes :stop.
|
|
267
|
+
def reap_loop
|
|
268
|
+
interval = [@config.max_idle_age / 4.0, 0.05].max
|
|
269
|
+
recycle_expired until @reaper_wakeup.pop(timeout: interval) == :stop
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def recycle_expired
|
|
273
|
+
expired = @mutex.synchronize do
|
|
274
|
+
next [] if @shutdown
|
|
275
|
+
|
|
276
|
+
old, fresh = @available.partition { |slot| expired?(slot) }
|
|
277
|
+
@available.replace(fresh)
|
|
278
|
+
old
|
|
279
|
+
end
|
|
280
|
+
return if expired.empty?
|
|
281
|
+
|
|
282
|
+
Bidi2pdf.notification_service.instrument("session_warmer.expired.bidi2pdf", { count: expired.size })
|
|
283
|
+
replenish_async
|
|
284
|
+
expired.each { |slot| retire(slot) }
|
|
285
|
+
rescue StandardError => e
|
|
286
|
+
Bidi2pdf.logger.warn "session_warmer: recycling idle slots failed: #{e.message}"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# A warm hit takes the spare and triggers a background replacement; a miss (empty cache, or a
|
|
290
|
+
# spare that died while idle) falls straight through to a synchronous slot - it never waits, so
|
|
291
|
+
# an under-provisioned warmer is never worse than not having one. A cold start that fails raises,
|
|
292
|
+
# as it would without the warmer.
|
|
293
|
+
#
|
|
294
|
+
# Oldest spare first (shift, not pop), so no slot lingers at the bottom of the cache while newer
|
|
295
|
+
# ones are used; and one past max_idle_age is never handed out, even if the reaper hasn't got to
|
|
296
|
+
# it yet - it is retired like a dead spare and this render cold-starts instead.
|
|
297
|
+
def checkout
|
|
298
|
+
slot = @mutex.synchronize { @available.shift }
|
|
299
|
+
had_spare = !slot.nil?
|
|
300
|
+
hit = had_spare && !expired?(slot) && healthy?(slot)
|
|
301
|
+
taken = nil
|
|
302
|
+
|
|
303
|
+
Bidi2pdf.notification_service.instrument("session_warmer.checkout.bidi2pdf", { hit: hit }) do
|
|
304
|
+
taken = hit ? slot : cold_checkout(slot)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Every checkout tops the cache back up, hit or miss - #replenish_async itself bounds the work
|
|
308
|
+
# to the current deficit, so a miss on an already-full-or-filling cache starts nothing.
|
|
309
|
+
replenish_async
|
|
310
|
+
taken
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def cold_checkout(dead_slot)
|
|
314
|
+
retire(dead_slot) if dead_slot
|
|
315
|
+
create_slot
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Tops the cache up towards config.size, counting warmers already in flight. Deficit-based on
|
|
319
|
+
# purpose, not "replace what this checkout popped": that rule could never recover from a single
|
|
320
|
+
# failed warm (nothing stashed -> every later checkout a miss -> never replenished again), while
|
|
321
|
+
# replenishing unconditionally let @available grow without bound under a burst of misses.
|
|
322
|
+
# available + warming never exceeds config.size, and a failed warm frees its reservation, so the
|
|
323
|
+
# next checkout simply tries again.
|
|
324
|
+
#
|
|
325
|
+
# Threads are created and registered inside the same critical section, so #shutdown's snapshot
|
|
326
|
+
# can't miss one that has started but isn't listed yet (#warm_one needs this mutex to finish,
|
|
327
|
+
# so it just waits for it).
|
|
328
|
+
def replenish_async
|
|
329
|
+
@mutex.synchronize do
|
|
330
|
+
next if @shutdown
|
|
331
|
+
|
|
332
|
+
@replenish_threads.select!(&:alive?)
|
|
333
|
+
deficit = @config.size - (@available.size + @warming)
|
|
334
|
+
deficit.times do
|
|
335
|
+
@warming += 1
|
|
336
|
+
@replenish_threads << Thread.new { warm_one }
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def warm_one
|
|
342
|
+
slot = create_slot
|
|
343
|
+
rescue StandardError => e
|
|
344
|
+
@mutex.synchronize { @warming -= 1 }
|
|
345
|
+
Bidi2pdf.logger.warn "session_warmer: failed to warm a replacement slot: #{e.message}"
|
|
346
|
+
Bidi2pdf.notification_service.instrument("session_warmer.warm_failed.bidi2pdf", { error: e.class.name })
|
|
347
|
+
else
|
|
348
|
+
stash_or_retire(slot)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# A replacement warmed after #shutdown has nothing to stash into - retire it immediately rather
|
|
352
|
+
# than leaking a live Chrome process that nothing will ever check out. Either way this warmer's
|
|
353
|
+
# reservation is released here, in the same critical section as the stash.
|
|
354
|
+
def stash_or_retire(slot)
|
|
355
|
+
discard = @mutex.synchronize do
|
|
356
|
+
@warming -= 1
|
|
357
|
+
|
|
358
|
+
if @shutdown
|
|
359
|
+
true
|
|
360
|
+
else
|
|
361
|
+
@available << stamp(slot)
|
|
362
|
+
false
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
retire(slot) if discard
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def retire(slot)
|
|
370
|
+
self.class.retire_slot(session: slot[:session], manager: slot[:manager])
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def safe_close(label, &)
|
|
374
|
+
self.class.safe_close(label, &)
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
@@ -70,7 +70,8 @@ RSpec.configure do |config|
|
|
|
70
70
|
config.chromedriver_container = start_chromedriver_container(
|
|
71
71
|
build_dir: File.join(Bidi2pdf::TestHelpers.configuration.docker_dir, ".."),
|
|
72
72
|
mounts: config.respond_to?(:chromedriver_mounts) ? config.chromedriver_mounts : {},
|
|
73
|
-
shared_network: config.shared_network
|
|
73
|
+
shared_network: config.shared_network,
|
|
74
|
+
chromedriver_log_level: config.respond_to?(:chromedriver_log_level) ? config.chromedriver_log_level : "WARNING"
|
|
74
75
|
)
|
|
75
76
|
|
|
76
77
|
reporter.message("🚀 chromedriver container started for tests")
|
|
@@ -121,12 +122,13 @@ end
|
|
|
121
122
|
# alias the long class name
|
|
122
123
|
ChromedriverTestcontainer = Bidi2pdf::TestHelpers::Testcontainers::ChromedriverContainer
|
|
123
124
|
|
|
124
|
-
def start_chromedriver_container(build_dir:, mounts:, shared_network:)
|
|
125
|
+
def start_chromedriver_container(build_dir:, mounts:, shared_network:, chromedriver_log_level: "WARNING")
|
|
125
126
|
container = ChromedriverTestcontainer.new(ChromedriverTestcontainer::DEFAULT_IMAGE,
|
|
126
127
|
build_dir: build_dir,
|
|
127
128
|
docker_file: "docker/Dockerfile.chromedriver")
|
|
128
129
|
.with_network(shared_network)
|
|
129
130
|
.with_network_aliases("remote-chrome")
|
|
131
|
+
.with_env("CHROMEDRIVER_LOG_LEVEL", chromedriver_log_level)
|
|
130
132
|
|
|
131
133
|
container.with_filesystem_binds(mounts) if mounts&.any?
|
|
132
134
|
|
data/lib/bidi2pdf/version.rb
CHANGED
data/lib/bidi2pdf.rb
CHANGED
|
@@ -9,9 +9,11 @@ require_relative "bidi2pdf/bidi/session"
|
|
|
9
9
|
require_relative "bidi2pdf/dsl"
|
|
10
10
|
require_relative "bidi2pdf/notifications"
|
|
11
11
|
require_relative "bidi2pdf/notifications/logging_subscriber"
|
|
12
|
+
require_relative "bidi2pdf/notifications/json_subscriber"
|
|
13
|
+
require_relative "bidi2pdf/session_warmer"
|
|
12
14
|
require_relative "bidi2pdf/verbose_logger"
|
|
13
15
|
|
|
14
|
-
module Bidi2pdf
|
|
16
|
+
module Bidi2pdf # rubocop:disable Metrics/ModuleLength
|
|
15
17
|
PAPER_FORMATS_CM = {
|
|
16
18
|
letter: { width: 21.59, height: 27.94 },
|
|
17
19
|
legal: { width: 21.59, height: 35.56 },
|
|
@@ -26,11 +28,32 @@ module Bidi2pdf
|
|
|
26
28
|
a6: { width: 10.5, height: 14.8 }
|
|
27
29
|
}.freeze
|
|
28
30
|
|
|
29
|
-
class
|
|
31
|
+
# Base class for every error this gem raises. Carries the machine-readable surface used by
|
|
32
|
+
# --json/--json-stream/manifests/recipe results (see Bidi2pdf::ErrorCodes): #retryable? and
|
|
33
|
+
# #hint are overridden per subclass below where a useful default exists; #details is set at
|
|
34
|
+
# raise time for structured, serializable context (e.g. which selector, which assertion).
|
|
35
|
+
class Error < StandardError
|
|
36
|
+
attr_reader :details
|
|
30
37
|
|
|
31
|
-
|
|
38
|
+
def initialize(message = nil, details: {})
|
|
39
|
+
@details = details
|
|
40
|
+
super(message)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def retryable? = false
|
|
44
|
+
|
|
45
|
+
def hint = nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class SessionNotStartedError < Error
|
|
49
|
+
def retryable? = true
|
|
32
50
|
|
|
33
|
-
|
|
51
|
+
def hint = "Check Chrome/chromedriver are installed and reachable, or that --remote-browser-url points at a running instance"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class WebsocketError < Error
|
|
55
|
+
def retryable? = true
|
|
56
|
+
end
|
|
34
57
|
|
|
35
58
|
class ClientError < WebsocketError; end
|
|
36
59
|
|
|
@@ -47,7 +70,11 @@ module Bidi2pdf
|
|
|
47
70
|
|
|
48
71
|
class CmdResponseNotStoredError < ClientError; end
|
|
49
72
|
|
|
50
|
-
class CmdTimeoutError < ClientError
|
|
73
|
+
class CmdTimeoutError < ClientError
|
|
74
|
+
def retryable? = true
|
|
75
|
+
|
|
76
|
+
def hint = "Raise --default-timeout"
|
|
77
|
+
end
|
|
51
78
|
|
|
52
79
|
class PrintError < Error; end
|
|
53
80
|
|
|
@@ -76,18 +103,74 @@ module Bidi2pdf
|
|
|
76
103
|
@url = url
|
|
77
104
|
super("Navigation to #{url} failed due to authentication error. #{message}")
|
|
78
105
|
end
|
|
106
|
+
|
|
107
|
+
def hint = "Pass --auth user:pass, or check the credentials are still valid"
|
|
79
108
|
end
|
|
80
109
|
|
|
81
|
-
class NavigationTimeoutError < NavigationError
|
|
110
|
+
class NavigationTimeoutError < NavigationError
|
|
111
|
+
def retryable? = true
|
|
112
|
+
|
|
113
|
+
def hint = "Raise --default-timeout, or add --wait-network-idle if the page loads data after the load event"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
class NavigationNotFoundError < NavigationError
|
|
117
|
+
def hint = "Check the URL is correct"
|
|
118
|
+
end
|
|
82
119
|
|
|
83
|
-
class
|
|
120
|
+
class NavigationDNSError < NavigationError
|
|
121
|
+
attr_reader :url
|
|
122
|
+
|
|
123
|
+
def initialize(url = nil, message = nil)
|
|
124
|
+
@url = url
|
|
125
|
+
super(url ? "Navigation to #{url} failed: DNS resolution error. #{message}" : message)
|
|
126
|
+
end
|
|
84
127
|
|
|
85
|
-
|
|
128
|
+
def retryable? = true
|
|
129
|
+
|
|
130
|
+
def hint = "Check the hostname is correct and reachable from this machine/container"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# --- Errors introduced for the CLI/recipe surface.
|
|
134
|
+
# Same style as the errors above: a thin subclass per failure mode, message text carried at the
|
|
135
|
+
# raise site so it stays exactly as specific as the situation warrants.
|
|
136
|
+
|
|
137
|
+
class MissingInputError < Error; end
|
|
138
|
+
|
|
139
|
+
class MultipleInputSourcesError < Error; end
|
|
140
|
+
|
|
141
|
+
class EmptyInputError < Error; end
|
|
142
|
+
|
|
143
|
+
class InvalidConfigError < Error; end
|
|
144
|
+
|
|
145
|
+
class InvalidPrintOptionError < Error; end
|
|
146
|
+
|
|
147
|
+
class InvalidRecipeError < Error; end
|
|
148
|
+
|
|
149
|
+
class SelectorNotFoundError < Error; end
|
|
150
|
+
|
|
151
|
+
class PageNotAsExpectedError < Error; end
|
|
152
|
+
|
|
153
|
+
class OutputWriteError < Error; end
|
|
154
|
+
|
|
155
|
+
class PdfInspectionUnavailableError < Error
|
|
156
|
+
def hint = "Install the pdf-reader gem, or drop assertions/fields that need it"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Loaded here, after the error classes above, since these reference them at load time.
|
|
160
|
+
require_relative "bidi2pdf/error_codes"
|
|
161
|
+
require_relative "bidi2pdf/exit_codes"
|
|
162
|
+
require_relative "bidi2pdf/result"
|
|
163
|
+
require_relative "bidi2pdf/pdf_inspection"
|
|
164
|
+
require_relative "bidi2pdf/result_collector"
|
|
165
|
+
require_relative "bidi2pdf/manifest"
|
|
166
|
+
require_relative "bidi2pdf/schema"
|
|
167
|
+
require_relative "bidi2pdf/diagnose"
|
|
168
|
+
require_relative "bidi2pdf/recipe"
|
|
86
169
|
|
|
87
170
|
# Global configuration for Bidi2pdf
|
|
88
171
|
|
|
89
172
|
class << self
|
|
90
|
-
attr_accessor :default_timeout, :enable_default_logging_subscriber
|
|
173
|
+
attr_accessor :default_timeout, :enable_default_logging_subscriber, :log_truncate_limit, :chromedriver_log_level
|
|
91
174
|
attr_reader :logging_subscriber, :logger, :network_events_logger, :browser_console_logger, :notification_service
|
|
92
175
|
|
|
93
176
|
# Allow configuration through a block
|
|
@@ -110,6 +193,26 @@ module Bidi2pdf
|
|
|
110
193
|
end
|
|
111
194
|
end
|
|
112
195
|
|
|
196
|
+
# Truncates a value for safe log output - a raw url/param can be a `data:` URL whose base64
|
|
197
|
+
# payload is proportional to document size, and logging it whole can be large enough to choke
|
|
198
|
+
# CI log ingestion (confirmed live: GitHub Actions' log UI stalls badly on very long single
|
|
199
|
+
# lines, reading as a hung job even though the process underneath is fine).
|
|
200
|
+
#
|
|
201
|
+
# @param [Object] value The value to truncate (converted via #to_s).
|
|
202
|
+
# @param [Integer] limit The maximum number of bytes to keep. Defaults to
|
|
203
|
+
# +Bidi2pdf.log_truncate_limit+, itself configurable via +Bidi2pdf.configure+.
|
|
204
|
+
# @return [String] The value unchanged if short enough, otherwise a truncated prefix plus a
|
|
205
|
+
# byte-count marker. Truncation is byte-based (not character-based), since the goal is
|
|
206
|
+
# bounding actual log-entry size; a partial trailing multi-byte character is scrubbed rather
|
|
207
|
+
# than left as invalid UTF-8.
|
|
208
|
+
def truncate_for_log(value, limit: log_truncate_limit)
|
|
209
|
+
str = value.to_s
|
|
210
|
+
return str if str.bytesize <= limit
|
|
211
|
+
|
|
212
|
+
truncated = str.byteslice(0, limit).scrub("")
|
|
213
|
+
"#{truncated}... (#{str.bytesize} bytes total)"
|
|
214
|
+
end
|
|
215
|
+
|
|
113
216
|
def translate_paper_format(format)
|
|
114
217
|
format = format.to_s.downcase.to_sym
|
|
115
218
|
|
|
@@ -161,6 +264,8 @@ module Bidi2pdf
|
|
|
161
264
|
|
|
162
265
|
config.default_timeout = 60
|
|
163
266
|
|
|
267
|
+
config.log_truncate_limit = 200
|
|
268
|
+
|
|
164
269
|
config.notification_service = Notifications
|
|
165
270
|
end
|
|
166
271
|
end
|
|
@@ -235,6 +235,23 @@ module Bidi2pdf
|
|
|
235
235
|
|
|
236
236
|
def navigate_with_listeners: (untyped url, ?wait: ::String) -> untyped
|
|
237
237
|
|
|
238
|
+
def raise_navigation_error_for: (untyped url, untyped error) -> untyped
|
|
239
|
+
|
|
240
|
+
# browsingContext.navigate's own response never carries an HTTP status - only a
|
|
241
|
+
# network.responseCompleted event does, correlated back to this specific navigation via its
|
|
242
|
+
# "navigation" field (a Chrome/redirect-chain-wide ID, not the network request's own id).
|
|
243
|
+
# register_event_listeners already ran above, so network_events has been tracking since
|
|
244
|
+
# before the navigate command was even sent. Deliberately conservative: with no navigation
|
|
245
|
+
# ID, or no correlated *completed* request found, this stays silent rather than guessing -
|
|
246
|
+
# only a positively confirmed >= 400 status raises. max_by(&:start_timestamp) picks the last
|
|
247
|
+
# hop of a redirect chain (every hop shares the same navigation ID), matching the page the
|
|
248
|
+
# browser actually ended up on.
|
|
249
|
+
def check_navigation_http_status: (untyped url, untyped navigation_id) -> (nil | untyped)
|
|
250
|
+
|
|
251
|
+
# The last hop of a redirect chain (every hop shares the same navigation ID) - the page the
|
|
252
|
+
# browser actually ended up on, not wherever the chain started.
|
|
253
|
+
def correlated_navigation_response: (untyped navigation_id) -> untyped
|
|
254
|
+
|
|
238
255
|
def register_event_listeners: () -> (nil | untyped)
|
|
239
256
|
|
|
240
257
|
def handle_injection_exception: (untyped response, untyped url, untyped exception_class) -> untyped
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
module Bidi2pdf
|
|
2
|
+
module Bidi
|
|
3
|
+
# A threaded WebSocket client on the `websocket` gem's framing - the same shape Selenium's Ruby
|
|
4
|
+
# BiDi client uses. It replaces websocket-client-simple, whose reader pulled one byte at a time
|
|
5
|
+
# (`getc`, then a frame-parse attempt per byte): a printed PDF comes back as one base64 message
|
|
6
|
+
# and every network event for a `data:` navigation echoes the whole URL, so that loop ran
|
|
7
|
+
# millions of times per render - ~730 ms to decode a 700 KB frame, against ~0.5 ms in 16 KB
|
|
8
|
+
# chunks.
|
|
9
|
+
#
|
|
10
|
+
# Emits :open, :message (a WebSocket frame, payload in #data), :error and :close.
|
|
11
|
+
class BufferedWebSocketClient
|
|
12
|
+
@listeners: untyped
|
|
13
|
+
|
|
14
|
+
@listeners_mutex: untyped
|
|
15
|
+
|
|
16
|
+
@write_mutex: untyped
|
|
17
|
+
|
|
18
|
+
# Re-entrant: a failed write inside #close closes again.
|
|
19
|
+
@close_monitor: untyped
|
|
20
|
+
|
|
21
|
+
@handshaked: untyped
|
|
22
|
+
|
|
23
|
+
@closed: untyped
|
|
24
|
+
|
|
25
|
+
@url: untyped
|
|
26
|
+
|
|
27
|
+
@socket: untyped
|
|
28
|
+
|
|
29
|
+
@handshake: untyped
|
|
30
|
+
|
|
31
|
+
@thread: untyped
|
|
32
|
+
|
|
33
|
+
READ_CHUNK_BYTES: 16384
|
|
34
|
+
|
|
35
|
+
attr_reader url: untyped
|
|
36
|
+
|
|
37
|
+
def self.connect: (untyped url, ?::Hash[untyped, untyped] options) ?{ (untyped) -> untyped } -> untyped
|
|
38
|
+
|
|
39
|
+
def initialize: () -> void
|
|
40
|
+
|
|
41
|
+
def on: (untyped event) { (?) -> untyped } -> untyped
|
|
42
|
+
|
|
43
|
+
def connect: (untyped url, ?::Hash[untyped, untyped] options) -> (nil | untyped)
|
|
44
|
+
|
|
45
|
+
# Commands are sent from whichever thread issues them. On a plain TCP socket one IO#write is
|
|
46
|
+
# already atomic, but OpenSSL::SSL::SSLSocket#write is not, so writes share a lock for wss://.
|
|
47
|
+
def send: (untyped data, ?type: ::Symbol) -> untyped
|
|
48
|
+
|
|
49
|
+
# Safe from any thread, including the reader's own: the reader sees the peer hang up and
|
|
50
|
+
# closes from inside its loop, so it must neither race a caller closing at the same moment
|
|
51
|
+
# nor be killed before :close has been emitted.
|
|
52
|
+
def close: (?untyped? error) -> untyped
|
|
53
|
+
|
|
54
|
+
def open?: () -> untyped
|
|
55
|
+
|
|
56
|
+
def closed?: () -> untyped
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def emit: (untyped event, *untyped) -> untyped
|
|
61
|
+
|
|
62
|
+
def write: (untyped bytes) -> untyped
|
|
63
|
+
|
|
64
|
+
def say_goodbye: () -> untyped
|
|
65
|
+
|
|
66
|
+
def open_socket: (untyped uri, untyped options) -> untyped
|
|
67
|
+
|
|
68
|
+
def ssl_context: (untyped options) -> untyped
|
|
69
|
+
|
|
70
|
+
def read_loop: (untyped socket, untyped frame) -> untyped
|
|
71
|
+
|
|
72
|
+
def consume: (untyped chunk, untyped frame) -> (nil | untyped)
|
|
73
|
+
|
|
74
|
+
# WebSocket control frames (ping/pong/close) must never reach a JSON-parsing consumer -
|
|
75
|
+
# WebSocketDispatcher tries to parse every :message payload as JSON, so a raw close frame
|
|
76
|
+
# would raise JSON::ParserError, and an unanswered ping can make chromedriver or an
|
|
77
|
+
# intermediate proxy tear down an otherwise healthy connection on its own keepalive timeout.
|
|
78
|
+
def dispatch_frame: (untyped message) -> untyped
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|