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).PERF (rule 3): quickjs is pinned to
~> 0.18.0in 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: 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) ⇒ 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. - #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.
-
#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
-
#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.
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_runnable ⇒ Object
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 |
.pool ⇒ Object
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_microtasks ⇒ Object
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
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_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.
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_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.
211 212 213 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 211 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.
217 |
# File 'lib/capybara/simulated/quickjs_runtime.rb', line 217 def reset_page = rebuild_ctx |
#reset_timers ⇒ Object
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_gen ⇒ Object
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 |