Class: Capybara::Simulated::QuickJSRuntime
- Inherits:
-
Object
- Object
- Capybara::Simulated::QuickJSRuntime
- Defined in:
- lib/capybara/simulated/quickjs_runtime.rb
Defined Under Namespace
Classes: VmPool
Constant Summary collapse
- INTL_FEATURES =
bridge.js patches
Intl.DateTimeFormat; rusty_racer ships ICU built-in but QuickJS gates it behind a polyfill flag (other surfaces bridge.js touches — URL / TextEncoder / atob/btoa / crypto — already route through Ruby host fns, so POLYFILL_INTL is the only one we strictly need).Since 0.19 the Intl polyfills live in the separate
quickjs-polyfill-intlgem, registered by name instead of quickjs's old bundledPOLYFILL_INTLflag.:polyfill_intl_datetimeformat_allis the whole DateTimeFormat chain (getcanonicallocales → locale → pluralrules → numberformat → datetimeformat) as ONE registration — the only chain bridge.js needs, and cheaper than requiring the five links separately.PERF (rule 3): the polyfill source is eval'd per VM, and the VM-pool pre-warm used to be GVL-serial, which put every feature on the critical path. quickjs now releases the GVL while loading polyfills, so the pre-warm overlaps.
[:polyfill_intl_datetimeformat_all].freeze
- VM_OPTIONS =
max_stack_size: 0—JS_SetMaxStackSizemeasures C stack delta from runtime construction; Ruby callers reach QuickJS through deep stacks (Capybarasynchronize+ RSpec matchers + bridge.js's class init closures), so the default 4 MB trips on routinecheck_stale → __csimAlivecalls.0disables the check; OS thread stack is the real ceiling.timeout_msec: (2**31)-1— quickjs.rb default eval timeout is 100 ms; bridge.js's__csimEvaluateXPath/__csimDispatchEventchains routinely exceed that on Avo-scale documents under QuickJS's interpreter. 0 means "interrupt immediately" (the handler returnselapsed >= limit_ms, so 0 fires on the first check), so practical no-limit. { features: INTL_FEATURES, max_stack_size: 0, # quickjs.rb's 128 MB default trips "out of memory in regexp # execution" on class-attribute-heavy polls and the heaviest # Mastodon hydrate (cumulative heap, not a single allocation). # 512 MB clears the ceiling without idle cost — `JS_SetMemoryLimit` # is a malloc ceiling, not a reservation. memory_limit: 512 * 1024 * 1024, # `drain_jobs!` loops `JS_ExecutePendingJob` until the # queue empties — but Forem's article-feed render schedules # new microtasks faster than they drain, so without a timer # the call never returns. Real per-spec eval rarely runs over # a second, and a 30 s ceiling is far below "hung CI worker" # while leaving headroom for the heaviest Mastodon hydrate. timeout_msec: 30_000 }.freeze
- @@bridge_lock =
Compile the vendor bundle + bridge.js into bytecode once per process. Every per-visit VM replays this in ~10–20 ms (PR 31's microbench: 504KB bundle in ~4 ms; vendor + bridge is ~10× larger). Side effects (class definitions, the xpathway
Document.prototype.evaluateinstall) run on each new VM —compileitself is pure (COMPILE_ONLYflag). Mutex.new
- @@bridge_runnable =
nil- @@runnable_cache_lock =
Process-wide cache of compiled
<script>bodies (classic + ESM factory wrappers). Bridge.js routes each body through__csim_runScript; first encounter compiles into bytecode, every subsequent visit replays the cachedRunnableagainst the current VM. The compile (PR 31 microbench: 504 KB → ~4 ms; Avo's bundle is ~10×) is the cost we're skipping.No size cap: typical app surface is a few hundred unique bodies (jQuery, Stimulus, Turbo, app bundle, per-page inlines). If a test suite generates pathological cardinality we can add LRU later — for now the parser-overhead saving dwarfs the cache RSS.
Mutex.new
- @@runnable_cache =
{}
- @@compiler_lock =
Sharing one compiler VM serialises compile calls, but compilation is CPU-bound C and parallel workers each have their own
QuickJSRuntimeclass state (Ruby's@@is per-class, shared in-process). One compile-only VM is enough; creating a freshQuickjs::VM.newper compile (~140 ms each for POLYFILL_INTL) would dwarf the compile itself. Mutex.new
- @@compiler_vm =
nil- @@pool_lock =
Mutex.new
- @@pool =
nil
Class Method Summary collapse
-
.attach_host_fns(v, browser) ⇒ Object
Class-level attach so Worker isolates (per-thread VMs that don't have a Runtime instance) reuse the same host-fn table.
- .bridge_runnable ⇒ Object
-
.build_worker(browser, post_back, broadcast_out = nil, sw_hooks = {}) ⇒ Object
Worker-isolate factory: fresh VM, bridge bytecode replayed, host fns attached after the replay (so snapshot_stubs.js's no-ops don't overwrite real ones),
__csim_isWorkerset, + the per-worker postMessage routed throughpost_back. - .pool ⇒ Object
- .runnable_for(body, label) ⇒ Object
Instance Method Summary collapse
-
#call(name, *args) ⇒ Object
the V8 engine drains its microtask queue at the end of every call (V8's default microtask policy).
-
#drain_microtasks ⇒ Object
drain_jobs!loops to queue-empty — one call is a full checkpoint, same contract asV8Runtime#drain_microtasks. -
#drain_timers(max_ms = nil) ⇒ Object
bridge.js owns the virtual clock; we drive it from Ruby because Capybara's polling cadence is wall-clock-anchored.
- #eval(code) ⇒ Object
-
#eval_esm_module(url, src = nil) ⇒ Object
Evaluates
urlas an ES module. -
#frame_realm_alive?(_realm_id) ⇒ Boolean
No per-frame realms, so no realm is ever "alive" as a distinct browsing context — every client id resolves to 'client-window' (the single global).
- #frame_realm_ids ⇒ Object
- #has_ready_timer? ⇒ Boolean
-
#initialize(browser) ⇒ QuickJSRuntime
constructor
A new instance of QuickJSRuntime.
-
#next_timer_delay_ms ⇒ Object
Delay (ms) until the nearest scheduled timer relative to the virtual clock, or -1 if none.
- #realm_call(_realm_id, name, *args) ⇒ Object
-
#rebuild_ctx ⇒ Object
Tear down the current VM and build a fresh one from the precompiled bytecode.
-
#reset_page ⇒ Object
Same operation as
rebuild_ctxsince per-visit rebuilds are already the inter-test reset point. - #reset_timers ⇒ Object
-
#run_loop_step(max_ms, max_iter = 10_000, yield_on_gen: false) ⇒ Object
One event-loop step; returns
{ 'fired', 'gen', 'dirtied' }(see V8Runtime#run_loop_step). - #settle_gen ⇒ Object
-
#supports_frames? ⇒ Boolean
QuickJS has no per-frame realms (iframes share the one VM):
within_framefalls back to the same realm, and any realm id routes to the single global context. -
#wrap_binary(bytes) ⇒ Object
No binary marshaler: QuickJS reinterprets high-bit bytes as UTF-8 and corrupts them, so binary payloads cross as base64 and the JS shim's
fetchedToBytesatob's them back (see Browser#transfer_buffer_fetch_for_js).
Constructor Details
#initialize(browser) ⇒ QuickJSRuntime
Returns a new instance of QuickJSRuntime.
139 140 141 142 143 144 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 139 def initialize(browser) @browser = browser @vm = nil @runnable = self.class.bridge_runnable self.class.pool # eager-start the warmers on first Browser end |
Class Method Details
.attach_host_fns(v, browser) ⇒ Object
Class-level attach so Worker isolates (per-thread VMs that don't have a Runtime instance) reuse the same host-fn table.
365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 365 def self.attach_host_fns(v, browser) RuntimeShared::BROWSER_HOST_FNS.each {|name, body| v.define_function(name) {|*a| RuntimeShared.safe_call { body.call(browser, *a) } } } RuntimeShared::STDLIB_HOST_FNS.each {|name, body| v.define_function(name, &body) } # `dispatchEventForUserAction` calls this between listener # invocations. `drain_jobs!` loops `JS_ExecutePendingJob` until # the queue is empty, matching V8's # `MicrotasksScope::PerformCheckpoint`. Older quickjs.rb # without `drain_jobs!` falls back to a no-op. if v.respond_to?(:drain_jobs!) v.define_function('__csim_yield') { v.drain_jobs!; nil } else v.define_function('__csim_yield') { nil } end end |
.bridge_runnable ⇒ Object
48 49 50 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 48 def self.bridge_runnable @@bridge_lock.synchronize { @@bridge_runnable ||= Quickjs::VM.new.compile(RuntimeShared.snapshot_src, filename: 'csim_bridge.js') } end |
.build_worker(browser, post_back, broadcast_out = nil, sw_hooks = {}) ⇒ Object
Worker-isolate factory: fresh VM, bridge bytecode replayed,
host fns attached after the replay (so snapshot_stubs.js's
no-ops don't overwrite real ones), __csim_isWorker set, +
the per-worker postMessage routed through post_back.
388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 388 def self.build_worker(browser, post_back, broadcast_out = nil, sw_hooks = {}) vm = Quickjs::VM.new(**VM_OPTIONS) bridge_runnable.run(on: vm) attach_host_fns(vm, browser) vm.define_function('__csim_workerPostMessage') {|data| post_back.call(data); nil } # A worker's BroadcastChannel fan-out routes through the thread-safe outbox, not # `browser.broadcast_to_windows` (cross-thread inbox mutation). See v8_runtime#build_worker. vm.define_function('__csimBroadcast') {|name, data, _rid, origin| broadcast_out&.call(name, data, origin); nil } if broadcast_out # Service-worker → main-thread signals via the outbox (see v8_runtime#build_worker). vm.define_function('__csim_swPostToClient') {|client_id, data| sw_hooks[:post_to_client]&.call(client_id, data); nil } if sw_hooks[:post_to_client] vm.define_function('__csim_swClaim') { sw_hooks[:claim]&.call; nil } if sw_hooks[:claim] vm.define_function('__csim_swFetchRespond') {|fetch_id, resp, realm_id| sw_hooks[:fetch_respond]&.call(fetch_id, resp, realm_id); nil } if sw_hooks[:fetch_respond] vm.define_function('__csim_swFetchStream') {|fetch_id, kind, payload, realm_id| sw_hooks[:fetch_stream]&.call(fetch_id, kind, payload, realm_id); nil } if sw_hooks[:fetch_stream] vm.define_function('__csim_workerPortPost') {|channel, data| sw_hooks[:port_post]&.call(channel, data); nil } if sw_hooks[:port_post] vm.define_function('__csim_workerPortEndpoint') {|channel| sw_hooks[:port_endpoint]&.call(channel); nil } if sw_hooks[:port_endpoint] # Override main's __setTimersActive so worker's empty-timer-map # flip doesn't race main's `polling?` gate. See v8_runtime's # build_worker for the long-form rationale. vm.define_function('__setTimersActive') {|_flag| nil } # importScripts runs a classic script at top-level scope so its top-level # const/let/class share the realm's global lexical env (see v8_runtime). vm.define_function('__csim_workerImportEval') {|src| vm.eval_code(src.to_s); nil } vm.eval_code('__csim_installWorkerScope();') vm.drain_jobs! WorkerRuntime.new( eval_fn: ->(s) { v = vm.eval_code(s.to_s); vm.drain_jobs!; v }, call_fn: ->(n, *a) { v = vm.call(n.to_s, *a); vm.drain_jobs!; v }, drain_microtasks: -> { vm.drain_jobs! }, drain_timers: -> { vm.call('__drainTimers', 50) }, has_ready_timer: -> { !!vm.call('__hasReadyTimer') }, # quickjs.rb has no explicit dispose; GC reclaims the VM. dispose: -> { nil } ) end |
.pool ⇒ Object
129 130 131 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 129 def self.pool @@pool_lock.synchronize { @@pool ||= VmPool.new(VM_OPTIONS) } end |
.runnable_for(body, label) ⇒ Object
75 76 77 78 79 80 81 82 83 84 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 75 def self.runnable_for(body, label) key = Digest::SHA256.hexdigest(body) cached = @@runnable_cache_lock.synchronize { @@runnable_cache[key] } return cached if cached fresh = @@compiler_lock.synchronize { @@compiler_vm ||= Quickjs::VM.new @@compiler_vm.compile(body, filename: label.to_s) } @@runnable_cache_lock.synchronize { @@runnable_cache[key] ||= fresh } end |
Instance Method Details
#call(name, *args) ⇒ Object
the V8 engine drains its microtask queue at
the end of every call (V8's default microtask policy). QuickJS
does not: js_std_await only pumps pending jobs while it's
waiting for an actual Promise to resolve, and host-fn returns
are plain values. Without a manual pump after every call,
Promise.then chains queued during a host-fn body (Turbo's
await fetch / Stimulus controllers, evaluate_async_script
test scripts) stall until the next async boundary.
drain_jobs! (quickjs.rb 0.18+) wraps JS_ExecutePendingJob
in a loop to empty the queue, bounded by the VM's timeout_msec.
163 164 165 166 167 168 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 163 def call(name, *args) v = vm result = v.call(name.to_s, *args) v.drain_jobs! normalize(result) end |
#drain_microtasks ⇒ Object
drain_jobs! loops to queue-empty — one call is a full checkpoint,
same contract as V8Runtime#drain_microtasks.
198 199 200 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 198 def drain_microtasks vm.drain_jobs! end |
#drain_timers(max_ms = nil) ⇒ Object
bridge.js owns the virtual clock; we drive it from Ruby because Capybara's polling cadence is wall-clock-anchored.
185 186 187 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 185 def drain_timers(max_ms = nil) max_ms.nil? ? vm.call('__drainTimers') : vm.call('__drainTimers', max_ms.to_i) end |
#eval(code) ⇒ Object
146 147 148 149 150 151 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 146 def eval(code) v = vm result = v.eval_code(code.to_s) v.drain_jobs! normalize(result) end |
#eval_esm_module(url, src = nil) ⇒ Object
Evaluates url as an ES module. For external <script type="module" src="…">, pass src=nil so QuickJS goes through
module_loader to fetch — the URL becomes the module's
identity. For inline <script type="module">{…}</script>,
pass the body as src and let QuickJS compile it inline (the
synthesised #inline-… URL becomes the module's identity, but
transitive imports still go through module_loader).
quickjs.rb's vm.import distinguishes these by which keyword
arg you pass: filename: alone → loader fetch; from: alone →
inline compile. Passing both makes the gem ignore the body.
317 318 319 320 321 322 323 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 317 def eval_esm_module(url, src = nil) v = vm opts = src ? { from: src.to_s } : { filename: url.to_s } opts[:code_to_expose] = '' v.import("* as __csim_entry_#{rand(1 << 32)}", **opts) v.drain_jobs! end |
#frame_realm_alive?(_realm_id) ⇒ Boolean
No per-frame realms, so no realm is ever "alive" as a distinct browsing context — every client id resolves to 'client-window' (the single global). Defined so a caller that gates realm routing on it (deliver_worker_messages) works on both engines.
179 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 179 def frame_realm_alive?(_realm_id) = false |
#frame_realm_ids ⇒ Object
181 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 181 def frame_realm_ids = [] |
#has_ready_timer? ⇒ Boolean
213 214 215 216 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 213 def has_ready_timer? return false if @vm.nil? !!vm.call('__hasReadyTimer') end |
#next_timer_delay_ms ⇒ Object
Delay (ms) until the nearest scheduled timer relative to the virtual
clock, or -1 if none. Drives the horizon-gated fast-forward in
Browser#tick_real_time.
221 222 223 224 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 221 def next_timer_delay_ms return -1 if @vm.nil? vm.call('__nextTimerDelay').to_i end |
#realm_call(_realm_id, name, *args) ⇒ Object
174 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 174 def realm_call(_realm_id, name, *args) = call(name, *args) |
#rebuild_ctx ⇒ Object
Tear down the current VM and build a fresh one from the precompiled bytecode. Partial in-VM resets carry the same library-init-leak hazards V8Runtime documents.
We don't @vm&.dispose! before swapping: per-visit rebuilds
happen on every spec example, and dispose! blocks on the
quickjs GC running with the GVL held. Ruby GC will eventually
reach the unreferenced VM and the gem's dfree handler frees
the JSRuntime. The transient C-heap growth between GCs is the
tradeoff for not paying ~hundreds of ms per spec.
241 242 243 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 241 def rebuild_ctx @vm = build_vm end |
#reset_page ⇒ Object
Same operation as rebuild_ctx since per-visit rebuilds are
already the inter-test reset point.
247 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 247 def reset_page = rebuild_ctx |
#reset_timers ⇒ Object
226 227 228 229 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 226 def reset_timers return if @vm.nil? vm.call('__resetTimers') end |
#run_loop_step(max_ms, max_iter = 10_000, yield_on_gen: false) ⇒ Object
One event-loop step; returns { 'fired', 'gen', 'dirtied' } (see
V8Runtime#run_loop_step). dirtied = settleGen changed during the step.
191 192 193 194 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 191 def run_loop_step(max_ms, max_iter = 10_000, yield_on_gen: false) r = vm.call('__runLoopStep', max_ms.to_i, max_iter.to_i, !!yield_on_gen) r.is_a?(Hash) ? r : { 'fired' => 0, 'gen' => 0, 'dirtied' => false } end |
#settle_gen ⇒ Object
209 210 211 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 209 def settle_gen vm.call('__settleGenGet').to_i end |
#supports_frames? ⇒ Boolean
QuickJS has no per-frame realms (iframes share the one VM): within_frame falls back to
the same realm, and any realm id routes to the single global context.
172 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 172 def supports_frames? = false |
#wrap_binary(bytes) ⇒ Object
No binary marshaler: QuickJS reinterprets high-bit bytes as UTF-8 and
corrupts them, so binary payloads cross as base64 and the JS shim's
fetchedToBytes atob's them back (see Browser#transfer_buffer_fetch_for_js).
205 206 207 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 205 def wrap_binary(bytes) Base64.strict_encode64(bytes) end |