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

VM_OPTIONS =

bridge.js patches ‘Intl.DateTimeFormat`; rusty_racer ships ICU built-in but QuickJS gates it behind a polyfill flag. Other JS surfaces bridge.js touches (URL / TextEncoder / atob/btoa / crypto) are already routed through Ruby-side host fns, so POLYFILL_INTL is the only one we strictly need.

‘max_stack_size: 0` — `JS_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:       [Quickjs::POLYFILL_INTL].freeze,
  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.



315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 315

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



338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 338

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



267
268
269
270
271
272
273
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 267

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