bidi2pdf 0.1.13 → 0.1.15

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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/.devcontainer/Dockerfile +53 -0
  3. data/.devcontainer/devcontainer.json +51 -0
  4. data/.devcontainer/docker-compose.yml +33 -0
  5. data/.ruby-gemset +1 -1
  6. data/.ruby-version +1 -1
  7. data/CHANGELOG.md +93 -6
  8. data/README.md +80 -5
  9. data/docker/Dockerfile +1 -1
  10. data/docker/Dockerfile.slim +2 -2
  11. data/docker/chrome-driver/chrome.json +874 -0
  12. data/lib/bidi2pdf/bidi/browser_tab.rb +97 -4
  13. data/lib/bidi2pdf/bidi/buffered_web_socket_client.rb +178 -0
  14. data/lib/bidi2pdf/bidi/client.rb +12 -5
  15. data/lib/bidi2pdf/bidi/commands/base.rb +2 -0
  16. data/lib/bidi2pdf/bidi/commands/capture_screenshot.rb +33 -0
  17. data/lib/bidi2pdf/bidi/commands/set_viewport.rb +32 -0
  18. data/lib/bidi2pdf/bidi/commands.rb +2 -0
  19. data/lib/bidi2pdf/bidi/network_event.rb +12 -3
  20. data/lib/bidi2pdf/bidi/network_events.rb +9 -1
  21. data/lib/bidi2pdf/bidi/session.rb +1 -1
  22. data/lib/bidi2pdf/chromedriver_manager.rb +10 -6
  23. data/lib/bidi2pdf/cli.rb +11 -1
  24. data/lib/bidi2pdf/launcher.rb +5 -3
  25. data/lib/bidi2pdf/notifications/logging_subscriber.rb +2 -0
  26. data/lib/bidi2pdf/session_warmer.rb +377 -0
  27. data/lib/bidi2pdf/test_helpers/images.rb +8 -0
  28. data/lib/bidi2pdf/test_helpers/pdf_reader_utils.rb +1 -2
  29. data/lib/bidi2pdf/test_helpers/pdf_text_sanitizer.rb +1 -2
  30. data/lib/bidi2pdf/test_helpers/testcontainers/chromedriver_container.rb +1 -1
  31. data/lib/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rb +22 -2
  32. data/lib/bidi2pdf/test_helpers/testcontainers/container_endpoint.rb +30 -0
  33. data/lib/bidi2pdf/test_helpers/testcontainers/testcontainers_refinement.rb +33 -0
  34. data/lib/bidi2pdf/test_helpers/testcontainers.rb +1 -0
  35. data/lib/bidi2pdf/version.rb +1 -1
  36. data/lib/bidi2pdf.rb +26 -1
  37. data/sig/bidi2pdf/bidi/add_headers_interceptor.rbs +16 -9
  38. data/sig/bidi2pdf/bidi/auth_interceptor.rbs +32 -7
  39. data/sig/bidi2pdf/bidi/browser.rbs +4 -31
  40. data/sig/bidi2pdf/bidi/browser_console_logger.rbs +59 -0
  41. data/sig/bidi2pdf/bidi/browser_tab.rbs +269 -25
  42. data/sig/bidi2pdf/bidi/buffered_web_socket_client.rbs +81 -0
  43. data/sig/bidi2pdf/bidi/client.rbs +103 -43
  44. data/sig/bidi2pdf/bidi/command_manager.rbs +5 -17
  45. data/sig/bidi2pdf/bidi/commands/add_intercept.rbs +16 -8
  46. data/sig/bidi2pdf/bidi/commands/base.rbs +49 -9
  47. data/sig/bidi2pdf/bidi/commands/browser_close.rbs +3 -1
  48. data/sig/bidi2pdf/bidi/commands/browser_create_user_context.rbs +2 -0
  49. data/sig/bidi2pdf/bidi/commands/browser_remove_user_context.rbs +19 -0
  50. data/sig/bidi2pdf/bidi/commands/browsing_context_close.rbs +8 -2
  51. data/sig/bidi2pdf/bidi/commands/browsing_context_navigate.rbs +12 -6
  52. data/sig/bidi2pdf/bidi/commands/browsing_context_print.rbs +10 -5
  53. data/sig/bidi2pdf/bidi/commands/cancel_auth.rbs +8 -2
  54. data/sig/bidi2pdf/bidi/commands/capture_screenshot.rbs +31 -0
  55. data/sig/bidi2pdf/bidi/commands/cdp_get_session.rbs +17 -0
  56. data/sig/bidi2pdf/bidi/commands/create_window.rbs +4 -0
  57. data/sig/bidi2pdf/bidi/commands/get_user_contexts.rbs +2 -0
  58. data/sig/bidi2pdf/bidi/commands/network_continue.rbs +4 -0
  59. data/sig/bidi2pdf/bidi/commands/page_print.rbs +28 -0
  60. data/sig/bidi2pdf/bidi/commands/print_parameters_validator.rbs +3 -0
  61. data/sig/bidi2pdf/bidi/commands/provide_credentials.rbs +12 -6
  62. data/sig/bidi2pdf/bidi/commands/script_evaluate.rbs +4 -0
  63. data/sig/bidi2pdf/bidi/commands/session_end.rbs +2 -0
  64. data/sig/bidi2pdf/bidi/commands/session_status.rbs +3 -1
  65. data/sig/bidi2pdf/bidi/commands/session_subscribe.rbs +4 -0
  66. data/sig/bidi2pdf/bidi/commands/set_tab_cookie.rbs +51 -23
  67. data/sig/bidi2pdf/bidi/commands/set_usercontext_cookie.rbs +17 -19
  68. data/sig/bidi2pdf/bidi/commands/set_viewport.rbs +31 -0
  69. data/sig/bidi2pdf/bidi/connection_manager.rbs +2 -1
  70. data/sig/bidi2pdf/bidi/event_manager.rbs +14 -19
  71. data/sig/bidi2pdf/bidi/interceptor.rbs +5 -0
  72. data/sig/bidi2pdf/bidi/js_logger_helper.rbs +9 -0
  73. data/sig/bidi2pdf/bidi/logger_events.rbs +21 -0
  74. data/sig/bidi2pdf/bidi/navigation_failed_events.rbs +17 -0
  75. data/sig/bidi2pdf/bidi/network_event.rbs +64 -46
  76. data/sig/bidi2pdf/bidi/network_event_formatters/network_event_console_formatter.rbs +63 -0
  77. data/sig/bidi2pdf/bidi/network_event_formatters/network_event_formatter_utils.rbs +15 -0
  78. data/sig/bidi2pdf/bidi/network_event_formatters/network_event_html_formatter.rbs +18 -0
  79. data/sig/bidi2pdf/bidi/network_event_formatters.rbs +6 -0
  80. data/sig/bidi2pdf/bidi/network_events.rbs +14 -39
  81. data/sig/bidi2pdf/bidi/session.rbs +144 -31
  82. data/sig/bidi2pdf/bidi/user_context.rbs +64 -45
  83. data/sig/bidi2pdf/bidi/web_socket_dispatcher.rbs +22 -30
  84. data/sig/bidi2pdf/chromedriver_manager.rbs +61 -22
  85. data/sig/bidi2pdf/cli.rbs +22 -2
  86. data/sig/bidi2pdf/dsl.rbs +34 -0
  87. data/sig/bidi2pdf/launcher.rbs +39 -1
  88. data/sig/bidi2pdf/notifications/event.rbs +40 -0
  89. data/sig/bidi2pdf/notifications/instrumenter.rbs +21 -0
  90. data/sig/bidi2pdf/notifications/logging_subscriber.rbs +41 -0
  91. data/sig/bidi2pdf/notifications.rbs +24 -0
  92. data/sig/bidi2pdf/process_tree.rbs +2 -2
  93. data/sig/bidi2pdf/session_runner.rbs +41 -1
  94. data/sig/bidi2pdf/session_warmer.rbs +221 -0
  95. data/sig/bidi2pdf/test_helpers/configuration.rbs +51 -0
  96. data/sig/bidi2pdf/test_helpers/images/extractor.rbs +43 -0
  97. data/sig/bidi2pdf/test_helpers/images/image_similarity_checker.rbs +35 -0
  98. data/sig/bidi2pdf/test_helpers/images/tiff_helper.rbs +70 -0
  99. data/sig/bidi2pdf/test_helpers/images.rbs +0 -0
  100. data/sig/bidi2pdf/test_helpers/matchers/contains_pdf_image.rbs +0 -0
  101. data/sig/bidi2pdf/test_helpers/matchers/contains_pdf_text.rbs +0 -0
  102. data/sig/bidi2pdf/test_helpers/matchers/have_pdf_page_count.rbs +0 -0
  103. data/sig/bidi2pdf/test_helpers/matchers/match_pdf_text.rbs +0 -0
  104. data/sig/bidi2pdf/test_helpers/pdf_file_helper.rbs +23 -0
  105. data/sig/bidi2pdf/test_helpers/pdf_reader_utils.rbs +49 -0
  106. data/sig/bidi2pdf/test_helpers/pdf_text_sanitizer.rbs +38 -0
  107. data/sig/bidi2pdf/test_helpers/spec_paths_helper.rbs +35 -0
  108. data/sig/bidi2pdf/test_helpers/testcontainers/chromedriver_container.rbs +43 -0
  109. data/sig/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rbs +45 -0
  110. data/sig/bidi2pdf/test_helpers/testcontainers/container_endpoint.rbs +25 -0
  111. data/sig/bidi2pdf/test_helpers/testcontainers/shared_docker_network.rbs +3 -0
  112. data/sig/bidi2pdf/test_helpers/testcontainers/testcontainers_refinement.rbs +47 -0
  113. data/sig/bidi2pdf/test_helpers/testcontainers.rbs +6 -0
  114. data/sig/bidi2pdf/test_helpers.rbs +0 -0
  115. data/sig/bidi2pdf/verbose_logger.rbs +37 -0
  116. data/sig/bidi2pdf/version.rbs +3 -0
  117. data/sig/bidi2pdf.rbs +130 -2
  118. data/tasks/generate_rbs.rake +11 -11
  119. data/tasks/release_credentials_check.rake +97 -0
  120. metadata +67 -10
  121. data/sig/bidi2pdf/utils.rbs +0 -5
@@ -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
@@ -6,6 +6,14 @@ rescue LoadError
6
6
  warn "Missing #{dep}. Add it to your Gemfile if you're using Bidi2pdf image test helpers."
7
7
  end
8
8
 
9
+ # CVE-2026-66066 (Rails Active Storage, 2026-07-29): libvips ships loaders/savers that were never
10
+ # fuzz-tested (SVG, JPEG XL, JPEG 2000, BMP, ICO, PSD, anything delegated to ImageMagick, ...) -
11
+ # left reachable, a crafted file can trigger arbitrary file read or RCE. Applied once, here, for
12
+ # every consumer of this opt-in module (Extractor, ImageSimilarityChecker, contains_pdf_image),
13
+ # rather than left to each call site to remember - PNG/JPEG (what this gem's own screenshot/PDF
14
+ # rendering actually produces) load through already-fuzzed loaders and are unaffected.
15
+ Vips.block_untrusted(true) if defined?(Vips)
16
+
9
17
  require_relative "images/tiff_helper"
10
18
  require_relative "images/extractor"
11
19
  require_relative "images/image_similarity_checker"
@@ -49,7 +49,7 @@ module Bidi2pdf
49
49
  # @param pdf_data [String, StringIO, File] The PDF data to be converted.
50
50
  # @return [IO] An IO object containing the PDF data.
51
51
  def convert_data_to_io(pdf_data)
52
- # rubocop:disable Lint/DuplicateBranch
52
+ # rubocop:disable-next Lint/DuplicateBranch
53
53
  if pdf_data.is_a?(String) && (pdf_data.start_with?("JVBERi") || pdf_data.start_with?("JVBER"))
54
54
  StringIO.new(Base64.decode64(pdf_data))
55
55
  elsif pdf_data.start_with?("%PDF-")
@@ -61,7 +61,6 @@ module Bidi2pdf
61
61
  else
62
62
  StringIO.new(pdf_data)
63
63
  end
64
- # rubocop:enable Lint/DuplicateBranch
65
64
  end
66
65
  end
67
66
 
@@ -6,7 +6,7 @@ require "diff/lcs/hunk"
6
6
 
7
7
  module Bidi2pdf
8
8
  module TestHelpers
9
- # rubocop: disable Metrics/ModuleLength
9
+ # rubocop: disable-next Metrics/ModuleLength
10
10
  module PDFTextSanitizer
11
11
  class << self
12
12
  def clean(text)
@@ -148,6 +148,5 @@ module Bidi2pdf
148
148
  end
149
149
  end
150
150
  end
151
- # rubocop:enable Metrics/ModuleLength
152
151
  end
153
152
  end
@@ -73,7 +73,7 @@ module Bidi2pdf
73
73
  # rubocop: enable Metrics/AbcSize
74
74
 
75
75
  def session_url(protocol: "http")
76
- "#{protocol}://#{host}:#{mapped_port(port)}/session"
76
+ ContainerEndpoint.new(self, port).url(scheme: protocol, path: "session")
77
77
  end
78
78
  end
79
79
  end
@@ -36,6 +36,24 @@ module Bidi2pdf
36
36
  def create_session(session_url)
37
37
  Bidi2pdf::Bidi::Session.new(session_url: session_url, headless: true, chrome_args: chrome_args)
38
38
  end
39
+
40
+ # Full session/tab lifecycle in one call - create_session above only builds the session
41
+ # object, it never starts or closes it, and there is no after(:each) teardown for a spec
42
+ # that uses it directly. Mirrors Bidi2pdf::DSL.with_tab's own ensure-based teardown, but
43
+ # against a session_url this module already knows about (a long-lived remote-chrome
44
+ # reached over `session:` metadata, not a freshly spawned local chromedriver per call).
45
+ def with_tab(session_url)
46
+ session = create_session(session_url)
47
+ session.start
48
+ user_context = session.browser.create_user_context
49
+ tab = user_context.create_browser_window
50
+
51
+ yield tab
52
+ ensure
53
+ tab&.close
54
+ user_context&.close
55
+ session&.close
56
+ end
39
57
  end
40
58
  end
41
59
  end
@@ -52,7 +70,8 @@ RSpec.configure do |config|
52
70
  config.chromedriver_container = start_chromedriver_container(
53
71
  build_dir: File.join(Bidi2pdf::TestHelpers.configuration.docker_dir, ".."),
54
72
  mounts: config.respond_to?(:chromedriver_mounts) ? config.chromedriver_mounts : {},
55
- 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"
56
75
  )
57
76
 
58
77
  reporter.message("🚀 chromedriver container started for tests")
@@ -103,12 +122,13 @@ end
103
122
  # alias the long class name
104
123
  ChromedriverTestcontainer = Bidi2pdf::TestHelpers::Testcontainers::ChromedriverContainer
105
124
 
106
- def start_chromedriver_container(build_dir:, mounts:, shared_network:)
125
+ def start_chromedriver_container(build_dir:, mounts:, shared_network:, chromedriver_log_level: "WARNING")
107
126
  container = ChromedriverTestcontainer.new(ChromedriverTestcontainer::DEFAULT_IMAGE,
108
127
  build_dir: build_dir,
109
128
  docker_file: "docker/Dockerfile.chromedriver")
110
129
  .with_network(shared_network)
111
130
  .with_network_aliases("remote-chrome")
131
+ .with_env("CHROMEDRIVER_LOG_LEVEL", chromedriver_log_level)
112
132
 
113
133
  container.with_filesystem_binds(mounts) if mounts&.any?
114
134
 
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bidi2pdf
4
+ module TestHelpers
5
+ module Testcontainers
6
+ # Builds a URL for reaching a container's published port from the test
7
+ # process, using the container's own host/port resolution.
8
+ class ContainerEndpoint
9
+ def initialize(container, port)
10
+ @container = container
11
+ @port = port
12
+ end
13
+
14
+ def url(scheme: "http", path: "")
15
+ "#{scheme}://#{@container.accessible_host}:#{@container.mapped_port(@port)}/#{path}"
16
+ end
17
+
18
+ # Address reachable from another container on the same Docker network (e.g. remote-chrome
19
+ # reaching nginx), by alias and internal port - as opposed to #url's host-accessible,
20
+ # externally-mapped port. Uses the same @port this instance was built with, not a
21
+ # container_ports.first guess: the two methods must agree on which port they're both
22
+ # describing for the same container, or a caller comparing #url and #alias_url for what's
23
+ # meant to be the same endpoint could silently get two different services.
24
+ def alias_url(scheme: "http", path: "")
25
+ "#{scheme}://#{@container.aliases.first}:#{@port}/#{path}"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -29,6 +29,39 @@ module Bidi2pdf
29
29
  self
30
30
  end
31
31
 
32
+ def container_json
33
+ @_container&.json
34
+ end
35
+
36
+ # Address of the Docker host at which this container's *mapped* ports
37
+ # are reachable from the test process.
38
+ #
39
+ # Resolution belongs to testcontainers: it honours TC_HOST, a tcp:/ssh:
40
+ # DOCKER_HOST, a native local daemon, and a sibling container reaching
41
+ # the daemon over the bridge gateway — and it derives #mapped_port from
42
+ # the same decision, so the two must stay on one path. We only add a
43
+ # fallback for the cases 0.2.0 leaves unresolved.
44
+ #
45
+ # This is not the address containers use to reach each other; for that
46
+ # the helpers join a shared network and address containers by alias.
47
+ def accessible_host
48
+ resolved_host || "localhost"
49
+ end
50
+
51
+ # testcontainers-core 0.2.0 cannot resolve a host when the test process
52
+ # runs inside a container and the container under test is on a custom
53
+ # network: #container_gateway_ip hardcodes the "bridge" network key
54
+ # (docker_container.rb:1074), so #host returns nil. On the default
55
+ # bridge it can instead reach docker_container.rb:703, which calls an
56
+ # undefined `bridge_ip`. Both are fixed on testcontainers-ruby main;
57
+ # treat either as "unresolved" and fall back.
58
+ def resolved_host
59
+ value = host
60
+ value unless value.nil? || value.empty?
61
+ rescue NoMethodError
62
+ nil
63
+ end
64
+
32
65
  def _container_create_options
33
66
  opts = super
34
67
  network_name = network&.info&.[]("Name")
@@ -10,6 +10,7 @@ module Bidi2pdf
10
10
  module TestHelpers
11
11
  module Testcontainers
12
12
  require_relative "testcontainers/testcontainers_refinement"
13
+ require_relative "testcontainers/container_endpoint"
13
14
  require_relative "testcontainers/chromedriver_container"
14
15
  require_relative "testcontainers/chromedriver_test_helper"
15
16
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bidi2pdf
4
- VERSION = "0.1.13"
4
+ VERSION = "0.1.15"
5
5
  end
data/lib/bidi2pdf.rb CHANGED
@@ -9,6 +9,7 @@ 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/session_warmer"
12
13
  require_relative "bidi2pdf/verbose_logger"
13
14
 
14
15
  module Bidi2pdf
@@ -51,6 +52,8 @@ module Bidi2pdf
51
52
 
52
53
  class PrintError < Error; end
53
54
 
55
+ class ScreenshotError < Error; end
56
+
54
57
  class ScriptInjectionError < Error; end
55
58
 
56
59
  class StyleInjectionError < Error; end
@@ -85,7 +88,7 @@ module Bidi2pdf
85
88
  # Global configuration for Bidi2pdf
86
89
 
87
90
  class << self
88
- attr_accessor :default_timeout, :enable_default_logging_subscriber
91
+ attr_accessor :default_timeout, :enable_default_logging_subscriber, :log_truncate_limit, :chromedriver_log_level
89
92
  attr_reader :logging_subscriber, :logger, :network_events_logger, :browser_console_logger, :notification_service
90
93
 
91
94
  # Allow configuration through a block
@@ -108,6 +111,26 @@ module Bidi2pdf
108
111
  end
109
112
  end
110
113
 
114
+ # Truncates a value for safe log output - a raw url/param can be a `data:` URL whose base64
115
+ # payload is proportional to document size, and logging it whole can be large enough to choke
116
+ # CI log ingestion (confirmed live: GitHub Actions' log UI stalls badly on very long single
117
+ # lines, reading as a hung job even though the process underneath is fine).
118
+ #
119
+ # @param [Object] value The value to truncate (converted via #to_s).
120
+ # @param [Integer] limit The maximum number of bytes to keep. Defaults to
121
+ # +Bidi2pdf.log_truncate_limit+, itself configurable via +Bidi2pdf.configure+.
122
+ # @return [String] The value unchanged if short enough, otherwise a truncated prefix plus a
123
+ # byte-count marker. Truncation is byte-based (not character-based), since the goal is
124
+ # bounding actual log-entry size; a partial trailing multi-byte character is scrubbed rather
125
+ # than left as invalid UTF-8.
126
+ def truncate_for_log(value, limit: log_truncate_limit)
127
+ str = value.to_s
128
+ return str if str.bytesize <= limit
129
+
130
+ truncated = str.byteslice(0, limit).scrub("")
131
+ "#{truncated}... (#{str.bytesize} bytes total)"
132
+ end
133
+
111
134
  def translate_paper_format(format)
112
135
  format = format.to_s.downcase.to_sym
113
136
 
@@ -159,6 +182,8 @@ module Bidi2pdf
159
182
 
160
183
  config.default_timeout = 60
161
184
 
185
+ config.log_truncate_limit = 200
186
+
162
187
  config.notification_service = Notifications
163
188
  end
164
189
  end
@@ -1,20 +1,27 @@
1
1
  module Bidi2pdf
2
2
  module Bidi
3
3
  class AddHeadersInterceptor
4
- @id: String
5
- @client: untyped
6
- @headers: Hash[String, String]
4
+ @headers: untyped
7
5
 
8
- attr_reader id: String
9
- attr_reader headers: Hash[String, String]
6
+ @url_patterns: untyped
10
7
 
11
- def initialize: (String id, Hash[String, String] headers, untyped client) -> void
8
+ @context: untyped
12
9
 
13
- def handle_event: (Hash[String, untyped] response) -> (nil | untyped)
10
+ include Interceptor
14
11
 
15
- private
12
+ def self.phases: () -> ::Array[untyped]
16
13
 
17
- attr_reader client: untyped
14
+ def self.events: () -> ::Array["network.beforeRequestSent"]
15
+
16
+ attr_reader headers: untyped
17
+
18
+ attr_reader url_patterns: untyped
19
+
20
+ attr_reader context: untyped
21
+
22
+ def initialize: (headers: untyped, url_patterns: untyped, context: untyped) -> void
23
+
24
+ def process_interception: (untyped _event_response, untyped _navigation_id, untyped network_id, untyped _url) -> untyped
18
25
  end
19
26
  end
20
27
  end