Class: Capybara::Simulated::QuickJSRuntime

Inherits:
Object
  • Object
show all
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-intl gem, registered by name instead of quickjs's old bundled POLYFILL_INTL flag. :polyfill_intl_datetimeformat_all is 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: 0JS_SetMaxStackSize measures C stack delta from runtime construction; Ruby callers reach QuickJS through deep stacks (Capybara synchronize + RSpec matchers + bridge.js's class init closures), so the default 4 MB trips on routine check_stale → __csimAlive calls. 0 disables 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 / __csimDispatchEvent chains routinely exceed that on Avo-scale documents under QuickJS's interpreter. 0 means "interrupt immediately" (the handler returns elapsed >= 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.evaluate install) run on each new VM — compile itself is pure (COMPILE_ONLY flag).

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 cached Runnable against 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 QuickJSRuntime class state (Ruby's @@ is per-class, shared in-process). One compile-only VM is enough; creating a fresh Quickjs::VM.new per 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

Instance Method Summary collapse

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_runnableObject



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

.poolObject



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_microtasksObject

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.

Returns:

  • (Boolean)


179
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 179

def frame_realm_alive?(_realm_id) = false

#frame_realm_idsObject



181
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 181

def frame_realm_ids = []

#has_ready_timer?Boolean

Returns:

  • (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_msObject

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_ctxObject

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_pageObject

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_timersObject



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_genObject



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.

Returns:

  • (Boolean)


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