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.
- checksums.yaml +4 -4
- data/.devcontainer/Dockerfile +53 -0
- data/.devcontainer/devcontainer.json +51 -0
- data/.devcontainer/docker-compose.yml +33 -0
- data/.ruby-gemset +1 -1
- data/.ruby-version +1 -1
- data/CHANGELOG.md +93 -6
- data/README.md +80 -5
- data/docker/Dockerfile +1 -1
- data/docker/Dockerfile.slim +2 -2
- data/docker/chrome-driver/chrome.json +874 -0
- data/lib/bidi2pdf/bidi/browser_tab.rb +97 -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/commands/capture_screenshot.rb +33 -0
- data/lib/bidi2pdf/bidi/commands/set_viewport.rb +32 -0
- data/lib/bidi2pdf/bidi/commands.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.rb +11 -1
- data/lib/bidi2pdf/launcher.rb +5 -3
- data/lib/bidi2pdf/notifications/logging_subscriber.rb +2 -0
- data/lib/bidi2pdf/session_warmer.rb +377 -0
- data/lib/bidi2pdf/test_helpers/images.rb +8 -0
- data/lib/bidi2pdf/test_helpers/pdf_reader_utils.rb +1 -2
- data/lib/bidi2pdf/test_helpers/pdf_text_sanitizer.rb +1 -2
- data/lib/bidi2pdf/test_helpers/testcontainers/chromedriver_container.rb +1 -1
- data/lib/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rb +22 -2
- data/lib/bidi2pdf/test_helpers/testcontainers/container_endpoint.rb +30 -0
- data/lib/bidi2pdf/test_helpers/testcontainers/testcontainers_refinement.rb +33 -0
- data/lib/bidi2pdf/test_helpers/testcontainers.rb +1 -0
- data/lib/bidi2pdf/version.rb +1 -1
- data/lib/bidi2pdf.rb +26 -1
- data/sig/bidi2pdf/bidi/add_headers_interceptor.rbs +16 -9
- data/sig/bidi2pdf/bidi/auth_interceptor.rbs +32 -7
- data/sig/bidi2pdf/bidi/browser.rbs +4 -31
- data/sig/bidi2pdf/bidi/browser_console_logger.rbs +59 -0
- data/sig/bidi2pdf/bidi/browser_tab.rbs +269 -25
- data/sig/bidi2pdf/bidi/buffered_web_socket_client.rbs +81 -0
- data/sig/bidi2pdf/bidi/client.rbs +103 -43
- data/sig/bidi2pdf/bidi/command_manager.rbs +5 -17
- data/sig/bidi2pdf/bidi/commands/add_intercept.rbs +16 -8
- data/sig/bidi2pdf/bidi/commands/base.rbs +49 -9
- data/sig/bidi2pdf/bidi/commands/browser_close.rbs +3 -1
- data/sig/bidi2pdf/bidi/commands/browser_create_user_context.rbs +2 -0
- data/sig/bidi2pdf/bidi/commands/browser_remove_user_context.rbs +19 -0
- data/sig/bidi2pdf/bidi/commands/browsing_context_close.rbs +8 -2
- data/sig/bidi2pdf/bidi/commands/browsing_context_navigate.rbs +12 -6
- data/sig/bidi2pdf/bidi/commands/browsing_context_print.rbs +10 -5
- data/sig/bidi2pdf/bidi/commands/cancel_auth.rbs +8 -2
- data/sig/bidi2pdf/bidi/commands/capture_screenshot.rbs +31 -0
- data/sig/bidi2pdf/bidi/commands/cdp_get_session.rbs +17 -0
- data/sig/bidi2pdf/bidi/commands/create_window.rbs +4 -0
- data/sig/bidi2pdf/bidi/commands/get_user_contexts.rbs +2 -0
- data/sig/bidi2pdf/bidi/commands/network_continue.rbs +4 -0
- data/sig/bidi2pdf/bidi/commands/page_print.rbs +28 -0
- data/sig/bidi2pdf/bidi/commands/print_parameters_validator.rbs +3 -0
- data/sig/bidi2pdf/bidi/commands/provide_credentials.rbs +12 -6
- data/sig/bidi2pdf/bidi/commands/script_evaluate.rbs +4 -0
- data/sig/bidi2pdf/bidi/commands/session_end.rbs +2 -0
- data/sig/bidi2pdf/bidi/commands/session_status.rbs +3 -1
- data/sig/bidi2pdf/bidi/commands/session_subscribe.rbs +4 -0
- data/sig/bidi2pdf/bidi/commands/set_tab_cookie.rbs +51 -23
- data/sig/bidi2pdf/bidi/commands/set_usercontext_cookie.rbs +17 -19
- data/sig/bidi2pdf/bidi/commands/set_viewport.rbs +31 -0
- data/sig/bidi2pdf/bidi/connection_manager.rbs +2 -1
- data/sig/bidi2pdf/bidi/event_manager.rbs +14 -19
- data/sig/bidi2pdf/bidi/interceptor.rbs +5 -0
- data/sig/bidi2pdf/bidi/js_logger_helper.rbs +9 -0
- data/sig/bidi2pdf/bidi/logger_events.rbs +21 -0
- data/sig/bidi2pdf/bidi/navigation_failed_events.rbs +17 -0
- data/sig/bidi2pdf/bidi/network_event.rbs +64 -46
- data/sig/bidi2pdf/bidi/network_event_formatters/network_event_console_formatter.rbs +63 -0
- data/sig/bidi2pdf/bidi/network_event_formatters/network_event_formatter_utils.rbs +15 -0
- data/sig/bidi2pdf/bidi/network_event_formatters/network_event_html_formatter.rbs +18 -0
- data/sig/bidi2pdf/bidi/network_event_formatters.rbs +6 -0
- data/sig/bidi2pdf/bidi/network_events.rbs +14 -39
- data/sig/bidi2pdf/bidi/session.rbs +144 -31
- data/sig/bidi2pdf/bidi/user_context.rbs +64 -45
- data/sig/bidi2pdf/bidi/web_socket_dispatcher.rbs +22 -30
- data/sig/bidi2pdf/chromedriver_manager.rbs +61 -22
- data/sig/bidi2pdf/cli.rbs +22 -2
- data/sig/bidi2pdf/dsl.rbs +34 -0
- data/sig/bidi2pdf/launcher.rbs +39 -1
- data/sig/bidi2pdf/notifications/event.rbs +40 -0
- data/sig/bidi2pdf/notifications/instrumenter.rbs +21 -0
- data/sig/bidi2pdf/notifications/logging_subscriber.rbs +41 -0
- data/sig/bidi2pdf/notifications.rbs +24 -0
- data/sig/bidi2pdf/process_tree.rbs +2 -2
- data/sig/bidi2pdf/session_runner.rbs +41 -1
- data/sig/bidi2pdf/session_warmer.rbs +221 -0
- data/sig/bidi2pdf/test_helpers/configuration.rbs +51 -0
- data/sig/bidi2pdf/test_helpers/images/extractor.rbs +43 -0
- data/sig/bidi2pdf/test_helpers/images/image_similarity_checker.rbs +35 -0
- data/sig/bidi2pdf/test_helpers/images/tiff_helper.rbs +70 -0
- data/sig/bidi2pdf/test_helpers/images.rbs +0 -0
- data/sig/bidi2pdf/test_helpers/matchers/contains_pdf_image.rbs +0 -0
- data/sig/bidi2pdf/test_helpers/matchers/contains_pdf_text.rbs +0 -0
- data/sig/bidi2pdf/test_helpers/matchers/have_pdf_page_count.rbs +0 -0
- data/sig/bidi2pdf/test_helpers/matchers/match_pdf_text.rbs +0 -0
- data/sig/bidi2pdf/test_helpers/pdf_file_helper.rbs +23 -0
- data/sig/bidi2pdf/test_helpers/pdf_reader_utils.rbs +49 -0
- data/sig/bidi2pdf/test_helpers/pdf_text_sanitizer.rbs +38 -0
- data/sig/bidi2pdf/test_helpers/spec_paths_helper.rbs +35 -0
- data/sig/bidi2pdf/test_helpers/testcontainers/chromedriver_container.rbs +43 -0
- data/sig/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rbs +45 -0
- data/sig/bidi2pdf/test_helpers/testcontainers/container_endpoint.rbs +25 -0
- data/sig/bidi2pdf/test_helpers/testcontainers/shared_docker_network.rbs +3 -0
- data/sig/bidi2pdf/test_helpers/testcontainers/testcontainers_refinement.rbs +47 -0
- data/sig/bidi2pdf/test_helpers/testcontainers.rbs +6 -0
- data/sig/bidi2pdf/test_helpers.rbs +0 -0
- data/sig/bidi2pdf/verbose_logger.rbs +37 -0
- data/sig/bidi2pdf/version.rbs +3 -0
- data/sig/bidi2pdf.rbs +130 -2
- data/tasks/generate_rbs.rake +11 -11
- data/tasks/release_credentials_check.rake +97 -0
- metadata +67 -10
- 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
|
|
@@ -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
|
data/lib/bidi2pdf/version.rb
CHANGED
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
|
-
@
|
|
5
|
-
@client: untyped
|
|
6
|
-
@headers: Hash[String, String]
|
|
4
|
+
@headers: untyped
|
|
7
5
|
|
|
8
|
-
|
|
9
|
-
attr_reader headers: Hash[String, String]
|
|
6
|
+
@url_patterns: untyped
|
|
10
7
|
|
|
11
|
-
|
|
8
|
+
@context: untyped
|
|
12
9
|
|
|
13
|
-
|
|
10
|
+
include Interceptor
|
|
14
11
|
|
|
15
|
-
|
|
12
|
+
def self.phases: () -> ::Array[untyped]
|
|
16
13
|
|
|
17
|
-
|
|
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
|