Class: Capybara::Simulated::V8Runtime

Inherits:
Object
  • Object
show all
Defined in:
lib/capybara/simulated/v8_runtime.rb

Defined Under Namespace

Classes: Ctx

Constant Summary collapse

HOST_NAMESPACE_NAME =

The host namespace rusty_racer installs into every context (main and per-frame): globalThis.RustyRacer.drainMicrotasks() (a native, rendezvous-free microtask checkpoint), contextGlobal(id) / contextOf(value) (the per-frame realm machinery), and setPromiseRejectHandler. The bridge JS hard-codes the same globalThis.RustyRacer literal (timers.js / platform-globals.js / unhandled-rejection.js / bridge.entry.js) — a rename must touch both sides.

'RustyRacer'
SNAPSHOT_WARMUP =

Pre-warm script: exercises the JS surfaces that get JIT-compiled on every page load (HTML parse, selector tokenise + match, event dispatch, style-decl parse, cascade resolve). Runs once at snapshot creation; the resulting compiled-code state ships in the snapshot so each new context starts with these paths warm. (Snapshot#warmup! follows the V8 WarmUpSnapshotDataBlob contract: the warmup runs in a throwaway context — only code, no heap state, survives into the blob.)

'logfile-per-isolate': nil)
  end
  # `CSIM_V8_FLAGS` passes arbitrary V8 flags through to
  # `set_flags_from_string` for perf experiments (JIT tier-up tuning,
  # GC, lite-mode). Whitespace-separated; each token is `--`-prefixed by
  # rusty's `set_flags!`, so write them WITHOUT the leading dashes:
  #   CSIM_V8_FLAGS='jitless'                 -> --jitless
  #   CSIM_V8_FLAGS='sparkplug no-turbofan'   -> --sparkplug --no-turbofan
  #   CSIM_V8_FLAGS='max-opt=1'               -> --max-opt=1
  # Flags may interact with the cached snapshot's compiled-code state, so
  # pair a sweep with `CSIM_SNAPSHOT_CACHE=off`.
  if (raw = ENV['CSIM_V8_FLAGS'].to_s.strip) && !raw.empty?
    flags = raw.split(/\s+/).map {|f| f.sub(/\A--/, '') }
    RustyRacer::Platform.set_flags!(*flags)
  end
rescue RustyRacer::PlatformAlreadyInitialized
end

module Capybara
  module Simulated
    class V8Runtime

@@snapshot_lock = Mutex.new
@@snapshot      = nil
@@live_lock     = Mutex.new
@@live          = []

at_exit do
  @@live_lock.synchronize {
    @@live.each {|c|
      begin
        c.terminate rescue nil
        c.dispose
      rescue StandardError
      end
    }
    @@live.clear
  }
end

# The host namespace rusty_racer installs into every context (main and
# per-frame): `globalThis.RustyRacer.drainMicrotasks()` (a native,
# rendezvous-free microtask checkpoint), `contextGlobal(id)` /
# `contextOf(value)` (the per-frame realm machinery), and
# `setPromiseRejectHandler`. The bridge JS hard-codes the same
# `globalThis.RustyRacer` literal (timers.js / platform-globals.js /
# unhandled-rejection.js / bridge.entry.js) — a rename must touch both
# sides.
HOST_NAMESPACE_NAME = 'RustyRacer'

# One isolate + its default context, presented as a single handle — the
# shape the rest of the runtime (and `ScriptCache`) passes around.
# rusty splits the VM into an `Isolate` (lifecycle / realms / microtasks /
# terminate) and the `Context`s it hands out (eval / call / attach /
# compile / reset); this class pairs them and replays recorded host-fn
# attaches onto per-frame realm contexts (rusty's attach is per-context).
class Ctx
  def initialize(snapshot: nil, timeout: 0)
    @iso        = RustyRacer::Isolate.new(host_namespace: HOST_NAMESPACE_NAME,
                                          snapshot:       snapshot,
                                          timeout_ms:     timeout.to_i)
    @ctx        = @iso.context
    @attached   = []
    @generation = 0
  end

  # ── Context surface ─────────────────────────────────────────
  # rusty drains microtasks at call-depth zero (V8's default kAuto
  # policy), so a returned eval/call has already run its end-of-script
  # microtasks.
  def eval(src)          = @ctx.eval(src)
  def eval_void(src)     = @ctx.eval_void(src)
  def call(name, *args)  = @ctx.call(name, *args)

  # Record every attach so `create_context` can replay them: the bridge
  # in a per-frame realm reaches the same Ruby host fns as the main
  # context, but rusty's attach is per-context.
  def attach(name, prc)
    @attached << [name, prc]
    @ctx.attach(name, prc)
  end

  # One rendezvous for the whole host-fn table (vs one per fn).
  def attach_many(fns)
    @attached.concat(fns.to_a)
    @ctx.attach_many(fns)
  end

  # Bumped on every realm reset: realm-bound caches (module handles)
  # key off `[object_id, generation]` so invalidation is intrinsic to
  # reset — the Ctx OBJECT survives a warm reset, so object_id alone
  # can't detect one.
  attr_reader :generation

  # Swap the realm for a snapshot-fresh one on the warm isolate. Per
  # rusty's contract the host fns die with the old context — drop the
  # replay record so the caller's re-attach doesn't accumulate stale
  # entries visit over visit.
  def reset
    @ctx.reset
    @attached.clear
    @generation += 1
  end

  def compile(src, **kw)        = @ctx.compile(src, **kw)
  def compile_module(src, **kw) = @ctx.compile_module(src, **kw)

  # ── Isolate surface ─────────────────────────────────────────
  def terminate                        = @iso.terminate
  def dispose                          = @iso.dispose
  def perform_microtask_checkpoint     = @iso.perform_microtask_checkpoint
  # V8 heap accounting + a forced full GC. Used by the per-visit
  # heap-pressure relief in `rebuild_ctx`.
  def heap_statistics                  = @iso.heap_statistics
  def low_memory_notification          = @iso.low_memory_notification

  def dynamic_import_resolver=(prc)
    @iso.dynamic_import_resolver = prc
  end

  # A per-iframe realm: a fresh context in the SAME isolate (shared heap,
  # own global + intrinsics). Carries `.id` / eval / call / dispose — the
  # rest of the surface `create_frame_realm` needs.
  #
  # `to_h` dedups re-attached names to their latest proc, matching
  # attach's override semantics. NOTE: context-bound fns
  # (`__csim_runScript*`, `__csim_evalEsmEntry`) get realm-bound
  # overrides in `create_frame_realm` after this replay.
  def create_context
    realm = @iso.create_context
    realm.attach_many(@attached.to_h)
    realm
  end
end

def self.snapshot
  @@snapshot_lock.synchronize { @@snapshot ||= build_snapshot }
end

# Pre-warm script: exercises the JS surfaces that get JIT-compiled
# on every page load (HTML parse, selector tokenise + match, event
# dispatch, style-decl parse, cascade resolve). Runs once at
# snapshot creation; the resulting compiled-code state ships in
# the snapshot so each new context starts with these paths warm.
# (`Snapshot#warmup!` follows the V8 WarmUpSnapshotDataBlob contract:
# the warmup runs in a throwaway context — only code, no heap state,
# survives into the blob.)
SNAPSHOT_WARMUP = <<~JS.freeze
  (function () {
    // Drive a representative document through parse → script
    // eval → selector / event / cascade primitives so the
    // bytecode cache covers them when a real visit hits.
    const html = '' +
      '' +
      '
' + '' + ''; try { __csimLoadDocument(html); } catch (_) {} try { __csimEvaluateXPath('//a', 0); } catch (_) {} try { __csimVisible(1); } catch (_) {} try { __csimQuery(0, '#m'); } catch (_) {} try { __csimQuery(0, '.b > .c'); } catch (_) {} try { const root = document.documentElement; if (root) { root.querySelectorAll('a'); root.querySelectorAll('.b > .c, #m'); } } catch (_) {} })(); JS
GC_PRESSURE_MB =

Memory-pressure threshold (MB) above which rebuild_ctx forces a full GC to reclaim dead per-frame realms (see the call site). Measured against used heap + external (ArrayBuffer backing stores, image pixel buffers) — used_heap_size alone misses the external component, which on image-heavy specs is the bulk of the footprint. Default 1 GB: far above a normal single visit (~200-500 MB) so ordinary specs never trigger it, far below the 4 GB old-space cap so a multi-visit spec reclaims long before the near-heap-limit GC would thrash. 0 disables.

(ENV['CSIM_V8_GC_PRESSURE_MB'] || '1024').to_i
HEAP_DIAG =

Opt-in heap accounting on every rebuild (CSIM_HEAP_DIAG). Resolved once at load (rule 3: don't re-read env per call); zero cost when off.

!ENV['CSIM_HEAP_DIAG'].nil?
CALL_TIMEOUT_MS =

Per-call wall-clock cap (ms). Off by default. Opt in via CSIM_V8_CALL_TIMEOUT_MS=30000 for long-running suites where an occasional JS-side infinite loop would otherwise stall the whole run; the timeout converts the hang into a RustyRacer::ScriptTerminatedError on that one example — whose #message / #js_backtrace name the looping JS frame (function + source position), so an in-V8 hang is diagnosable from the failure alone, no live debugger attach needed. The terminate escalates through any nested frames (it is isolate-global by design), and the isolate itself stays healthy for subsequent calls — csim treats a terminated call as fatal to that call only. The clean slate comes from the next rebuild: a warm Context#reset normally, or — if the terminate wedged a suspended request and reset is refused — the loud cold-rebuild fallback in rebuild_ctx.

(ENV['CSIM_V8_CALL_TIMEOUT_MS'] || '0').to_i
MAX_FRAME_DEPTH =

Build the iframe's realm: a snapshot-built isolate replays the whole bridge into every new context automatically, so the realm already has document / DOMParser / the event loop; re-seed the post-snapshot JS state, point it at its own URL with the top frame as parent/top, then load its document (running its scripts in the realm). Tracked for event-loop draining + teardown. Browsers cap nested browsing-context depth; with eager frame building a self-referential or pathologically nested iframe (<iframe src=self>) would otherwise build realms without bound and stall the settle loop. Depth is one more than the parent realm's (the main frame is depth 0).

16
SCRIPT_CACHE_MIN_BYTES =

Override the JS-side __csim_runScript fallback with a Ruby host fn that bytecode-caches each script body in a process-wide hash + on-disk store (Context#compile + Script#cached_data). Discourse's main chunk is ~140 ms of parse + JIT per visit otherwise; the cache reduces it to a deserialize + run path. Worker isolates run on their own threads — compile from the main thread against a Worker isolate is unsafe — so the class-level attach_host_fns (used by build_worker) intentionally skips this attach. V8's bytecode cache only pays off above a body-size threshold — the rendezvous round-trip + Ruby-side SHA256 + compile + dispose runs ~150–300 µs, while (0, eval)(body) at V8 globalThis for a tiny script is sub-microsecond. Above the threshold, V8 parse + JIT cold-path is multiple ms — worth the cache. Redmine's jQuery + Stimulus inline scripts (median ~400 B) dominated the regression: pre-threshold, routing every snippet through Ruby blew the 122-test suite from 56 s → 224 s. Threshold sweep:

threshold | Redmine wall
1 KB    | 143 s
8 KB    | 103 s
32 KB    |  90 s
64 KB    |  62 s  ← baseline parity

64 KB keeps Discourse's main Ember chunk (140 KB+) on the cache path while Stimulus / Trix / etc. shorts stay on the JS-only fast path. CSIM_SCRIPT_CACHE_MIN_BYTES=0 forces the cache for everything (debug / cross-process bench).

(ENV['CSIM_SCRIPT_CACHE_MIN_BYTES'] || '65536').to_i
@@snapshot_lock =
Mutex.new
@@snapshot =
nil
@@live_lock =
Mutex.new
@@live =
[]

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(browser) ⇒ V8Runtime

Returns a new instance of V8Runtime.



325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
# File 'lib/capybara/simulated/v8_runtime.rb', line 325

def initialize(browser)
  @browser = browser
  @ctx     = nil
  # Every context is built from the base snapshot (bridge +
  # vendor bundle). Library scripts (`<script src>`) get evaluated
  # per-visit just like a real browser does on page navigation.
  # Pre-evaluating libraries into the snapshot heap is not safe:
  # jQuery's `readyList` Callbacks queue would carry `$(handler)`
  # registrations from a prior page's scripts, and a single
  # throwing handler (e.g. touching a DOM node that only existed
  # on the prior page) aborts iteration mid-fire and silently
  # drops every later callback — including the current page's.
  @snapshot = self.class.snapshot
  # `@compiled_module_urls` / `@compiled_script_keys` track what this
  # isolate has already compiled, for the no-cd paths in
  # `native_module_for` / `attach_run_script_with_cache`. They persist
  # across warm realm resets (same isolate, warm in-memory compilation
  # cache) and are cleared only on a true rebuild (different isolate,
  # cold cache).
  @compiled_module_urls = {}
  @compiled_script_keys = {}
end

Class Method Details

.attach_host_fns(c, browser) ⇒ Object

Class-level attach so Worker isolates (Ruby-thread-owned contexts that don't have a Runtime instance wrapping them) reuse the same BROWSER_HOST_FNS + STDLIB_HOST_FNS table the main runtime wires up.



1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
# File 'lib/capybara/simulated/v8_runtime.rb', line 1481

def self.attach_host_fns(c, browser)
  fns = {}
  RuntimeShared::BROWSER_HOST_FNS.each {|name, body|
    fns[name] = ->(*a) { RuntimeShared.safe_call { body.call(browser, *a) } }
  }
  fns.update(RuntimeShared::STDLIB_HOST_FNS)
  # One rendezvous for the whole table (~50 fns) — this runs per cold
  # build, per worker, and per warm realm reset.
  c.attach_many(fns)
  # `dispatchEventForUserAction` calls `__csim_yield` between listener
  # invocations to match HTML spec "clean up after running script"
  # microtask-checkpoint semantics. Alias it to the namespace's native
  # in-isolate checkpoint so callers pay ~sub-µs instead of an
  # attached-fn cross-thread round-trip.
  c.eval_void("globalThis.__csim_yield = globalThis.#{HOST_NAMESPACE_NAME}.drainMicrotasks;")
  # Register the bridge's recorder for V8's promise-reject notifications
  # — the channel that surfaces rejections NO handler ever sees
  # (fire-and-forget async functions, bare `Promise.reject`); the
  # bridge's `.then`-wrap can't observe those. Post-snapshot: the host
  # namespace doesn't exist while the snapshot is built, which is why
  # unhandled-rejection.js leaves registration to us. Main realm only —
  # the recorder routes per-realm via `contextGlobal` itself, and a
  # frame-realm registration would dangle once that realm is disposed.
  c.eval_void(<<~JS)
    if (typeof globalThis.#{HOST_NAMESPACE_NAME}.setPromiseRejectHandler === 'function' &&
        typeof globalThis.__csimPromiseRejected === 'function') {
      globalThis.#{HOST_NAMESPACE_NAME}.setPromiseRejectHandler(globalThis.__csimPromiseRejected);
    }
  JS
end

.build_snapshotObject

Snapshot.new(source) is non-deterministic — V8 embeds transient allocator state in the produced bytes, so the same source yields different blobs across runs. V8's bytecode-cache validation (ScriptCompiler::CompileUnboundScript with kConsumeCodeCache) keys on snapshot bytes, so re-new-ing in each process makes cross-process ScriptCache hits get rejected. Building once and persisting the dump fixes that: every process boots off byte-identical snapshot bytes and cached_data accepts.



239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
# File 'lib/capybara/simulated/v8_runtime.rb', line 239

def self.build_snapshot
  cache_path = snapshot_cache_path
  return build_snapshot_uncached unless cache_path
  begin
    FileUtils.mkdir_p(File.dirname(cache_path))
    # Serialize concurrent cold boots (parallel test workers):
    # `Snapshot.new` is non-deterministic, so two processes racing the
    # build would persist different bytes and every ScriptCache entry
    # keyed to the loser's blob gets `cache_rejected` forever after.
    # One process builds under the lock; the rest load its bytes.
    File.open("#{cache_path}.lock", File::CREAT | File::RDWR) do |lock|
      lock.flock(File::LOCK_EX)
      if (bytes = read_verified_snapshot(cache_path))
        return RustyRacer::Snapshot.load(bytes)
      end
      snap  = build_snapshot_uncached
      # Persist + reload so this process also boots from the same
      # bytes other processes will load — the produce-side snapshot
      # must equal the consume-side snapshot for `cached_data` to
      # accept (see the build_snapshot header rationale).
      bytes = snap.dump
      persist_snapshot_bytes(bytes, cache_path)
      return RustyRacer::Snapshot.load(bytes)
    end
  rescue StandardError
    # Cache plumbing must never break boot; fall back to an
    # in-process build (we just lose the cross-process savings).
    build_snapshot_uncached
  end
end

.build_snapshot_uncachedObject



270
271
272
273
274
275
276
277
# File 'lib/capybara/simulated/v8_runtime.rb', line 270

def self.build_snapshot_uncached
  snap = RustyRacer::Snapshot.new(RuntimeShared.snapshot_src)
  # `warmup!` runs `SNAPSHOT_WARMUP` once in a throwaway context and
  # keeps the resulting compiled code, so contexts created from this
  # snapshot inherit JIT-primed versions of the hot paths above.
  snap.warmup!(SNAPSHOT_WARMUP) rescue nil
  snap
end

.build_worker(browser, post_back, broadcast_out = nil, sw_hooks = {}) ⇒ Object

Worker-isolate factory: fresh isolate from the shared snapshot, host fns attached, __csim_isWorker flag set, + the per-worker postMessage host fn closed over post_back. Returns a uniform WorkerRuntime adapter that Browser#run_worker drives.



1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
# File 'lib/capybara/simulated/v8_runtime.rb', line 1517

def self.build_worker(browser, post_back, broadcast_out = nil, sw_hooks = {})
  c = Ctx.new(snapshot: snapshot)
  attach_host_fns(c, browser)
  c.attach('__csim_workerPostMessage', ->(data) { post_back.call(data); nil })
  # Service-worker → main-thread signals route through the thread-safe outbox (delivered by
  # deliver_worker_messages): client.postMessage, clients.claim (set the client's controller),
  # and a controlled fetch's respondWith result. See run_worker for the closures.
  c.attach('__csim_swPostToClient', ->(client_id, data) { sw_hooks[:post_to_client]&.call(client_id, data); nil }) if sw_hooks[:post_to_client]
  c.attach('__csim_swFocusClient',   ->(client_id)       { sw_hooks[:focus_client]&.call(client_id);       nil }) if sw_hooks[:focus_client]
  c.attach('__csim_swNavigateClient', ->(client_id, url, nav_id) { sw_hooks[:navigate_client]&.call(client_id, url, nav_id); nil }) if sw_hooks[:navigate_client]
  c.attach('__csim_swClaim',        ->                  { sw_hooks[:claim]&.call; nil }) if sw_hooks[:claim]
  c.attach('__csim_swSkipWaitingRequest', ->             { sw_hooks[:skip_waiting]&.call; nil }) if sw_hooks[:skip_waiting]
  c.attach('__csim_swNoteRouterRules',    ->             { sw_hooks[:router]&.call; nil }) if sw_hooks[:router]
  c.attach('__csim_swRaceNetwork', ->(fetch_id, realm_id, url, method) { sw_hooks[:race_network]&.call(fetch_id, realm_id, url, method); nil }) if sw_hooks[:race_network]
  c.attach('__csim_swUnregisterRequest',  ->             { sw_hooks[:unregister]&.call; nil }) if sw_hooks[:unregister]
  c.attach('__csim_swExtendedChanged',    ->(n)          { sw_hooks[:extended]&.call(n); nil }) if sw_hooks[:extended]
  c.attach('__csim_swFetchRespond', ->(fetch_id, resp, realm_id) { sw_hooks[:fetch_respond]&.call(fetch_id, resp, realm_id); nil }) if sw_hooks[:fetch_respond]
  c.attach('__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]
  # Cross-isolate MessagePort channel signals (a worker/SW port endpoint + its outbound messages).
  c.attach('__csim_workerPortPost',     ->(channel, data) { sw_hooks[:port_post]&.call(channel, data); nil }) if sw_hooks[:port_post]
  c.attach('__csim_workerPortEndpoint', ->(channel)       { sw_hooks[:port_endpoint]&.call(channel); nil }) if sw_hooks[:port_endpoint]
  # A worker is a SEPARATE isolate: its BroadcastChannel fan-out to the main + frame realms +
  # other workers can't run `browser.broadcast_to_windows` inline (that would mutate the main
  # browser's inbox from the worker thread). Route it through the thread-safe outbox instead
  # (the shared BROWSER_HOST_FNS `__csimBroadcast` above, bound to `browser`, is overridden
  # here). Same-worker-context delivery already happened in-VM via `_bcChannels`.
  c.attach('__csimBroadcast', ->(name, data, _rid, origin) { broadcast_out&.call(name, data, origin); nil }) if broadcast_out
  # Worker's timer table is independent from main's; routing the
  # worker's `setTimersActive` through `browser.timers_active=`
  # races the main isolate's polling? gate, dropping main-thread
  # pending XHRs the moment the worker's queue empties. The settle
  # loop already polls `worker_pending?` for worker thread activity.
  c.attach('__setTimersActive', ->(_flag) { nil })
  # `importScripts` runs a classic script at the worker's TOP-LEVEL script scope,
  # so its top-level `const`/`let`/`class` (dispatcher.js's `const send`/`receive`)
  # join the realm's shared global lexical environment where later code sees them.
  # `(0, eval)` would block-scope them to the eval and they'd vanish. `c.eval` is
  # the top-level-script path (same as the worker's own body eval).
  c.attach('__csim_workerImportEval', ->(src) { c.eval_void(src.to_s) })
  c.eval_void('__csim_installWorkerScope();')
  WorkerRuntime.new(
    eval_void_fn:      ->(s)     { c.eval_void(s.to_s) },
    call_fn:           ->(n, *a) { c.call(n.to_s, *a) },
    drain_microtasks:  ->        { c.perform_microtask_checkpoint },
    drain_timers:      ->        { c.call('__drainTimers', 50) },
    has_ready_timer:   ->        { !!c.call('__hasReadyTimer') },
    dispose:           ->        { c.dispose rescue nil },
    # Called from the SESSION BOUNDARY's thread, not this worker's: V8's terminate is
    # thread-safe by design, and it is the only way to end a call that is already running.
    terminate:         ->        { c.terminate rescue nil },
    # A `{type: 'module'}` service worker's main script + static import graph,
    # via V8's native module API (the same surface the main realm's
    # eval_esm_module uses). The whole graph resolves through the root's
    # instantiate callback (V8 calls it per unresolved edge, transitively);
    # `fetch_import` runs on the worker's own thread and raises to fail the
    # evaluation. Specifier resolution is PLAIN URL resolution — a worker has
    # no document, so the page's importmap does not apply, and a bare
    # specifier is a resolution failure per the spec.
    eval_module_graph: lambda {|src, url, fetch_import|
      src_text = RuntimeShared.utf8_text(src.to_s.dup)
      handles = {}
      root = c.compile_module(src_text, filename: url.to_s)
      handles[url.to_s] = root
      root.instantiate do |spec, ref|
        s = spec.to_s
        resolved =
          if s.match?(%r{\A[a-z]+://}i)
            s
          elsif s.start_with?('/', './', '../')
            URI.join((ref || url).to_s, s).to_s
          else
            raise "Failed to resolve module specifier '#{s}'"
          end
        handles[resolved] ||= c.compile_module(RuntimeShared.utf8_text(fetch_import.call(resolved).to_s.dup), filename: resolved)
      end
      # Top-level await is disallowed in a service worker module ("Run Service
      # Worker" fails the script; Chrome rejects the registration). V8's
      # IsGraphAsync (Module#graph_async?) answers it for the WHOLE
      # instantiated graph, which is what the spec asks: TLA hiding in an
      # imported module fails too. Per-module `[[HasTLA]]` would name the
      # offender but not this question — it can't see an imported module's
      # await. This lambda serves service workers only; a dedicated module
      # worker, where TLA is legal, would need the check parameterized.
      raise 'Top-level await is disallowed in a service worker' if root.graph_async?

      root.evaluate
      nil
    }
  )
end

.cached_data_version_tagObject

V8's bytecode-cache version tag. Keys every ScriptCache entry so a V8 upgrade invalidates stale bytecode. Fixed per process → memoized.



756
757
758
759
# File 'lib/capybara/simulated/v8_runtime.rb', line 756

def self.cached_data_version_tag
  return @cached_data_version_tag if defined?(@cached_data_version_tag)
  @cached_data_version_tag = RustyRacer.cached_data_version_tag
end

.persist_snapshot_bytes(bytes, path) ⇒ Object



302
303
304
305
306
307
308
309
310
311
# File 'lib/capybara/simulated/v8_runtime.rb', line 302

def self.persist_snapshot_bytes(bytes, path)
  tmp = "#{path}.#{Process.pid}.tmp"
  File.binwrite(tmp, bytes)
  File.write("#{path}.sha256", Digest::SHA256.hexdigest(bytes))
  File.rename(tmp, path)
  prune_snapshot_cache(path)
rescue StandardError
  # Best-effort: snapshot rebuild on every process is fine,
  # we just lose the cross-process startup savings.
end

.prune_snapshot_cache(current) ⇒ Object

A multi-MB blob per bridge edit / V8 upgrade accrues forever otherwise; only the current key is ever loadable again, so drop the rest.



316
317
318
319
320
321
322
323
# File 'lib/capybara/simulated/v8_runtime.rb', line 316

def self.prune_snapshot_cache(current)
  keep = File.basename(current)
  Dir.glob(File.join(File.dirname(current), '*.bin')).each do |f|
    next if File.basename(f) == keep
    FileUtils.rm_f([f, "#{f}.sha256", "#{f}.lock"])
  end
rescue StandardError
end

.read_verified_snapshot(path) ⇒ Object

Snapshot.load doesn't validate — corrupt bytes surface as a V8 FATAL abort at the first Isolate.new, long past any rescue here. Verify against the SHA sidecar written at persist time, so a truncated / corrupted blob rebuilds instead of crash-looping every subsequent run.



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

def self.read_verified_snapshot(path)
  return nil unless File.exist?(path)
  bytes = File.binread(path)
  sha   = File.read("#{path}.sha256").strip
  Digest::SHA256.hexdigest(bytes) == sha ? bytes : nil
rescue StandardError
  nil
end

.snapshotObject



189
190
191
# File 'lib/capybara/simulated/v8_runtime.rb', line 189

def self.snapshot
  @@snapshot_lock.synchronize { @@snapshot ||= build_snapshot }
end

.snapshot_cache_pathObject



293
294
295
296
297
298
299
300
# File 'lib/capybara/simulated/v8_runtime.rb', line 293

def self.snapshot_cache_path
  return nil if ENV['CSIM_SNAPSHOT_CACHE'].to_s.casecmp('off').zero?
  dir = ENV['CSIM_SNAPSHOT_CACHE_DIR'] ||
        File.join(ENV['HOME'] || '/tmp', '.cache', 'capybara-simulated', 'snapshot')
  sha = Digest::SHA256.hexdigest(RuntimeShared.snapshot_src + SNAPSHOT_WARMUP)
  tag = cached_data_version_tag
  File.join(dir, "#{tag}-#{sha[0, 16]}.bin")
end

Instance Method Details

#attach_frame_realm_loader(c) ⇒ Object

The bridge calls __csim_createFrameRealm(url, body, contentType, parentId) (from iframe.contentWindow's getter) to spin up a real per-iframe realm whose parent/top point at the realm parentId identifies. This runs re-entrantly inside the main ctx's eval — rusty services nested requests while a host callback is in flight. Returns the realm's context id (or nil on failure — then the bridge keeps its same-realm fallback). The bridge maps iframe.contentWindow to RustyRacer.contextGlobal(id).



783
784
785
786
787
788
789
790
791
792
793
794
795
# File 'lib/capybara/simulated/v8_runtime.rb', line 783

def attach_frame_realm_loader(c)
  c.attach('__csim_createFrameRealm', ->(url, body, content_type, parent_id = 0, frame_name = nil, frame_doc_origin = nil, frame_location_origin = nil, js_url_source = nil, frame_about_base = nil, frame_viewport = nil, client_id = nil) {
    RuntimeShared.safe_call { create_frame_realm(c, url, body, content_type, parent_id, frame_name, frame_doc_origin, frame_location_origin, js_url_source, frame_about_base, frame_viewport, client_id) }
  })
  # Re-navigating an iframe (src/srcdoc reassigned) builds a fresh realm;
  # the bridge calls this to tear down the superseded one so it doesn't
  # linger in @frame_realms and get re-drained on every poll tick.
  # Disposing a non-executing child realm mid-callback is safe.
  c.attach('__csim_disposeFrameRealm', ->(id) {
    dispose_frame_realm(id)   # also revokes the realm's blob URLs
    nil
  })
end

#attach_host_fns(c) ⇒ Object



769
770
771
772
773
774
# File 'lib/capybara/simulated/v8_runtime.rb', line 769

def attach_host_fns(c)
  self.class.attach_host_fns(c, @browser)
  attach_run_script_with_cache(c)
  attach_native_module_loader(c)
  attach_frame_realm_loader(c)
end

#attach_native_module_loader(c) ⇒ Object

import('x') routes through this callback; rusty's native side finishes the dynamic import per the V8 host contract — it instantiates + evaluates the returned Module (TLA-aware, via the evaluation promise) before resolving the outer import() promise. The resolver is per-ISOLATE; rusty hands it the INITIATING realm's Context as the third argument, so a frame realm's import() compiles + links in that realm with its own handle cache — same realm-correctness as static <script type=module> via attach_realm_esm_entry.



1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
# File 'lib/capybara/simulated/v8_runtime.rb', line 1227

def attach_native_module_loader(c)
  c.attach('__csim_evalEsmEntry', ->(url, inline, *sw) {
    RuntimeShared.safe_call { eval_esm_module(url, inline, sw: esm_sw_ctx(sw)) }
    nil
  })
  c.dynamic_import_resolver = ->(specifier, referrer, initiating) {
    target, handles =
      if initiating && initiating.id != 0
        [initiating, realm_module_handles(initiating.id)]
      else
        [ctx, native_module_handles]
      end
    resolved = @browser.resolve_module_specifier(specifier, referrer)
    m = native_module_for(resolved, nil, target, handles)
    raise "module not found: #{resolved}" unless m
    instantiate_native_module(m, resolved, target, handles)
    m
  }
end

#attach_realm_esm_entry(realm) ⇒ Object

Frame-document <script type=module> entry, bound to the realm.



1288
1289
1290
1291
1292
1293
1294
1295
# File 'lib/capybara/simulated/v8_runtime.rb', line 1288

def attach_realm_esm_entry(realm)
  realm.attach('__csim_evalEsmEntry', ->(url, inline, *sw) {
    RuntimeShared.safe_call {
      eval_esm_module(url, inline, target: realm, handles: realm_module_handles(realm.id), sw: esm_sw_ctx(sw))
    }
    nil
  })
end

#attach_run_script_with_cache(c) ⇒ Object



1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
# File 'lib/capybara/simulated/v8_runtime.rb', line 1327

def attach_run_script_with_cache(c)
  version_tag = self.class.cached_data_version_tag
  debug = ENV['CSIM_SCRIPT_CACHE_DEBUG']
  # Big bodies → Ruby-side bytecode cache. The dispatcher below
  # routes small bodies to a JS-only `(0, eval)` so they don't
  # pay the rendezvous round-trip.
  c.attach('__csim_runScriptCached', ->(label, body) {
    RuntimeShared.safe_call {
      src = body.to_s
      # No-cd warm path, mirroring `native_module_for`: once this
      # isolate has compiled a (label, bytesize), re-visits compile
      # straight against V8's source-keyed in-memory cache — skipping
      # the SHA256 of a 140KB+ chunk per visit (rbspy: ~4.8% of the
      # Discourse perf sample was Digest#update) AND the disk lookup.
      # The key is a heuristic, but a false positive only costs a
      # plain recompile of the true source — never wrong code.
      key = [label.to_s, src.bytesize]
      if @compiled_script_keys.key?(key)
        script = c.compile(src, filename: label.to_s)
      else
        sha    = Digest::SHA256.hexdigest(src)
        cached = ScriptCache.lookup(sha, version_tag)
        script = c.compile(src, filename: label.to_s, cached_data: cached)
        $stderr.puts "[runScript] label=#{label.to_s[0,60]} hit=#{!cached.nil?} rejected=#{script.cache_rejected?}" if debug
        # V8 forbids `produce_cache: true` from inside a host-fn
        # callback so we queue misses + rejects for top-level
        # produce via `ScriptCache.warm_pending!` after the
        # current `V8Runtime#call` returns.
        if cached.nil? || script.cache_rejected?
          ScriptCache.queue_warm(c, sha, label, src, version_tag, stale: !cached.nil?)
        end
        @compiled_script_keys[key] = true if key
      end
      begin
        # run_void, not run: a `<script>`'s completion value is nobody's
        # answer, and marshalling it would drag a large inline script's
        # trailing jQuery-ish expression (a non-cloneable `ce.fn.init`)
        # through the deep-copy filter for nothing. This is why `src` is
        # the body verbatim — it used to carry a `;undefined` suffix, which
        # then had to be hashed into the bytecode-cache key as well.
        script.run_void
      ensure
        script.dispose
      end
    }
    nil
  })
  # Small bodies normally run JS-side via `(0, eval)(body)` — fast,
  # no Ruby↔V8 boundary. But `(0, eval)` block-scopes a script's
  # top-level `const`/`let`/`class` to the eval, so they vanish
  # instead of landing in the realm's *shared* global lexical
  # environment where a later `<script>` would see them. Real
  # browsers (and our big-body `compile().run` path above) keep
  # them. The shape that needs this is a leading lexical
  # declaration: `<script>const CFG=…</script><script>…use CFG…` and
  # every WPT helper pulled in via `// META: script=` that starts
  # `const TABLE = […]` (sab.js's `createBuffer`, encodings.js's
  # `encodings_table`, …). So route ONLY scripts whose first real
  # statement is a top-level `const`/`let`/`class` through `ctx.eval`
  # (a top-level V8 script → shared lexical env); everything else
  # (IIFEs, `var`/`function` — which already leak to globalThis
  # under `(0, eval)` — and plain calls) stays on the fast path. A
  # later `(0, eval)` script can READ those bindings from the global
  # lexical environment fine; only DEFINING them needed the
  # real-script path. No bytecode cache here — the SHA + compile +
  # dispose is the part that regressed tiny-script-heavy suites
  # (Redmine 56→224 s); plain `ctx.eval` is rendezvous-cheap, and
  # the leading-lexical gate keeps the boundary off the hot path for
  # the ~95% of inline scripts that don't lead with a declaration.
  # Limitation: a top-level `const` that is NOT the first statement
  # (after other top-level code) won't be shared — rare, and the
  # WPT helper corpus + the `<script>const CFG…` pattern both lead
  # with the declaration.
  # NOTE: do NOT wrap in `safe_call`. A JS throw from `c.eval`
  # raises RustyRacer::RuntimeError, which rusty re-raises as a
  # JS exception at the call site — so bridge.entry.js's
  # `try { __csim_runScript(…) } catch (e)` sees it and runs its
  # normal path (console diagnostic, `_ok=false`, fire the script
  # `error` event), exactly as the JS-side `(0, eval)` does and
  # as the QuickJS runner does. Swallowing here would turn a
  # throwing leading-`const` inline script into a silent `load`.
  c.attach('__csim_runScriptEval', ->(label, body) {
    # A `<script>`'s completion value is nobody's answer, and reading it is
    # not free: a leading-lexical inline script ending in a jQuery-ish
    # expression (`const cfg=…; $(…)`) evaluates to a `ce.fn.init`
    # (array-like, non-cloneable) that drags through the deep-copy filter,
    # and a hostile getter in there would raise an error this call has no
    # business raising. `eval_void` says so outright, replacing the trailing
    # `;undefined` this used to append. Lexical declarations persist as a
    # side effect of eval, independent of the completion value.
    c.eval_void("#{body}\n//# sourceURL=#{label.to_s.tr("\n", ' ')}")
  })
  install_run_script_dispatcher(c)
end

#build_and_track_ctxObject

build_ctx + register for at_exit cleanup.



699
700
701
702
703
# File 'lib/capybara/simulated/v8_runtime.rb', line 699

def build_and_track_ctx
  c = build_ctx
  @@live_lock.synchronize { @@live << c }
  c
end

#build_ctxObject



761
762
763
764
765
766
# File 'lib/capybara/simulated/v8_runtime.rb', line 761

def build_ctx
  c = Ctx.new(snapshot: @snapshot || self.class.snapshot, timeout: CALL_TIMEOUT_MS)
  attach_host_fns(c)
  c.eval_void('__csim_installWorker();')
  c
end

#call(name, *args) ⇒ Object



350
351
352
353
354
# File 'lib/capybara/simulated/v8_runtime.rb', line 350

def call(name, *args)
  result = ctx.call(name, *args)
  ScriptCache.warm_pending!
  result
end

#create_frame_realm(parent_ctx, url, body, content_type, parent_id = 0, frame_name = nil, frame_doc_origin = nil, frame_location_origin = nil, js_url_source = nil, frame_about_base = nil, frame_viewport = nil, client_id = nil) ⇒ Object



837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
# File 'lib/capybara/simulated/v8_runtime.rb', line 837

def create_frame_realm(parent_ctx, url, body, content_type, parent_id = 0, frame_name = nil, frame_doc_origin = nil, frame_location_origin = nil, js_url_source = nil, frame_about_base = nil, frame_viewport = nil, client_id = nil)
  depth = (frame_realm_depths[parent_id] || 0) + 1
  if depth > MAX_FRAME_DEPTH
    @browser.log_console('warn', "iframe nesting depth #{depth} exceeds #{MAX_FRAME_DEPTH}; not building #{url}")
    return nil
  end
  realm = parent_ctx.create_context
  # Record depth BEFORE loading the document: the frame's own scripts run
  # during __csimLoadDocument below and may synchronously build NESTED frames
  # (their create_frame_realm looks up this realm's depth as their parent's),
  # so it must already be set or the nested depth undercounts and the cap
  # never trips.
  frame_realm_depths[realm.id]  = depth
  frame_realm_parents[realm.id] = parent_id.to_i   # owning realm, for contentWindow-reached rebuilds
  seed_realm_bridge(realm)
  # Wire `parent` / `top` to the realm that owns this iframe (its context
  # id passed from `__csimFrameWindow`), BEFORE the frame's scripts run —
  # `top` propagates up the chain (the main realm's `top` is itself). A
  # nested frame thus reaches its TRUE parent, not unconditionally the
  # main frame. `parent_id` is an integer the marshaller carries verbatim.
  #
  # eval_void, and not by preference: a statement list's completion value is
  # its last statement's, so this block evaluates to the WindowProxy it just
  # assigned — and marshalling a value RUNS JS, so the proxy's `ownKeys` trap
  # throws SecurityError at a cross-origin parent. That lands after every
  # write here has already succeeded, aborting the rest of the frame's boot
  # (leaving it on the snapshot's default origin) over a value we never asked
  # for. Every eval below whose value we discard is spelled the same way.
  realm.eval_void(<<~JS)
    if (globalThis.#{HOST_NAMESPACE_NAME} && typeof globalThis.#{HOST_NAMESPACE_NAME}.contextGlobal === 'function') {
      var __parentWin = globalThis.#{HOST_NAMESPACE_NAME}.contextGlobal(#{parent_id.to_i});
      if (__parentWin) {
        // Expose `parent`/`top` as THIS realm's WindowProxy for them (not the
        // raw parent global) so `e.source === parent` holds and a frame's
        // `parent.postMessage(...)` attributes the sender. `top` resolves
        // through the parent's own top (already a proxy if the parent is a
        // frame), unwrapped to its raw global then re-proxied for this realm.
        var __NS = globalThis.#{HOST_NAMESPACE_NAME};
        var __pf = globalThis.__csimFrameWindowProxyFor;
        if (__pf && __NS && typeof __NS.contextOf === 'function') {
          var __topRaw = __parentWin.top || __parentWin;
          if (__topRaw && __topRaw.__csimRawWindow) __topRaw = __topRaw.__csimRawWindow;
          globalThis.parent = __pf(__NS.contextOf(__parentWin)) || __parentWin;
          globalThis.top    = __pf(__NS.contextOf(__topRaw)) || __topRaw;
        } else {
          globalThis.parent = __parentWin;
          globalThis.top    = __parentWin.top || __parentWin;
        }
      }
    }
  JS
  # Pass the URL + document body as call ARGUMENTS, not interpolated into
  # an eval string: the marshaller carries them losslessly, so arbitrary
  # HTML / control bytes survive (Ruby's String#inspect is NOT a faithful
  # JS string escaper — it mangles \a, \e, and binary bytes).
  realm.call('__csimUpdateLocation', url.to_s) unless url.to_s.empty?
  # The navigation's reserved client id — this document's realm ADOPTS it as its
  # service-worker Client identity (`event.resultingClientId` resolves to THIS
  # client after commit). Seeded BEFORE the document loads so the realm's own
  # client reports (sw-client.js clientId()) already carry it; the browser-side
  # alias keeps message routing and record minting coherent (sw_adopt_client_id).
  unless client_id.to_s.empty?
    realm.eval_void("globalThis.__csimClientId = #{JSON.generate(client_id.to_s)};")
    @browser.sw_adopt_client_id(realm.id, client_id.to_s)
  end
  # Set window.name from the container's `name` attribute BEFORE the document
  # loads, so a frame whose load handler reads window.name to identify itself
  # (declarative-shadow declarative-child-frame) sees it.
  realm.call('__csimSetWindowName', frame_name.to_s) unless frame_name.nil?
  # The frame's viewport is its container's content box, measured by the parent realm. Seed it
  # BEFORE the document loads so load-time `innerWidth` reads and `@media` evaluation see the
  # frame's own size rather than the top window's. `nil` = an unrendered container, which is a
  # 0x0 window — pass it through rather than skipping, or the frame keeps the top-level size.
  realm.call('__csimFrameViewportChanged', frame_viewport)
  # Seed the frame's document origin (opaque/inherited) BEFORE the document
  # loads, so its load-time scripts read the right self.origin. nil → a
  # real-URL frame whose origin is its own location origin.
  realm.call('__csimSetDocumentOrigin', frame_doc_origin.to_s) unless frame_doc_origin.nil?
  # The frame's location.origin (opaque "null" for about:blank / srcdoc /
  # javascript:); decoupled from the location string so navigation is intact.
  realm.call('__csimSetLocationOrigin', frame_location_origin.to_s) unless frame_location_origin.nil?
  # An about:blank / about:srcdoc frame's URL is opaque, but its base URL (for
  # relative-URL resolution) is INHERITED from the creator. Seed that inherited
  # base BEFORE the document loads so its load-time scripts resolve relative URLs
  # against the parent, even though location.href reports about:blank/srcdoc.
  realm.call('__csimSetAboutBaseURL', frame_about_base.to_s) unless frame_about_base.to_s.empty?
  # If a service worker's scope covers this frame's URL, the frame is a CONTROLLED client:
  # wire its `navigator.serviceWorker.controller` BEFORE its document loads, so the frame's
  # load-time scripts see the controller and route their subresource fetch() / XHR / EventSource
  # through the SW's fetch event (fetch-event-network-error's controllee fires XHRs during load).
  # The frame never called register(), so its per-realm registration Map is empty — set the
  # controller directly from Ruby's scope→handle mirror. An OPAQUE child (about:blank / srcdoc)
  # has no scope of its own, so it INHERITS its parent frame's controller (HTML: an about:blank
  # document is controlled by its creator). Inheritance chains through controlled FRAME realms
  # (sw_note_realm_controller); recording it BEFORE the load also lets a NESTED frame built during
  # this frame's load inherit the controller.
  opaque = opaque_frame_url?(url)
  # A sandboxed frame WITHOUT allow-same-origin has an OPAQUE origin (doc origin 'null'), which
  # is cross-origin to everything — so it is NOT CONTROLLED AT ALL. It inherits no controller
  # from its creator (srcdoc-iframe: "sandboxed srcdoc should not inherit"), its subresource
  # fetches never reach the SW, and it is absent from `clients.matchAll()`. Excluding it from
  # only the client registry would not hold: a controlled frame's first subresource fetch
  # re-registers it lazily from the fetch event (js/src/workers.js clientFor). The SW does still
  # answer its NAVIGATION request, because a CSP-header sandbox is knowable only FROM that
  # response (sandboxed-iframe-fetch-event, "Service worker should NOT control the sandboxed
  # page"). allow-same-origin restores the parent origin, and a real-URL frame carries no
  # explicit origin at all (nil = its own location origin) — both are controlled normally.
  if frame_doc_origin.to_s != 'null'
    ctrl   = @browser.sw_client_controller_for(url.to_s)
    ctrl ||= @browser.sw_inherited_controller_for(parent_id) if opaque
    if ctrl
      # Wiring the controller is what registers this frame as a client — the realm reports
      # itself from installController (js/src/sw-client.js), before the document below loads.
      realm.call('__csim_swSetControllerDirect', *ctrl)
      @browser.sw_note_realm_controller(realm.id, ctrl)
    end
  end
  realm.call('__csimLoadDocument', body.to_s, content_type.to_s)
  # This document now has a URL and any host-wired controller, so it can announce itself
  # as a service-worker client — an UNCONTROLLED context is still a client of its origin
  # and must show up in `matchAll({includeUncontrolled: true})`.
  realm.call('__csim_swReportClient') rescue nil
  # A `javascript:` URL frame: the initial empty document is now loaded and
  # parent/top are wired, so evaluate the URL's script in the realm (global
  # scope). Per HTML, only a STRING result navigates the frame to a new
  # document built from it; any other result (incl. the common undefined)
  # leaves the about:blank document, so only its side effects (e.g.
  # `parent.foo()`) take effect. A throwing script is reported and left as a
  # no-op rather than aborting the frame build.
  unless js_url_source.nil?
    begin
      result = realm.eval(js_url_source.to_s)
      realm.call('__csimLoadDocument', result, 'text/html') if result.is_a?(String)
    rescue StandardError => e
      @browser.log_console('warn', "javascript: URL frame threw: #{e.message}")
    end
  end
  frame_realms[realm.id] = realm
  # Fire the nested document's window `load`. The frame's inline scripts ran
  # during __csimLoadDocument and registered their `window.onload` (the usual
  # `window.onload = () => parent.postMessage(...)` a frame reports back
  # through); without firing it, an eagerly-built frame that the parent never
  # touches would never run its load handler. Safe if no handler is set
  # (dispatches to an empty listener list). Guarded so a frame whose load
  # handler throws doesn't abort the build.
  realm.call('__csimFireWindowLoad') rescue nil
  realm.id
rescue StandardError => e
  @browser.log_console('warn', "frame realm load failed: #{e.message}")
  # A realm created before the failure (load threw) is untracked — not in
  # frame_realms nor __csimChildRealmIds — so nothing would ever drain or
  # dispose it. Tear it down here (safe: it's non-executing in the rescue),
  # including any module handles its scripts compiled before the throw.
  if realm
    @realm_module_handles&.delete(realm.id)
    @module_sw_ctxs&.delete(realm.id)
    realm.dispose rescue nil
  end
  nil
end

#create_window_realm(url, body, content_type, opener_id: nil, window_name: nil, doc_origin: nil, location_origin: nil, about_base: nil, about_origin: nil) ⇒ Object

Build a same-origin auxiliary WINDOW (window.open / open_new_window) as a realm in THIS isolate — like an iframe's frame realm, but top-level: parent

top === the window itself (the bridge default, so unlike

create_frame_realm we don't wire them to a container), and window.opener points at the opener realm's WindowProxy. Tracked in frame_realms so realm_call / drainChildRealms / dispose_frame_realms cover it for free. Returns the new realm's context id; the opener-side window.open wraps it in a native __csimFrameWindowProxyFor, so popup.document is a real same-isolate Document (cross-window adoptNode works). The single isolate also makes a same-origin window far cheaper than today's isolate-per-window.



1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
# File 'lib/capybara/simulated/v8_runtime.rb', line 1008

def create_window_realm(url, body, content_type, opener_id: nil, window_name: nil, doc_origin: nil, location_origin: nil, about_base: nil, about_origin: nil)
  realm = seed_realm_bridge(ctx.create_context)
  # Mark it a top-level window realm so its location setter routes to
  # __csimWindowRealmNavigate (reload THIS realm) rather than the frame-nav or
  # top-page path — top === self here, so neither default branch fits. Also give
  # it the window-lifecycle surface a popup needs but a frame realm doesn't:
  # `window.closed` (flag-backed) and `window.close()` (marks closed; the realm
  # lingers inert until the Browser tears the isolate down — matching a real
  # closed window whose proxy stays valid and reports closed === true).
  realm.eval_void(<<~JS)
    globalThis.__csimIsWindowRealm = true;
    globalThis.__csimWindowClosedFlag = false;
    try {
      Object.defineProperty(globalThis, 'closed', {
        configurable: true,
        get() { return !!globalThis.__csimWindowClosedFlag; }
      });
    } catch (_) {}
    globalThis.close = function () { globalThis.__csimWindowClosedFlag = true; };
  JS
  # window.opener → a WindowProxy for the opener realm. opener_id is the opener's
  # context id (0 = the main realm, a VALID opener); nil means "no opener", so the
  # guard is on nil, not on 0 (0 is falsy but real here). Assigning globalThis.opener
  # routes through the bridge's opener setter (stores the override the getter returns).
  unless opener_id.nil?
    realm.eval_void(<<~JS)
      if (typeof globalThis.__csimFrameWindowProxyFor === 'function') {
        var __op = globalThis.__csimFrameWindowProxyFor(#{opener_id.to_i});
        if (__op) globalThis.opener = __op;
      }
    JS
  end
  # `window.open()` with no URL opens about:blank — that IS the new document's URL, and a
  # realm built from the snapshot would otherwise keep reporting the OPENER's (making the
  # popup a phantom duplicate of its opener everywhere a document URL is read, the service-
  # worker client set included). Its ORIGIN and its BASE url are inherited from the opener,
  # exactly as an empty <iframe>'s are (create_frame_realm's frame_about_base): the origin
  # so the popup isn't cross-origin to the window that opened it, the base so relative
  # resolution inside it still targets the opener's document.
  if url.to_s.empty?
    realm.call('__csimUpdateLocation', 'about:blank')
    realm.call('__csimSetAboutBaseURL', about_base.to_s)   unless about_base.to_s.empty?
    doc_origin      ||= about_origin unless about_origin.to_s.empty?
    location_origin ||= about_origin unless about_origin.to_s.empty?
  else
    realm.call('__csimUpdateLocation', url.to_s)
  end
  realm.call('__csimSetWindowName', window_name.to_s) unless window_name.nil?
  realm.call('__csimSetDocumentOrigin', doc_origin.to_s)         unless doc_origin.nil?
  realm.call('__csimSetLocationOrigin', location_origin.to_s)    unless location_origin.nil?
  # Register the realm as alive BEFORE its document loads: its inline scripts run
  # synchronously inside __csimLoadDocument and may post to a BroadcastChannel the
  # opener listens on (a blob popup that posts then self.close()s). The delivery
  # path gates on `frame_realm_alive?`, so the realm must already be tracked or its
  # own load-time post is dropped. Mirrors create_frame_realm seeding its depth /
  # parent maps before the load for the same reason.
  frame_realms[realm.id]        = realm
  frame_realm_parents[realm.id] = 0   # top-level (no parent frame)
  # Register with the opener (main) realm's child-realm set so `drainChildRealms`
  # steps THIS realm's event loop too — otherwise its queued tasks (e.g. a
  # BroadcastChannel delivery from a blob document) never fire.
  ctx.eval_void("(globalThis.__csimChildRealmIds || (globalThis.__csimChildRealmIds = new Set())).add(#{realm.id});")
  # Remember the window's opener / name so a self-navigation (reload_window_realm
  # builds a FRESH realm) can carry them across — a real popup keeps window.opener
  # and window.name through its own navigation.
  window_realm_meta[realm.id] = {opener_id: opener_id, window_name: window_name, about_base: about_base, about_origin: about_origin}
  realm.call('__csimLoadDocument', body.to_s, content_type.to_s)
  # As in create_frame_realm: an auxiliary window is a client of its origin too.
  realm.call('__csim_swReportClient') rescue nil
  realm.call('__csimFireWindowLoad') rescue nil
  realm.id
rescue StandardError => e
  @browser.log_console('warn', "window realm load failed: #{e.message}")
  if realm
    # The realm is registered (frame_realms / parents / __csimChildRealmIds)
    # BEFORE the document loads, so a load-time throw must roll all of that back
    # — otherwise frame_realm_alive? and drainChildRealms keep treating a
    # disposed context as live. dispose_frame_realm unwinds the registries (and
    # disposes if registered); the explicit dispose is the backstop for a throw
    # that happened before registration.
    dispose_frame_realm(realm.id)
    realm.dispose rescue nil
  end
  nil
end

#ctxObject

Built lazily on first use, on the calling (main) thread. There is no pool / background pre-warm: under warm-compile the steady-state visit reuses this one isolate via Context#reset (rebuild_ctx) and never builds another, so a pool's async pre-warm bought nothing — and a pool dispatched to its entries from a refill thread before the main thread used them, migrating an isolate's caller thread. Building here keeps every isolate confined to one thread for its whole life. The one-time synchronous build is ~3 ms.



693
694
695
696
# File 'lib/capybara/simulated/v8_runtime.rb', line 693

def ctx
  return @ctx if @disposed   # don't resurrect a disposed runtime (closed window)
  @ctx ||= build_and_track_ctx
end

#disposeObject

Tear this runtime's isolate down for good. Each auxiliary window (window.open / a switched-into target=_blank) is its own Browser + V8Runtime + isolate; without this, closing the window reaped its background threads (Browser#dispose) but left the isolate ALIVE — the @@live at-exit registry holds a strong reference, so a bare GC never reclaimed it. Over a long suite those isolates (and their RSS) accumulated (measured: V8 isolate count 2 → 10, RSS ~2.7 → 6.6 GB across the Discourse suite). Idempotent; only ever called on teardown (Browser#dispose) — never on the per-test reset_page path, which reuses the isolate via Context#reset. The ctx getter stops rebuilding once @disposed, so a stray post-close call can't resurrect the isolate.



729
730
731
732
733
734
735
# File 'lib/capybara/simulated/v8_runtime.rb', line 729

def dispose
  return if @disposed
  @disposed = true
  dispose_frame_realms rescue nil
  c, @ctx = @ctx, nil
  dispose_ctx(c)
end

#dispose_ctx(c) ⇒ Object

Terminate + dispose a tracked isolate and drop it from the at-exit @@live registry. Dispose FIRST and de-register only on success: if dispose raises (rescued), the isolate stays in @@live so the at_exit sweep retries it instead of leaking it un-disposed. Shared by the cold-rebuild fallback (rebuild_ctx) and #dispose.



710
711
712
713
714
715
716
# File 'lib/capybara/simulated/v8_runtime.rb', line 710

def dispose_ctx(c)
  return unless c
  c.terminate rescue nil
  c.dispose
  @@live_lock.synchronize { @@live.delete(c) }
rescue StandardError
end

#dispose_frame_realm(id) ⇒ Object

Tear down a single frame realm (e.g. a descendant frame destroyed when an ancestor frame re-navigates). No-op for nil/0/unknown ids.



501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
# File 'lib/capybara/simulated/v8_runtime.rb', line 501

def dispose_frame_realm(id)
  return if id.nil? || id.zero?
  # The frame's browsing context is going away — revoke the blob URLs it
  # created (url-lifetime "Removing an iframe").
  @browser.revoke_realm_blobs(id) rescue nil
  # Drop it from the SW Client registry so matchAll stops returning a dead client.
  @browser.sw_unregister_client(id) rescue nil
  # A dedicated worker is owned by the context that created it — discarding the context
  # terminates it (and unregisters its own client record).
  @browser.terminate_realm_workers(id) rescue nil
  # If it held the focus chain, focus returns to the top-level browsing context.
  @browser.note_realm_discarded(id) rescue nil
  @realm_module_handles&.delete(id)
  @module_sw_ctxs&.delete(id)
  @frame_realm_depths&.delete(id)
  @frame_realm_parents&.delete(id)
  @window_realm_meta&.delete(id)
  # Evict from the main realm's child-realm set so `drainChildRealms` stops
  # stepping a disposed context. A FRAME realm is also evicted via the DOM
  # unregister path (its iframe element going away), but a WINDOW realm has no
  # element — this is its only eviction, and it covers the reload (dispose +
  # recreate) path too, where the old id would otherwise leak in the set.
  ctx.eval_void("globalThis.__csimChildRealmIds && globalThis.__csimChildRealmIds.delete(#{id.to_i});") rescue nil
  fr = frame_realms.delete(id)
  fr.dispose rescue nil if fr
  nil
end

#dispose_frame_realm_tree(id) ⇒ Object

Dispose a frame realm and every descendant frame realm (transitively), deepest first so a parent's child-realm set is consistent as each level is torn down.



476
477
478
479
# File 'lib/capybara/simulated/v8_runtime.rb', line 476

def dispose_frame_realm_tree(id)
  frame_realm_parents.select {|_child, parent| parent == id }.each_key {|child| dispose_frame_realm_tree(child) }
  dispose_frame_realm(id)
end

#dispose_frame_realmsObject



446
447
448
449
450
451
452
453
454
455
# File 'lib/capybara/simulated/v8_runtime.rb', line 446

def dispose_frame_realms
  @realm_module_handles&.clear
  @module_sw_ctxs&.clear
  @frame_realm_depths&.clear
  @frame_realm_parents&.clear
  @window_realm_meta&.clear
  return if @frame_realms.nil?
  @frame_realms.each_value {|fr| fr.dispose rescue nil }
  @frame_realms.clear
end

#drain_microtasksObject

One native microtask checkpoint — a checkpoint runs the queue until empty, and rusty already performs one at the end of every top-level eval/call (V8's default kAuto policy), so a single explicit checkpoint is all settle needs to advance chained await/.then queues between ticks.



534
535
536
# File 'lib/capybara/simulated/v8_runtime.rb', line 534

def drain_microtasks
  @ctx&.perform_microtask_checkpoint
end

#drain_timers(max_ms = nil) ⇒ Object

bridge.js owns the virtual clock; Ruby still drives it because Capybara's polling cadence is wall-clock-anchored. Use call (function reference) rather than eval (string compile) — the polling loop hits these every retry tick.



394
395
396
397
398
399
400
# File 'lib/capybara/simulated/v8_runtime.rb', line 394

def drain_timers(max_ms = nil)
  # The bridge's `__drainTimers`/`__runLoopStep` step iframe realms' event
  # loops themselves (timers.js `drainChildRealms`), so this one call covers
  # child frames too — no separate Ruby-side fan-out (which would
  # double-advance their clocks and fire intervals twice).
  max_ms.nil? ? ctx.call('__drainTimers') : ctx.call('__drainTimers', max_ms.to_i)
end

#esm_sw_ctx(sw) ⇒ Object

The optional [handle, clientId, referrer, credentials, integrity] tail moduleSwCtx (bridge.entry.js) appends to __csim_evalEsmEntry, or nil.



1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
# File 'lib/capybara/simulated/v8_runtime.rb', line 1276

def esm_sw_ctx(sw)
  return nil unless sw && sw[0].to_i.positive?
  {
    handle:      sw[0].to_i,
    client_id:   sw[1].to_s,
    referrer:    sw[2].to_s,
    credentials: sw[3].to_s,
    integrity:   sw[4].to_s
  }
end

#eval(code) ⇒ Object



348
# File 'lib/capybara/simulated/v8_runtime.rb', line 348

def eval(code)         = ctx.eval(code.to_s)

#eval_esm_module(url, inline_src = nil, target: nil, handles: nil, sw: nil) ⇒ Object

target is the context the module graph compiles + evaluates in, handles its module-handle cache — the main ctx + native_module_handles by default, or a frame realm + its realm-local cache (Module handles are context-bound; sharing the main cache would link a frame's imports against main-context modules).



1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
# File 'lib/capybara/simulated/v8_runtime.rb', line 1099

def eval_esm_module(url, inline_src = nil, target: nil, handles: nil, sw: nil)
  target  ||= ctx
  handles ||= native_module_handles
  # A CONTROLLED document's module-graph source fetches dispatch SW fetch
  # events (destination 'script', mode 'cors'). The context is recorded per
  # REALM (not scoped to this call) so a dynamic `import()` resolved later
  # by the isolate resolver still finds it; `root` carries the entry URL,
  # whose fetch alone uses the element's own `integrity` attribute.
  # KNOWN LOSS (documented divergence): one slot per realm means the LAST
  # evaluated script's fetch options win — a deferred import() from an
  # earlier script picks up the later script's credentials/root. Spec wants
  # per-referring-script options; no vendored test observes the difference.
  module_sw_ctxs[realm_key(target)] = sw&.merge(root: url.to_s)
  m = native_module_for(url, inline_src, target, handles)
  return nil unless m
  begin
    instantiate_native_module(m, url, target, handles)
    m.evaluate
  rescue RustyRacer::ParseError, RustyRacer::RuntimeError => e
    # A top-level module throw belongs on the page console
    # (trace-visible diagnostics), like a classic script's error —
    # not just safe_call's truncated stderr warn. ScriptTerminatedError
    # deliberately propagates (a watchdog terminate must escalate).
    @browser.log_console('error', "module evaluate error in #{url}: #{e.message}")
  end
  nil
end

#eval_void(code) ⇒ Object



349
# File 'lib/capybara/simulated/v8_runtime.rb', line 349

def eval_void(code)    = ctx.eval_void(code.to_s)

#frame_realm_alive?(realm_id) ⇒ Boolean

Returns:

  • (Boolean)


386
387
388
# File 'lib/capybara/simulated/v8_runtime.rb', line 386

def frame_realm_alive?(realm_id)
  !(realm_id.nil? || realm_id.zero?) && frame_realms.key?(realm_id)
end

#frame_realm_depthsObject



809
# File 'lib/capybara/simulated/v8_runtime.rb', line 809

def frame_realm_depths = (@frame_realm_depths ||= {})

#frame_realm_idsObject

Ids of every live frame / window realm in this isolate (excludes the main realm, id 0). Used to fan a BroadcastChannel post out to sibling realms.



384
# File 'lib/capybara/simulated/v8_runtime.rb', line 384

def frame_realm_ids = frame_realms.keys

#frame_realm_parent(realm_id) ⇒ Object



429
430
431
432
# File 'lib/capybara/simulated/v8_runtime.rb', line 429

def frame_realm_parent(realm_id)
  return 0 if realm_id.nil? || realm_id.zero?
  frame_realm_parents[realm_id] || 0
end

#frame_realm_parentsObject

Parent realm id per frame realm, captured at create_frame_realm time (parallel to @frame_realm_depths). Lets a form/navigation that reaches a frame via contentWindow (so it never entered within_frame and has no @frame_stack entry) recover the realm that OWNS the iframe element, to rebuild + rebind it. 0/nil = the main realm.



427
# File 'lib/capybara/simulated/v8_runtime.rb', line 427

def frame_realm_parents = (@frame_realm_parents ||= {})

#frame_realmsObject

Per-iframe realms (Isolate#create_context): a separate V8 context — own global + intrinsics (Function/Error/DOMParser/onerror) — per nested browsing context, so cross-realm tests behave per spec. Keyed by context id; released explicitly by dispose_frame_realms on every rebuild — under warm-compile the isolate survives the visit, so nothing else would ever free them.



420
# File 'lib/capybara/simulated/v8_runtime.rb', line 420

def frame_realms = (@frame_realms ||= {})

#has_ready_timer?Boolean

Returns:

  • (Boolean)


551
552
553
554
# File 'lib/capybara/simulated/v8_runtime.rb', line 551

def has_ready_timer?
  return false if @ctx.nil?
  !!ctx.call('__hasReadyTimer')
end

#install_run_script_dispatcher(c) ⇒ Object

The JS-side __csim_runScript dispatcher routes each inline-script body to the bytecode-cache path, the shared-lexical ctx.eval path, or the JS-only (0, eval) fast path. It snapshots the CURRENT __csim_runScriptCached / __csim_runScriptEval host fns, so it must run after the attaches it captures (attach_run_script_with_cache installs it last for exactly that reason).



1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
# File 'lib/capybara/simulated/v8_runtime.rb', line 1428

def install_run_script_dispatcher(c)
  c.eval_void(<<~JS)
    (function () {
      const cached    = globalThis.__csim_runScriptCached;
      const runEval   = globalThis.__csim_runScriptEval;
      const threshold = #{SCRIPT_CACHE_MIN_BYTES};
      // Leading top-level lexical declaration, after optional BOM /
      // whitespace / line+block comments / a "use strict" prologue.
      const LEADS_LEXICAL = /^[\\s\\uFEFF]*(?:(?:\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/)\\s*)*(?:["']use strict["'];?\\s*)?(?:export\\s+)?(?:const|let|class)[\\s{\\[]/;
      // A top-level lexical declaration can also sit MID-file (WPT's
      // cookie-helper.sub.js opens with an IIFE and declares `const
      // wait_for_message` at line 129) — under `(0, eval)` it block-scopes
      // and later <script>s see a ReferenceError. Detect it by a
      // declaration at COLUMN 0 of any line: real top-level declarations
      // in unminified files start unindented, function-body ones are
      // indented. Residual known gap: a one-line `stmt; const CFG = …`
      // (declaration mid-LINE) still takes the fast path and loses the
      // binding — no observed page does this. A false positive (a template
      // literal's line starting with `const `) merely routes through the
      // always-correct ctx.eval path.
      const MID_LEXICAL = /^(?:const|let|class)[\\s{\\[]/m;
      // A "use strict" directive prologue. A classic <script> evaluates as a
      // top-level Script, where top-level `var` / `function` declarations
      // bind on the global object even in strict mode — but the JS-only
      // `(0, eval)(body)` fast path runs them as an INDIRECT eval, and a
      // strict indirect eval gets its OWN variable environment, so those
      // declarations never reach globalThis (a later <script> can't see
      // them). Route strict-prologue scripts through the real top-level
      // `ctx.eval` path too, same as leading lexical declarations.
      const LEADS_USE_STRICT = /^[\\s\\uFEFF]*(?:(?:\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/)\\s*)*["']use strict["']/;
      globalThis.__csim_runScript = function (label, body) {
        if (body.length >= threshold) return cached(label, body);
        if (LEADS_LEXICAL.test(body) || LEADS_USE_STRICT.test(body) || MID_LEXICAL.test(body)) return runEval(label || 'csim-eval', body);
        (0, eval)(body + '\\n//# sourceURL=' + (label || 'csim-eval'));
      };
    })();
  JS
end

#instantiate_native_module(m, importer_url, target, handles) ⇒ Object



1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
# File 'lib/capybara/simulated/v8_runtime.rb', line 1207

def instantiate_native_module(m, importer_url, target, handles)
  return unless m.status == :uninstantiated
  browser = @browser
  m.instantiate do |specifier, referrer|
    resolved = browser.resolve_module_specifier(specifier, referrer || importer_url)
    child = native_module_for(resolved, nil, target, handles)
    raise "module not found: #{resolved}" unless child
    child
  end
end

#module_body(url, src) ⇒ Object

A .json module is exposed as the default export of its parsed value; every other body is the fetched source as-is. Module SOURCE is text, but it arrives as the raw Rack / File.binread body — tagged ASCII-8BIT (see RuntimeShared.utf8_text).



1131
1132
1133
1134
# File 'lib/capybara/simulated/v8_runtime.rb', line 1131

def module_body(url, src)
  src = RuntimeShared.utf8_text(src)
  url.to_s.match?(/\.json(?:\?|$)/) ? "export default #{src};" : src
end

#module_sw_ctxsObject

realm -> SW fetch context for module-source fetches (see eval_esm_module). Invalidated exactly like native_module_handles (a service worker OUTLIVES navigation, so a stale entry would be ACTIVE, not inert: the next page's dynamic import would dispatch fetch events for an uncontrolled document); frame-realm entries also drop with their realm in the dispose paths.



1260
1261
1262
1263
1264
1265
1266
1267
1268
# File 'lib/capybara/simulated/v8_runtime.rb', line 1260

def module_sw_ctxs
  @module_sw_ctxs ||= {}
  key = [ctx.object_id, ctx.generation]
  if @module_sw_ctxs_key != key
    @module_sw_ctxs     = {}
    @module_sw_ctxs_key = key
  end
  @module_sw_ctxs
end

#native_module_for(url, inline_src, target, handles) ⇒ Object



1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
# File 'lib/capybara/simulated/v8_runtime.rb', line 1149

def native_module_for(url, inline_src, target, handles)
  return handles[url] if handles.key?(url)
  url_s = url.to_s
  src   = inline_src
  if src.nil? && (sw = module_sw_ctxs[realm_key(target)])
    # Only the graph's ENTRY fetch carries the element's integrity
    # attribute; every other module resolves integrity from the import map
    # (HTML "resolve a module integrity metadata").
    integ = url_s == sw[:root] ? sw[:integrity].to_s : ''
    integ = @browser.importmap_integrity(url_s) if integ.empty?
    r = @browser.sw_script_subresource_fetch(
      sw[:handle],
      url_s,
      sw[:client_id],
      sw[:referrer],
      'script',
      'cors',
      sw[:credentials] || 'same-origin',
      integrity: integ
    )
    return handles[url] = nil if r && r['blocked']   # respondWith failed the load
    src = r && r['body']
  end
  src ||= @browser.rack_fetch_body(url_s)
  return handles[url] = nil unless src
  body = module_body(url_s, src)
  # No-cd warm path: once this isolate has compiled a URL, its in-memory
  # compilation cache holds the bytecode keyed by source — skip
  # `cached_data` so V8 hits that cache directly (~0.04 ms/module)
  # instead of paying the forced kConsumeCodeCache deserialize
  # (~0.15 ms/module). The first compile of each URL goes through the
  # on-disk bytecode cache and warms it. The in-memory cache is
  # source-keyed and re-populated by every compile, so a changed body
  # or a GC-aged-out entry costs ONE re-parse and is warm again — no
  # sticky cliff. (Only the on-disk blob for a changed body stays
  # unwarmed; acceptable, module URLs here are fingerprinted-
  # immutable.) Realms share the isolate's cache, so the tracking
  # applies to frame-realm compiles too. On a cold rebuild
  # `@compiled_module_urls` is cleared and everything returns to the
  # `cached_data` path.
  if inline_src.nil? && @compiled_module_urls.key?(url_s)
    m = target.compile_module(body, filename: url_s)
  else
    sha     = Digest::SHA256.hexdigest(body)
    version = self.class.cached_data_version_tag
    cached  = ScriptCache.lookup(sha, version, kind: :module)
    m       = target.compile_module(body, filename: url_s, cached_data: cached)
    if cached.nil? || m.cache_rejected?
      ScriptCache.queue_warm(target, sha, url_s, body, version, kind: :module, stale: !cached.nil?)
    end
    @compiled_module_urls[url_s] = true if inline_src.nil?
  end
  handles[url] = m
rescue RustyRacer::ParseError => e
  @browser.log_console('error', "module parse error in #{url}: #{e.message}")
  handles[url] = nil
end

#native_module_handlesObject

RustyRacer::Module handles are bound to their realm; both rebuild paths invalidate them. The key carries Ctx#generation because a warm reset keeps the same Ctx OBJECT — object_id alone can't see it.



1139
1140
1141
1142
1143
1144
1145
1146
1147
# File 'lib/capybara/simulated/v8_runtime.rb', line 1139

def native_module_handles
  @native_module_handles ||= {}
  key = [ctx.object_id, ctx.generation]
  if @native_module_handles_key != key
    @native_module_handles     = {}
    @native_module_handles_key = key
  end
  @native_module_handles
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.



559
560
561
562
# File 'lib/capybara/simulated/v8_runtime.rb', line 559

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

#opaque_frame_url?(url) ⇒ Boolean

A frame whose document URL is opaque — about:blank (empty src), about:srcdoc, or empty — has no scope of its own; its controller is inherited from the creator. (create_frame_realm receives 'about:blank'/'about:srcdoc' as the url for these, seeded by __csimFrameWindow.)

Returns:

  • (Boolean)


815
816
817
818
# File 'lib/capybara/simulated/v8_runtime.rb', line 815

def opaque_frame_url?(url)
  u = url.to_s
  u.empty? || u.start_with?('about:')
end

#realm_call(realm_id, name, *args) ⇒ Object

Route a host-fn call into a specific frame realm's context — or the main context when realm_id is nil/0. Each frame realm is a full bridge with its OWN handle registry + document, so a node / query op on a frame node (a within_frame body) must execute in that realm; running it in the main context would dereference the handle against the wrong registry. Callers (Browser#dom_call) gate on frame_realm_alive? first, so a disposed realm surfaces as a stale element rather than silently mis-resolving against the main registry.



364
365
366
367
368
369
370
371
# File 'lib/capybara/simulated/v8_runtime.rb', line 364

def realm_call(realm_id, name, *args)
  return call(name, *args) if realm_id.nil? || realm_id.zero?
  fr = frame_realms[realm_id]
  return call(name, *args) unless fr
  result = fr.call(name, *args)
  ScriptCache.warm_pending!
  result
end

#realm_key(target) ⇒ Object



1270
1271
1272
# File 'lib/capybara/simulated/v8_runtime.rb', line 1270

def realm_key(target)
  target.equal?(ctx) ? 0 : target.id
end

#realm_module_handles(realm_id) ⇒ Object

Per-realm module-handle caches, keyed by realm id (Module handles are context-bound). Shared by the realm's static __csim_evalEsmEntry and the isolate resolver's dynamic-import routing; dropped with the realm in the dispose paths.



1251
1252
1253
# File 'lib/capybara/simulated/v8_runtime.rb', line 1251

def realm_module_handles(realm_id)
  (@realm_module_handles ||= {})[realm_id] ||= {}
end

#rebuild_ctxObject

Brings up a snapshot-fresh realm for the next page via the warm path: Context#reset swaps in a brand-new global on the long-lived isolate — a FULL fresh realm, not a partial in-context reset (those are unsafe per feedback_visit_always_rebuilds: library init guards stick, delegates leak) — keeping the isolate's in-memory compilation cache + tiered-up code warm across visits (measured −4.5..19% suite wall). Only a refused reset falls back to the cold route: dispose the isolate and build a fresh one (synchronously, on this thread).



577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
# File 'lib/capybara/simulated/v8_runtime.rb', line 577

def rebuild_ctx
  # Produce any queued bytecode-cache blobs while every queued target
  # (frame realms included) is still alive — a job queued by the last
  # activity of a test (e.g. a timer-fired dynamic import in a lazy
  # frame) would otherwise compile against a disposed context and be
  # dropped, leaving the disk cache permanently cold for that body.
  ScriptCache.warm_pending!
  # Drop the previous page's iframe realms (a new visit = new nested
  # browsing contexts). Explicit — under warm-compile the isolate
  # survives, so nothing else would ever release them.
  dispose_frame_realms
  # Warm path: per rusty's reset contract the snapshot is REPLAYED —
  # including its precompiled code cache — so re-visited app modules
  # compile at in-memory-hit cost (~3.3× cheaper than a cold
  # `cached_data` deserialize; see `@compiled_module_urls`). Host fns,
  # module handles (invalidated via `Ctx#generation`), and every
  # post-snapshot `c.eval` died with the old realm — re-seed exactly
  # as `build_ctx` does after `Ctx.new`. A refused reset (mid-drain /
  # suspended request — can't happen from these top-level call sites,
  # but the contract reserves it, e.g. after a watchdog terminate
  # wedges a nested rendezvous) falls back to the cold rebuild below —
  # loudly, because a persistent fallback is an invisible perf cliff
  # (and log_console is trace-gated, nil during reset!).
  if @ctx
    begin
      @ctx.reset
      # `@ctx.reset` swaps in a snapshot-fresh realm and `dispose_frame_realms`
      # above tore down the visit's iframe realms — but V8 keeps those dead
      # contexts as RECLAIMABLE GARBAGE: a per-frame realm (within_frame /
      # `Isolate#create_context`) is a large GC root that V8's incremental
      # GC won't collect between visits. Over a long run (esp. iframe-heavy
      # pages) they pile toward the old-space cap, where the near-heap-limit
      # GC thrashes instead of reclaiming. A full GC under pressure drops the
      # used heap back to baseline (measured: ~450 MB -> ~150 MB, native
      # contexts N -> 1). See `relieve_heap_pressure`.
      relieve_heap_pressure
      attach_host_fns(@ctx)
      @ctx.eval_void('__csim_installWorker();')
      return @ctx
    rescue StandardError => e
      warn "[capybara-simulated] warm context reset failed, falling back to cold rebuild: #{e.class}: #{e.message}"
      @browser.log_console('warn', "warm context reset failed, falling back to full rebuild: #{e.message}")
    end
  end
  old = @ctx
  @ctx = nil
  # The cold rebuild brings up a *different* isolate, whose in-memory
  # compilation cache is cold — drop the no-cd tracking so the next
  # visit goes back through the on-disk bytecode-cache path.
  @compiled_module_urls.clear
  @compiled_script_keys.clear
  # Tear the old isolate down synchronously, on this (the only) thread
  # that ever drove it. Each isolate is created, used, and disposed on
  # the main thread — never dispatched to from a second thread (see
  # `ctx`), which rusty_racer's thread-confined isolates require. This
  # cold path is only the rare reset-failure fallback, so the inline
  # teardown isn't on the steady-state path.
  dispose_ctx(old) if old
  @ctx = build_and_track_ctx
end

#relieve_heap_pressureObject

Forced full GC when V8-managed memory (used heap + external) crosses GC_PRESSURE_MB. The stat read is cheap (a counter snapshot every visit); the GC itself only fires once a multi-visit spec has actually piled up dead realms — measured at ~once per 25-50 iframe-heavy visits, reclaiming native contexts back to 1 and the heap to baseline — so the amortized cost is negligible while memory stays bounded. No-op when disabled (GC_PRESSURE_MB <= 0).



664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
# File 'lib/capybara/simulated/v8_runtime.rb', line 664

def relieve_heap_pressure
  return unless GC_PRESSURE_MB.positive?
  s    = @ctx.heap_statistics
  over = (s[:used_heap_size].to_i + s[:external_memory].to_i) > GC_PRESSURE_MB * 1_048_576
  if HEAP_DIAG
    warn(format('[csim heap] used=%dMB ext=%dMB native_ctx=%d over=%s',
                s[:used_heap_size].to_i >> 20, s[:external_memory].to_i >> 20,
                s[:number_of_native_contexts].to_i, over))
  end
  return unless over
  @ctx.low_memory_notification
  if HEAP_DIAG
    a = @ctx.heap_statistics
    warn(format('[csim heap]   -> after GC used=%dMB native_ctx=%d',
                a[:used_heap_size].to_i >> 20, a[:number_of_native_contexts].to_i))
  end
rescue StandardError
  # A transient read failure — heap relief is best-effort, never fail a
  # visit over it.
end

#reload_frame_realm(old_id, parent_id, url, body, content_type, client_id = nil) ⇒ Object

Frame-scoped navigation: tear down the realm old_id and build a fresh one for the same <iframe> from the just-fetched document, returning the new realm's context id. A new context (not an in-place document reset) is the right model — it drops the prior frame document's timers / listeners / module state, exactly like the main page's per-visit rebuild. parent_id keeps the new realm's parent/top wired to the owning realm. The Browser then re-points the iframe element at the new id (__csimRebindFrameRealm).



464
465
466
467
468
469
470
471
472
# File 'lib/capybara/simulated/v8_runtime.rb', line 464

def reload_frame_realm(old_id, parent_id, url, body, content_type, client_id = nil)
  # A re-navigated document discards its child browsing contexts, so dispose the old realm's
  # DESCENDANT frame realms too — not just old_id. The JS src-reassignment path gets this for
  # free (the old document's iframe elements go away → DOM-unregister disposes their realms);
  # the Ruby reload path (navigate_realm_self_get/_post) rebuilds without that DOM teardown, so
  # a descendant frame's realm would otherwise linger and its contentWindow stay live.
  dispose_frame_realm_tree(old_id)
  create_frame_realm(ctx, url, body, content_type, parent_id, nil, nil, nil, nil, nil, nil, client_id)
end

#reload_window_realm(old_id, url, body, content_type) ⇒ Object

Navigate a window realm (win.location = …): a FRESH realm for the new document, like reload_frame_realm — an in-place __csimLoadDocument on an already-loaded realm does NOT re-run inline scripts, but a fresh realm does. Returns the new realm's context id. (The opener's WindowProxy, keyed by the old id, goes stale across this — acceptable while no window.open test scripts the window after navigating it; a stable WindowProxy is future work.)



487
488
489
490
491
492
493
494
495
496
497
# File 'lib/capybara/simulated/v8_runtime.rb', line 487

def reload_window_realm(old_id, url, body, content_type)
  meta = window_realm_meta[old_id] || {}
  # Like reload_frame_realm, a re-navigated window discards its child browsing contexts — dispose
  # the descendant frame realms too, not just old_id, so a popup's iframes don't linger.
  dispose_frame_realm_tree(old_id)
  create_window_realm(
    url, body, content_type,
    opener_id: meta[:opener_id], window_name: meta[:window_name],
    about_base: meta[:about_base], about_origin: meta[:about_origin]
  )
end

#reseed_realm_js(c) ⇒ Object

A fresh per-frame realm boots from the snapshot, so every globalThis.… assignment csim ran post-snapshot in build_ctx is missing (realm state). Re-seed the __csim_yield alias and the __csim_installWorker() post-snapshot init; the __csim_runScript dispatcher comes from attach_run_script_with_cache (realm-bound).



1472
1473
1474
1475
# File 'lib/capybara/simulated/v8_runtime.rb', line 1472

def reseed_realm_js(c)
  c.eval_void("globalThis.__csim_yield = globalThis.#{HOST_NAMESPACE_NAME}.drainMicrotasks;")
  c.eval_void('__csim_installWorker();')
end

#reset_pageObject

Capybara calls Driver#reset! between tests; Browser delegates here. With per-visit rebuild already running, the inter-test path is the same operation.



641
# File 'lib/capybara/simulated/v8_runtime.rb', line 641

def reset_page = rebuild_ctx

#reset_timersObject



564
565
566
567
# File 'lib/capybara/simulated/v8_runtime.rb', line 564

def reset_timers
  return if @ctx.nil?
  ctx.call('__resetTimers')
end

#run_loop_step(max_ms, max_iter = 10_000, yield_on_gen: false) ⇒ Object

One event-loop step (task → microtask-checkpoint → render). Returns the { 'fired', 'gen', 'dirtied' } hash — dirtied (settleGen changed during the step) is the authoritative find-cache-invalidation signal, since a render-phase rAF / microtask-delivered MutationObserver can mutate the DOM without firing a timer (fired == 0).



407
408
409
410
411
412
# File 'lib/capybara/simulated/v8_runtime.rb', line 407

def run_loop_step(max_ms, max_iter = 10_000, yield_on_gen: false)
  # `__runLoopStep` steps child iframe realms itself (timers.js
  # `drainChildRealms`), folding their fired/dirtied into the result.
  r = ctx.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

#seed_realm_bridge(realm) ⇒ Object

Bring a freshly-created realm context up to a runnable bridge, shared by the frame and window realm constructors. Re-evaling the snapshot source would redefine snapshot globals (e.g. the scrollX accessor) and throw, so only eval it on a bare no-snapshot dev ctx where the realm boots empty. The replayed __csim_runScriptCached / __csim_evalEsmEntry close over the MAIN ctx they were first attached to, so a realm script routing through them (leading-lexical, ≥64KB, or type=module) would run against the main document — rebind realm-executing variants on top, then reseed per-realm JS.



828
829
830
831
832
833
834
835
# File 'lib/capybara/simulated/v8_runtime.rb', line 828

def seed_realm_bridge(realm)
  has_bridge = realm.eval("typeof __csimLoadDocument === 'function'")
  realm.eval_void(RuntimeShared.snapshot_src) unless has_bridge
  attach_run_script_with_cache(realm)
  attach_realm_esm_entry(realm)
  reseed_realm_js(realm)
  realm
end

#settle_genObject



547
548
549
# File 'lib/capybara/simulated/v8_runtime.rb', line 547

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

#supports_frames?Boolean

Is realm_id a live frame realm? A frame removed / re-navigated mid-block disposes its realm (__csim_disposeFrameRealm) while the Browser's @current_realm_id may still point at it; the Browser uses this to raise a stale-element instead of running a frame handle op against the main registry. Per-frame browsing contexts (nested realms for iframes / aux windows) are a V8-engine feature; QuickJS keeps a same-realm fallback. within_frame gates on this.

Returns:

  • (Boolean)


380
# File 'lib/capybara/simulated/v8_runtime.rb', line 380

def supports_frames? = true

#top_level_realm?(realm_id) ⇒ Boolean

Is this realm a TOP-LEVEL browsing context? The main realm is, and so is an auxiliary window realm — frame_realm_parents records 0 for one because it has no parent FRAME, which is not the same as being nested in the main window. An ancestor walk (the focus chain, most of all) has to stop here rather than step into the opener.

Returns:

  • (Boolean)


444
# File 'lib/capybara/simulated/v8_runtime.rb', line 444

def top_level_realm?(realm_id) = realm_id.to_i.zero? || window_realm_meta.key?(realm_id.to_i)

#window_realm_metaObject

Per-window-realm metadata (opener id + window.name) captured at create time so a window's self-navigation (which builds a fresh realm via reload_window_realm) can carry them across, the way a real popup keeps window.opener / window.name through its own navigation.



438
# File 'lib/capybara/simulated/v8_runtime.rb', line 438

def window_realm_meta = (@window_realm_meta ||= {})

#wrap_binary(bytes) ⇒ Object

Raw bytes pass through as-is: rusty marshals tag-driven — a BINARY-encoded Ruby String crosses as a JS Uint8Array (and Uint8Array/ArrayBuffer args come back as BINARY Strings) — one copy, no base64 / latin1 string inflation. transfer_buffer_fetch already returns ASCII-8BIT-tagged bytes.



543
544
545
# File 'lib/capybara/simulated/v8_runtime.rb', line 543

def wrap_binary(bytes)
  bytes
end