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).

PERF (rule 3): quickjs is pinned to ~> 0.18.0 in the Gemfile. quickjs 0.19 both split the Intl polyfills into a separate quickjs-polyfill-intl gem (which eval's them per VM at ~226 ms — vs this single bundled flag's ~140 ms) AND regressed interpreter execution ~2.8× (measured: the QuickJS spec suite ran 5.6 min on 0.18 vs 15.5 min on 0.19 with an equivalent Intl set). The 0.19 migration is recorded in the cross-window / quickjs-CI memory; re-migrate to 0.19 + quickjs-polyfill-intl once that upstream perf regression is fixed.

[Quickjs::POLYFILL_INTL].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.



122
123
124
125
126
127
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 122

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.



332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 332

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



31
32
33
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 31

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) ⇒ 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.



355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 355

def self.build_worker(browser, post_back)
  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 }
  # 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



112
113
114
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 112

def self.pool
  @@pool_lock.synchronize { @@pool ||= VmPool.new(VM_OPTIONS) }
end

.runnable_for(body, label) ⇒ Object



58
59
60
61
62
63
64
65
66
67
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 58

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.



146
147
148
149
150
151
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 146

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.



168
169
170
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 168

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.



155
156
157
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 155

def drain_timers(max_ms = nil)
  max_ms.nil? ? vm.call('__drainTimers') : vm.call('__drainTimers', max_ms.to_i)
end

#eval(code) ⇒ Object



129
130
131
132
133
134
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 129

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.



284
285
286
287
288
289
290
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 284

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

#has_ready_timer?Boolean

Returns:

  • (Boolean)


183
184
185
186
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 183

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.



191
192
193
194
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 191

def next_timer_delay_ms
  return -1 if @vm.nil?
  vm.call('__nextTimerDelay').to_i
end

#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.



211
212
213
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 211

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.



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

def reset_page = rebuild_ctx

#reset_timersObject



196
197
198
199
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 196

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.



161
162
163
164
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 161

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



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

def settle_gen
  vm.call('__settleGenGet').to_i
end

#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).



175
176
177
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 175

def wrap_binary(bytes)
  Base64.strict_encode64(bytes)
end