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
- 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
-
.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_isWorker` set, + the per-worker postMessage routed through `post_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 as `V8Runtime#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 ‘url` as 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_ctx` since 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 ‘fetchedToBytes` atob’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.
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_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`.
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 |
.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.
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
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 |