capybara-simulated 0.0.7 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +303 -158
  3. data/lib/capybara/simulated/asset_cache.rb +232 -0
  4. data/lib/capybara/simulated/browser.rb +3409 -845
  5. data/lib/capybara/simulated/driver.rb +341 -134
  6. data/lib/capybara/simulated/errors.rb +9 -5
  7. data/lib/capybara/simulated/js/bridge.bundle.js +19409 -0
  8. data/lib/capybara/simulated/js/snapshot_stubs.js +110 -0
  9. data/lib/capybara/simulated/node.rb +151 -163
  10. data/lib/capybara/simulated/quickjs_runtime.rb +424 -0
  11. data/lib/capybara/simulated/runtime_shared.rb +183 -0
  12. data/lib/capybara/simulated/script_cache.rb +168 -0
  13. data/lib/capybara/simulated/sourcemap.rb +119 -0
  14. data/lib/capybara/simulated/stack_resolver.rb +97 -0
  15. data/lib/capybara/simulated/trace.rb +111 -0
  16. data/lib/capybara/simulated/v8_runtime.rb +987 -0
  17. data/lib/capybara/simulated/version.rb +3 -1
  18. data/lib/capybara/simulated/webauthn_state.rb +367 -0
  19. data/lib/capybara/simulated/whitespace_normalizer.rb +45 -0
  20. data/lib/capybara/simulated/worker_runtime.rb +30 -0
  21. data/lib/capybara/simulated.rb +31 -4
  22. data/lib/capybara-simulated.rb +2 -0
  23. data/vendor/js/vendor.bundle.js +13 -0
  24. metadata +24 -32
  25. data/vendor/esbuild-wasm/LICENSE.md +0 -21
  26. data/vendor/esbuild-wasm/bin/esbuild +0 -91
  27. data/vendor/esbuild-wasm/esbuild.wasm +0 -0
  28. data/vendor/esbuild-wasm/lib/main.js +0 -2337
  29. data/vendor/esbuild-wasm/wasm_exec.js +0 -575
  30. data/vendor/esbuild-wasm/wasm_exec_node.js +0 -40
  31. data/vendor/js/bundle-modules.mjs +0 -168
  32. data/vendor/js/csim.bundle.js +0 -91560
  33. data/vendor/js/entry.mjs +0 -23
  34. data/vendor/js/prelude.js +0 -190
  35. data/vendor/js/runtime.js +0 -2208
@@ -0,0 +1,987 @@
1
+ # frozen_string_literal: true
2
+
3
+ # V8 runtime on rusty_racer. The DOM lives in JS; this class owns the
4
+ # V8 isolate/context pair, the warm snapshot, the host-fn callbacks the
5
+ # bridge reaches back through, and the per-visit `rebuild_ctx` dance.
6
+ #
7
+ # `QuickJSRuntime` is the alternate implementation; both expose the same
8
+ # surface (`eval` / `call` / `drain_timers` / `drain_microtasks` /
9
+ # `settle_gen` / `has_ready_timer?` / `reset_timers` / `rebuild_ctx` /
10
+ # `reset_page`). Browser picks one at construction.
11
+
12
+ require 'digest'
13
+ require 'fileutils'
14
+ require 'rusty_racer'
15
+
16
+ require_relative 'runtime_shared'
17
+ require_relative 'script_cache'
18
+ require_relative 'worker_runtime'
19
+
20
+
21
+ begin
22
+ stack_kb = (ENV['CSIM_V8_STACK_KB'] || '2000').to_i
23
+ RustyRacer::Platform.set_flags!(stack_size: stack_kb)
24
+ # Default V8 old-space cap is ~1.4 GB, which OOMs on workloads that
25
+ # marshal large pixel buffers across postMessage (Discourse's
26
+ # media-optimization-worker hands a 317 MB raw RGBA frame from an
27
+ # 8900×8900 fixture through the transfer-buffer path). Match
28
+ # Discourse's own testem flag of 4 GB so the test fits.
29
+ max_old_mb = (ENV['CSIM_V8_MAX_OLD_SPACE_MB'] || '4096').to_i
30
+ RustyRacer::Platform.set_flags!('max-old-space-size': max_old_mb) if max_old_mb > 0
31
+ # `CSIM_V8_PROF=1` turns on V8's tick-sampling profiler. Output
32
+ # lands in `isolate-*-v8.log`; process with:
33
+ # node --prof-process isolate-*-v8.log > prof.txt
34
+ # (Standard Node distribution ships the post-processor; no extra
35
+ # install needed.) The log is per-isolate, and warm-compile keeps one
36
+ # isolate for the whole run, so expect a single aggregate log.
37
+ if ENV['CSIM_V8_PROF'] == '1'
38
+ RustyRacer::Platform.set_flags!(:prof, 'logfile-per-isolate': nil)
39
+ end
40
+ # `CSIM_V8_FLAGS` passes arbitrary V8 flags through to
41
+ # `set_flags_from_string` for perf experiments (JIT tier-up tuning,
42
+ # GC, lite-mode). Whitespace-separated; each token is `--`-prefixed by
43
+ # rusty's `set_flags!`, so write them WITHOUT the leading dashes:
44
+ # CSIM_V8_FLAGS='jitless' -> --jitless
45
+ # CSIM_V8_FLAGS='sparkplug no-turbofan' -> --sparkplug --no-turbofan
46
+ # CSIM_V8_FLAGS='max-opt=1' -> --max-opt=1
47
+ # Flags may interact with the cached snapshot's compiled-code state, so
48
+ # pair a sweep with `CSIM_SNAPSHOT_CACHE=off`.
49
+ if (raw = ENV['CSIM_V8_FLAGS'].to_s.strip) && !raw.empty?
50
+ flags = raw.split(/\s+/).map {|f| f.sub(/\A--/, '') }
51
+ RustyRacer::Platform.set_flags!(*flags)
52
+ end
53
+ rescue RustyRacer::PlatformAlreadyInitialized
54
+ end
55
+
56
+ module Capybara
57
+ module Simulated
58
+ class V8Runtime
59
+
60
+ @@snapshot_lock = Mutex.new
61
+ @@snapshot = nil
62
+ @@live_lock = Mutex.new
63
+ @@live = []
64
+
65
+ at_exit do
66
+ @@live_lock.synchronize {
67
+ @@live.each {|c|
68
+ begin
69
+ c.terminate rescue nil
70
+ c.dispose
71
+ rescue StandardError
72
+ end
73
+ }
74
+ @@live.clear
75
+ }
76
+ end
77
+
78
+ # The host namespace rusty_racer installs into every context (main and
79
+ # per-frame): `globalThis.RustyRacer.drainMicrotasks()` (a native,
80
+ # rendezvous-free microtask checkpoint), `contextGlobal(id)` /
81
+ # `contextOf(value)` (the per-frame realm machinery), and
82
+ # `setPromiseRejectHandler`. The bridge JS hard-codes the same
83
+ # `globalThis.RustyRacer` literal (timers.js / platform-globals.js /
84
+ # unhandled-rejection.js / bridge.entry.js) — a rename must touch both
85
+ # sides.
86
+ HOST_NAMESPACE_NAME = 'RustyRacer'
87
+
88
+ # One isolate + its default context, presented as a single handle — the
89
+ # shape the rest of the runtime (and `ScriptCache`) passes around.
90
+ # rusty splits the VM into an `Isolate` (lifecycle / realms / microtasks /
91
+ # terminate) and the `Context`s it hands out (eval / call / attach /
92
+ # compile / reset); this class pairs them and replays recorded host-fn
93
+ # attaches onto per-frame realm contexts (rusty's attach is per-context).
94
+ class Ctx
95
+ def initialize(snapshot: nil, timeout: 0)
96
+ @iso = RustyRacer::Isolate.new(host_namespace: HOST_NAMESPACE_NAME,
97
+ snapshot: snapshot,
98
+ timeout_ms: timeout.to_i)
99
+ @ctx = @iso.context
100
+ @attached = []
101
+ @generation = 0
102
+ end
103
+
104
+ # ── Context surface ─────────────────────────────────────────
105
+ # rusty drains microtasks at call-depth zero (V8's default kAuto
106
+ # policy), so a returned eval/call has already run its end-of-script
107
+ # microtasks.
108
+ def eval(src) = @ctx.eval(src)
109
+ def call(name, *args) = @ctx.call(name, *args)
110
+
111
+ # Record every attach so `create_context` can replay them: the bridge
112
+ # in a per-frame realm reaches the same Ruby host fns as the main
113
+ # context, but rusty's attach is per-context.
114
+ def attach(name, prc)
115
+ @attached << [name, prc]
116
+ @ctx.attach(name, prc)
117
+ end
118
+
119
+ # One rendezvous for the whole host-fn table (vs one per fn).
120
+ def attach_many(fns)
121
+ @attached.concat(fns.to_a)
122
+ @ctx.attach_many(fns)
123
+ end
124
+
125
+ # Bumped on every realm reset: realm-bound caches (module handles)
126
+ # key off `[object_id, generation]` so invalidation is intrinsic to
127
+ # reset — the Ctx OBJECT survives a warm reset, so object_id alone
128
+ # can't detect one.
129
+ attr_reader :generation
130
+
131
+ # Swap the realm for a snapshot-fresh one on the warm isolate. Per
132
+ # rusty's contract the host fns die with the old context — drop the
133
+ # replay record so the caller's re-attach doesn't accumulate stale
134
+ # entries visit over visit.
135
+ def reset
136
+ @ctx.reset
137
+ @attached.clear
138
+ @generation += 1
139
+ end
140
+
141
+ def compile(src, **kw) = @ctx.compile(src, **kw)
142
+ def compile_module(src, **kw) = @ctx.compile_module(src, **kw)
143
+
144
+ # ── Isolate surface ─────────────────────────────────────────
145
+ def terminate = @iso.terminate
146
+ def dispose = @iso.dispose
147
+ def perform_microtask_checkpoint = @iso.perform_microtask_checkpoint
148
+
149
+ def dynamic_import_resolver=(prc)
150
+ @iso.dynamic_import_resolver = prc
151
+ end
152
+
153
+ # A per-iframe realm: a fresh context in the SAME isolate (shared heap,
154
+ # own global + intrinsics). Carries `.id` / eval / call / dispose — the
155
+ # rest of the surface `create_frame_realm` needs.
156
+ #
157
+ # `to_h` dedups re-attached names to their latest proc, matching
158
+ # attach's override semantics. NOTE: context-bound fns
159
+ # (`__csim_runScript*`, `__csim_evalEsmEntry`) get realm-bound
160
+ # overrides in `create_frame_realm` after this replay.
161
+ def create_context
162
+ realm = @iso.create_context
163
+ realm.attach_many(@attached.to_h)
164
+ realm
165
+ end
166
+ end
167
+
168
+ def self.snapshot
169
+ @@snapshot_lock.synchronize { @@snapshot ||= build_snapshot }
170
+ end
171
+
172
+ # Pre-warm script: exercises the JS surfaces that get JIT-compiled
173
+ # on every page load (HTML parse, selector tokenise + match, event
174
+ # dispatch, style-decl parse, cascade resolve). Runs once at
175
+ # snapshot creation; the resulting compiled-code state ships in
176
+ # the snapshot so each new context starts with these paths warm.
177
+ # (`Snapshot#warmup!` follows the V8 WarmUpSnapshotDataBlob contract:
178
+ # the warmup runs in a throwaway context — only code, no heap state,
179
+ # survives into the blob.)
180
+ SNAPSHOT_WARMUP = <<~JS.freeze
181
+ (function () {
182
+ // Drive a representative document through parse → script
183
+ // eval → selector / event / cascade primitives so the
184
+ // bytecode cache covers them when a real visit hits.
185
+ const html = '<!doctype html><html><head><style>' +
186
+ '.a { display: none } .a.show { display: block }' +
187
+ '#m, .b > .c { visibility: hidden }' +
188
+ '@media (max-width: 899px) { .b { display: none } }' +
189
+ '</style></head><body>' +
190
+ '<div id="m" class="a"><span class="b"><a class="c" href="/x">x</a></span></div>' +
191
+ '<form><input name="q" type="text" value="hi"><button type="submit">go</button></form>' +
192
+ '<script>document.querySelector("#m");</script>' +
193
+ '</body></html>';
194
+ try { __csimLoadDocument(html); } catch (_) {}
195
+ try { __csimEvaluateXPath('//a', 0); } catch (_) {}
196
+ try { __csimVisible(1); } catch (_) {}
197
+ try { __csimQuery(0, '#m'); } catch (_) {}
198
+ try { __csimQuery(0, '.b > .c'); } catch (_) {}
199
+ try {
200
+ const root = document.documentElement;
201
+ if (root) {
202
+ root.querySelectorAll('a');
203
+ root.querySelectorAll('.b > .c, #m');
204
+ }
205
+ } catch (_) {}
206
+ })();
207
+ JS
208
+
209
+ # `Snapshot.new(source)` is non-deterministic — V8 embeds
210
+ # transient allocator state in the produced bytes, so the same
211
+ # source yields different blobs across runs. V8's bytecode-cache
212
+ # validation (`ScriptCompiler::CompileUnboundScript` with
213
+ # `kConsumeCodeCache`) keys on snapshot bytes, so re-`new`-ing in
214
+ # each process makes cross-process `ScriptCache` hits get
215
+ # rejected. Building once and persisting the dump fixes that:
216
+ # every process boots off byte-identical snapshot bytes and
217
+ # `cached_data` accepts.
218
+ def self.build_snapshot
219
+ cache_path = snapshot_cache_path
220
+ return build_snapshot_uncached unless cache_path
221
+ begin
222
+ FileUtils.mkdir_p(File.dirname(cache_path))
223
+ # Serialize concurrent cold boots (parallel test workers):
224
+ # `Snapshot.new` is non-deterministic, so two processes racing the
225
+ # build would persist different bytes and every ScriptCache entry
226
+ # keyed to the loser's blob gets `cache_rejected` forever after.
227
+ # One process builds under the lock; the rest load its bytes.
228
+ File.open("#{cache_path}.lock", File::CREAT | File::RDWR) do |lock|
229
+ lock.flock(File::LOCK_EX)
230
+ if (bytes = read_verified_snapshot(cache_path))
231
+ return RustyRacer::Snapshot.load(bytes)
232
+ end
233
+ snap = build_snapshot_uncached
234
+ # Persist + reload so this process also boots from the same
235
+ # bytes other processes will load — the produce-side snapshot
236
+ # must equal the consume-side snapshot for `cached_data` to
237
+ # accept (see the build_snapshot header rationale).
238
+ bytes = snap.dump
239
+ persist_snapshot_bytes(bytes, cache_path)
240
+ return RustyRacer::Snapshot.load(bytes)
241
+ end
242
+ rescue StandardError
243
+ # Cache plumbing must never break boot; fall back to an
244
+ # in-process build (we just lose the cross-process savings).
245
+ build_snapshot_uncached
246
+ end
247
+ end
248
+
249
+ def self.build_snapshot_uncached
250
+ snap = RustyRacer::Snapshot.new(RuntimeShared.snapshot_src)
251
+ # `warmup!` runs `SNAPSHOT_WARMUP` once in a throwaway context and
252
+ # keeps the resulting compiled code, so contexts created from this
253
+ # snapshot inherit JIT-primed versions of the hot paths above.
254
+ snap.warmup!(SNAPSHOT_WARMUP) rescue nil
255
+ snap
256
+ end
257
+
258
+ # `Snapshot.load` doesn't validate — corrupt bytes surface as a V8
259
+ # FATAL abort at the first `Isolate.new`, long past any rescue here.
260
+ # Verify against the SHA sidecar written at persist time, so a
261
+ # truncated / corrupted blob rebuilds instead of crash-looping every
262
+ # subsequent run.
263
+ def self.read_verified_snapshot(path)
264
+ return nil unless File.exist?(path)
265
+ bytes = File.binread(path)
266
+ sha = File.read("#{path}.sha256").strip
267
+ Digest::SHA256.hexdigest(bytes) == sha ? bytes : nil
268
+ rescue StandardError
269
+ nil
270
+ end
271
+
272
+ def self.snapshot_cache_path
273
+ return nil if ENV['CSIM_SNAPSHOT_CACHE'].to_s.casecmp('off').zero?
274
+ dir = ENV['CSIM_SNAPSHOT_CACHE_DIR'] ||
275
+ File.join(ENV['HOME'] || '/tmp', '.cache', 'capybara-simulated', 'snapshot')
276
+ sha = Digest::SHA256.hexdigest(RuntimeShared.snapshot_src + SNAPSHOT_WARMUP)
277
+ tag = cached_data_version_tag
278
+ File.join(dir, "#{tag}-#{sha[0, 16]}.bin")
279
+ end
280
+
281
+ def self.persist_snapshot_bytes(bytes, path)
282
+ tmp = "#{path}.#{Process.pid}.tmp"
283
+ File.binwrite(tmp, bytes)
284
+ File.write("#{path}.sha256", Digest::SHA256.hexdigest(bytes))
285
+ File.rename(tmp, path)
286
+ prune_snapshot_cache(path)
287
+ rescue StandardError
288
+ # Best-effort: snapshot rebuild on every process is fine,
289
+ # we just lose the cross-process startup savings.
290
+ end
291
+
292
+ # A multi-MB blob per bridge edit / V8 upgrade accrues forever
293
+ # otherwise; only the current key is ever loadable again, so drop
294
+ # the rest.
295
+ def self.prune_snapshot_cache(current)
296
+ keep = File.basename(current)
297
+ Dir.glob(File.join(File.dirname(current), '*.bin')).each do |f|
298
+ next if File.basename(f) == keep
299
+ FileUtils.rm_f([f, "#{f}.sha256", "#{f}.lock"])
300
+ end
301
+ rescue StandardError
302
+ end
303
+
304
+ def initialize(browser)
305
+ @browser = browser
306
+ @ctx = nil
307
+ # Every context is built from the base snapshot (bridge +
308
+ # vendor bundle). Library scripts (`<script src>`) get evaluated
309
+ # per-visit just like a real browser does on page navigation.
310
+ # Pre-evaluating libraries into the snapshot heap is not safe:
311
+ # jQuery's `readyList` Callbacks queue would carry `$(handler)`
312
+ # registrations from a prior page's scripts, and a single
313
+ # throwing handler (e.g. touching a DOM node that only existed
314
+ # on the prior page) aborts iteration mid-fire and silently
315
+ # drops every later callback — including the current page's.
316
+ @snapshot = self.class.snapshot
317
+ # `@compiled_module_urls` / `@compiled_script_keys` track what this
318
+ # isolate has already compiled, for the no-cd paths in
319
+ # `native_module_for` / `attach_run_script_with_cache`. They persist
320
+ # across warm realm resets (same isolate, warm in-memory compilation
321
+ # cache) and are cleared only on a true rebuild (different isolate,
322
+ # cold cache).
323
+ @compiled_module_urls = {}
324
+ @compiled_script_keys = {}
325
+ end
326
+
327
+ def eval(code) = ctx.eval(code.to_s)
328
+ def call(name, *args)
329
+ result = ctx.call(name, *args)
330
+ ScriptCache.warm_pending!
331
+ result
332
+ end
333
+
334
+ # bridge.js owns the virtual clock; Ruby still drives it because
335
+ # Capybara's polling cadence is wall-clock-anchored. Use `call`
336
+ # (function reference) rather than `eval` (string compile) — the
337
+ # polling loop hits these every retry tick.
338
+ def drain_timers(max_ms = nil)
339
+ # The bridge's `__drainTimers`/`__runLoopStep` step iframe realms' event
340
+ # loops themselves (timers.js `drainChildRealms`), so this one call covers
341
+ # child frames too — no separate Ruby-side fan-out (which would
342
+ # double-advance their clocks and fire intervals twice).
343
+ max_ms.nil? ? ctx.call('__drainTimers') : ctx.call('__drainTimers', max_ms.to_i)
344
+ end
345
+
346
+ # One event-loop step (task → microtask-checkpoint → render). Returns the
347
+ # `{ 'fired', 'gen', 'dirtied' }` hash — `dirtied` (settleGen changed during
348
+ # the step) is the authoritative find-cache-invalidation signal, since a
349
+ # render-phase rAF / microtask-delivered MutationObserver can mutate the DOM
350
+ # without firing a timer (fired == 0).
351
+ def run_loop_step(max_ms, max_iter = 10_000, yield_on_gen: false)
352
+ # `__runLoopStep` steps child iframe realms itself (timers.js
353
+ # `drainChildRealms`), folding their fired/dirtied into the result.
354
+ r = ctx.call('__runLoopStep', max_ms.to_i, max_iter.to_i, !!yield_on_gen)
355
+ r.is_a?(Hash) ? r : { 'fired' => 0, 'gen' => 0, 'dirtied' => false }
356
+ end
357
+
358
+ # Per-iframe realms (`Isolate#create_context`): a separate V8 context —
359
+ # own global + intrinsics (Function/Error/DOMParser/onerror) — per
360
+ # nested browsing context, so cross-realm tests behave per spec. Keyed
361
+ # by context id; released explicitly by `dispose_frame_realms` on every
362
+ # rebuild — under warm-compile the isolate survives the visit, so
363
+ # nothing else would ever free them.
364
+ def frame_realms = (@frame_realms ||= {})
365
+
366
+ def dispose_frame_realms
367
+ @realm_module_handles&.clear
368
+ return if @frame_realms.nil?
369
+ @frame_realms.each_value {|fr| fr.dispose rescue nil }
370
+ @frame_realms.clear
371
+ end
372
+
373
+ # One native microtask checkpoint — a checkpoint runs the queue until
374
+ # empty, and rusty already performs one at the end of every top-level
375
+ # eval/call (V8's default kAuto policy), so a single explicit checkpoint
376
+ # is all `settle` needs to advance chained `await`/`.then` queues
377
+ # between ticks.
378
+ def drain_microtasks
379
+ @ctx&.perform_microtask_checkpoint
380
+ end
381
+
382
+ # Raw bytes pass through as-is: rusty marshals tag-driven — a
383
+ # BINARY-encoded Ruby String crosses as a JS Uint8Array (and
384
+ # Uint8Array/ArrayBuffer args come back as BINARY Strings) — one copy,
385
+ # no base64 / latin1 string inflation. `transfer_buffer_fetch` already
386
+ # returns ASCII-8BIT-tagged bytes.
387
+ def wrap_binary(bytes)
388
+ bytes
389
+ end
390
+
391
+ def settle_gen
392
+ ctx.call('__settleGenGet').to_i
393
+ end
394
+
395
+ def has_ready_timer?
396
+ return false if @ctx.nil?
397
+ !!ctx.call('__hasReadyTimer')
398
+ end
399
+
400
+ # Delay (ms) until the nearest scheduled timer relative to the virtual
401
+ # clock, or -1 if none. Drives the horizon-gated fast-forward in
402
+ # `Browser#tick_real_time`.
403
+ def next_timer_delay_ms
404
+ return -1 if @ctx.nil?
405
+ ctx.call('__nextTimerDelay').to_i
406
+ end
407
+
408
+ def reset_timers
409
+ return if @ctx.nil?
410
+ ctx.call('__resetTimers')
411
+ end
412
+
413
+ # Brings up a snapshot-fresh realm for the next page via the warm path:
414
+ # `Context#reset` swaps in a brand-new global on the long-lived isolate —
415
+ # a FULL fresh realm, not a partial in-context reset (those are unsafe per
416
+ # feedback_visit_always_rebuilds: library init guards stick, delegates
417
+ # leak) — keeping the isolate's in-memory compilation cache + tiered-up
418
+ # code warm across visits (measured −4.5..19% suite wall). Only a refused
419
+ # reset falls back to the cold route: dispose the isolate and build a
420
+ # fresh one (synchronously, on this thread).
421
+ def rebuild_ctx
422
+ # Produce any queued bytecode-cache blobs while every queued target
423
+ # (frame realms included) is still alive — a job queued by the last
424
+ # activity of a test (e.g. a timer-fired dynamic import in a lazy
425
+ # frame) would otherwise compile against a disposed context and be
426
+ # dropped, leaving the disk cache permanently cold for that body.
427
+ ScriptCache.warm_pending!
428
+ # Drop the previous page's iframe realms (a new visit = new nested
429
+ # browsing contexts). Explicit — under warm-compile the isolate
430
+ # survives, so nothing else would ever release them.
431
+ dispose_frame_realms
432
+ # Warm path: per rusty's reset contract the snapshot is REPLAYED —
433
+ # including its precompiled code cache — so re-visited app modules
434
+ # compile at in-memory-hit cost (~3.3× cheaper than a cold
435
+ # `cached_data` deserialize; see `@compiled_module_urls`). Host fns,
436
+ # module handles (invalidated via `Ctx#generation`), and every
437
+ # post-snapshot `c.eval` died with the old realm — re-seed exactly
438
+ # as `build_ctx` does after `Ctx.new`. A refused reset (mid-drain /
439
+ # suspended request — can't happen from these top-level call sites,
440
+ # but the contract reserves it, e.g. after a watchdog terminate
441
+ # wedges a nested rendezvous) falls back to the cold rebuild below —
442
+ # loudly, because a persistent fallback is an invisible perf cliff
443
+ # (and log_console is trace-gated, nil during reset!).
444
+ if @ctx
445
+ begin
446
+ @ctx.reset
447
+ attach_host_fns(@ctx)
448
+ @ctx.eval('__csim_installWorker();')
449
+ return @ctx
450
+ rescue StandardError => e
451
+ warn "[capybara-simulated] warm context reset failed, falling back to cold rebuild: #{e.class}: #{e.message}"
452
+ @browser.log_console('warn', "warm context reset failed, falling back to full rebuild: #{e.message}")
453
+ end
454
+ end
455
+ old = @ctx
456
+ @ctx = nil
457
+ # The cold rebuild brings up a *different* isolate, whose in-memory
458
+ # compilation cache is cold — drop the no-cd tracking so the next
459
+ # visit goes back through the on-disk bytecode-cache path.
460
+ @compiled_module_urls.clear
461
+ @compiled_script_keys.clear
462
+ # Tear the old isolate down synchronously, on this (the only) thread
463
+ # that ever drove it. Each isolate is created, used, and disposed on
464
+ # the main thread — never dispatched to from a second thread (see
465
+ # `ctx`), which rusty_racer's thread-confined isolates require. This
466
+ # cold path is only the rare reset-failure fallback, so the inline
467
+ # teardown isn't on the steady-state path.
468
+ if old
469
+ @@live_lock.synchronize { @@live.delete(old) }
470
+ begin
471
+ old.terminate rescue nil
472
+ old.dispose
473
+ rescue StandardError
474
+ end
475
+ end
476
+ @ctx = build_and_track_ctx
477
+ end
478
+
479
+ # Capybara calls `Driver#reset!` between tests; Browser delegates
480
+ # here. With per-visit rebuild already running, the inter-test
481
+ # path is the same operation.
482
+ def reset_page = rebuild_ctx
483
+
484
+ # Built lazily on first use, on the calling (main) thread. There is no
485
+ # pool / background pre-warm: under warm-compile the steady-state visit
486
+ # reuses this one isolate via `Context#reset` (rebuild_ctx) and never
487
+ # builds another, so a pool's async pre-warm bought nothing — and a pool
488
+ # dispatched to its entries from a refill thread before the main thread
489
+ # used them, migrating an isolate's caller thread. Building here keeps
490
+ # every isolate confined to one thread for its whole life. The one-time
491
+ # synchronous build is ~3 ms.
492
+ def ctx
493
+ @ctx ||= build_and_track_ctx
494
+ end
495
+
496
+ # build_ctx + register for at_exit cleanup.
497
+ def build_and_track_ctx
498
+ c = build_ctx
499
+ @@live_lock.synchronize { @@live << c }
500
+ c
501
+ end
502
+
503
+ # Per-call wall-clock cap (ms). Off by default. Opt in via
504
+ # `CSIM_V8_CALL_TIMEOUT_MS=30000` for long-running suites where an
505
+ # occasional JS-side infinite loop would otherwise stall the whole
506
+ # run; the timeout converts the hang into a
507
+ # `RustyRacer::ScriptTerminatedError` on that one example. The
508
+ # terminate escalates through any nested frames (it is
509
+ # isolate-global by design), and the isolate itself stays healthy
510
+ # for subsequent calls — csim treats a terminated call as fatal to
511
+ # that call only. The clean slate comes from the next rebuild: a
512
+ # warm `Context#reset` normally, or — if the terminate wedged a
513
+ # suspended request and reset is refused — the loud cold-rebuild
514
+ # fallback in `rebuild_ctx`.
515
+ CALL_TIMEOUT_MS = (ENV['CSIM_V8_CALL_TIMEOUT_MS'] || '0').to_i
516
+
517
+ # V8's bytecode-cache version tag. Keys every ScriptCache entry so a
518
+ # V8 upgrade invalidates stale bytecode. Fixed per process → memoized.
519
+ def self.cached_data_version_tag
520
+ return @cached_data_version_tag if defined?(@cached_data_version_tag)
521
+ @cached_data_version_tag = RustyRacer.cached_data_version_tag
522
+ end
523
+
524
+ def build_ctx
525
+ c = Ctx.new(snapshot: @snapshot || self.class.snapshot, timeout: CALL_TIMEOUT_MS)
526
+ attach_host_fns(c)
527
+ c.eval('__csim_installWorker();')
528
+ c
529
+ end
530
+
531
+
532
+ def attach_host_fns(c)
533
+ self.class.attach_host_fns(c, @browser)
534
+ attach_run_script_with_cache(c)
535
+ attach_native_module_loader(c)
536
+ attach_frame_realm_loader(c)
537
+ end
538
+
539
+ # The bridge calls `__csim_createFrameRealm(url, body, contentType)` (from
540
+ # `iframe.contentWindow`'s getter) to spin up a real per-iframe realm. This
541
+ # runs re-entrantly inside the main ctx's eval — rusty services nested
542
+ # requests while a host callback is in flight. Returns the realm's context
543
+ # id (or nil on failure — then the bridge keeps its same-realm fallback).
544
+ # The bridge maps `iframe.contentWindow` to `RustyRacer.contextGlobal(id)`.
545
+ def attach_frame_realm_loader(c)
546
+ c.attach('__csim_createFrameRealm', ->(url, body, content_type) {
547
+ RuntimeShared.safe_call { create_frame_realm(c, url, body, content_type) }
548
+ })
549
+ # Re-navigating an iframe (src/srcdoc reassigned) builds a fresh realm;
550
+ # the bridge calls this to tear down the superseded one so it doesn't
551
+ # linger in @frame_realms and get re-drained on every poll tick.
552
+ # Disposing a non-executing child realm mid-callback is safe.
553
+ c.attach('__csim_disposeFrameRealm', ->(id) {
554
+ @realm_module_handles&.delete(id)
555
+ fr = frame_realms.delete(id)
556
+ fr.dispose rescue nil if fr
557
+ nil
558
+ })
559
+ end
560
+
561
+ # Build the iframe's realm: a snapshot-built isolate replays the whole
562
+ # bridge into every new context automatically, so the realm already has
563
+ # `document` / `DOMParser` / the event loop; re-seed the post-snapshot JS
564
+ # state, point it at its own URL with the top frame as parent/top, then
565
+ # load its document (running its scripts in the realm). Tracked for
566
+ # event-loop draining + teardown.
567
+ def create_frame_realm(parent_ctx, url, body, content_type)
568
+ realm = parent_ctx.create_context
569
+ # Re-evaling the snapshot source would redefine snapshot globals (e.g.
570
+ # the `scrollX` accessor) and throw — re-entrantly. Only eval the
571
+ # source on a bare no-snapshot dev ctx, where the realm boots empty.
572
+ # Host fns are replayed onto the realm by `Ctx#create_context`.
573
+ has_bridge = realm.eval("typeof __csimLoadDocument === 'function'")
574
+ realm.eval(RuntimeShared.snapshot_src) unless has_bridge
575
+ # The replayed `__csim_runScriptCached` / `__csim_runScriptEval` /
576
+ # `__csim_evalEsmEntry` close over the context they EXECUTE in (the
577
+ # main ctx) — left as-is, a frame script that routes through them
578
+ # (leading-lexical, ≥64KB, or `type=module`) would run against the
579
+ # PARENT realm's document. Rebind realm-executing variants on top.
580
+ attach_run_script_with_cache(realm)
581
+ attach_realm_esm_entry(realm)
582
+ reseed_realm_js(realm)
583
+ # Wire parent/top to the main realm (context id 0). No user data in
584
+ # this eval — only the literal namespace.
585
+ realm.eval(<<~JS)
586
+ if (globalThis.#{HOST_NAMESPACE_NAME} && typeof globalThis.#{HOST_NAMESPACE_NAME}.contextGlobal === 'function') {
587
+ var __topWin = globalThis.#{HOST_NAMESPACE_NAME}.contextGlobal(0);
588
+ globalThis.parent = __topWin; globalThis.top = __topWin;
589
+ }
590
+ JS
591
+ # Pass the URL + document body as call ARGUMENTS, not interpolated into
592
+ # an eval string: the marshaller carries them losslessly, so arbitrary
593
+ # HTML / control bytes survive (Ruby's String#inspect is NOT a faithful
594
+ # JS string escaper — it mangles \a, \e, and binary bytes).
595
+ realm.call('__csimUpdateLocation', url.to_s) unless url.to_s.empty?
596
+ realm.call('__csimLoadDocument', body.to_s, content_type.to_s)
597
+ frame_realms[realm.id] = realm
598
+ realm.id
599
+ rescue StandardError => e
600
+ @browser.log_console('warn', "frame realm load failed: #{e.message}")
601
+ # A realm created before the failure (load threw) is untracked — not in
602
+ # frame_realms nor __csimChildRealmIds — so nothing would ever drain or
603
+ # dispose it. Tear it down here (safe: it's non-executing in the rescue),
604
+ # including any module handles its scripts compiled before the throw.
605
+ if realm
606
+ @realm_module_handles&.delete(realm.id)
607
+ realm.dispose rescue nil
608
+ end
609
+ nil
610
+ end
611
+
612
+ # `target` is the context the module graph compiles + evaluates in,
613
+ # `handles` its module-handle cache — the main ctx + `native_module_handles`
614
+ # by default, or a frame realm + its realm-local cache (Module handles are
615
+ # context-bound; sharing the main cache would link a frame's imports
616
+ # against main-context modules).
617
+ def eval_esm_module(url, inline_src = nil, target: nil, handles: nil)
618
+ target ||= ctx
619
+ handles ||= native_module_handles
620
+ m = native_module_for(url, inline_src, target, handles)
621
+ return nil unless m
622
+ begin
623
+ instantiate_native_module(m, url, target, handles)
624
+ m.evaluate
625
+ rescue RustyRacer::ParseError, RustyRacer::RuntimeError => e
626
+ # A top-level module throw belongs on the page console
627
+ # (trace-visible diagnostics), like a classic script's error —
628
+ # not just safe_call's truncated stderr warn. ScriptTerminatedError
629
+ # deliberately propagates (a watchdog terminate must escalate).
630
+ @browser.log_console('error', "module evaluate error in #{url}: #{e.message}")
631
+ end
632
+ nil
633
+ end
634
+
635
+ # A `.json` module is exposed as the default export of its parsed value;
636
+ # every other body is the fetched source as-is. Module SOURCE is text,
637
+ # but it arrives as the raw Rack / File.binread body — tagged
638
+ # ASCII-8BIT (see `RuntimeShared.utf8_text`).
639
+ def module_body(url, src)
640
+ src = RuntimeShared.utf8_text(src)
641
+ url.to_s.match?(/\.json(?:\?|$)/) ? "export default #{src};" : src
642
+ end
643
+
644
+ # `RustyRacer::Module` handles are bound to their realm; both rebuild
645
+ # paths invalidate them. The key carries `Ctx#generation` because a
646
+ # warm reset keeps the same Ctx OBJECT — object_id alone can't see it.
647
+ def native_module_handles
648
+ @native_module_handles ||= {}
649
+ key = [ctx.object_id, ctx.generation]
650
+ if @native_module_handles_key != key
651
+ @native_module_handles = {}
652
+ @native_module_handles_key = key
653
+ end
654
+ @native_module_handles
655
+ end
656
+
657
+ def native_module_for(url, inline_src, target, handles)
658
+ return handles[url] if handles.key?(url)
659
+ url_s = url.to_s
660
+ src = inline_src || @browser.rack_fetch_body(url_s)
661
+ return handles[url] = nil unless src
662
+ body = module_body(url_s, src)
663
+ # No-cd warm path: once this isolate has compiled a URL, its in-memory
664
+ # compilation cache holds the bytecode keyed by source — skip
665
+ # `cached_data` so V8 hits that cache directly (~0.04 ms/module)
666
+ # instead of paying the forced kConsumeCodeCache deserialize
667
+ # (~0.15 ms/module). The first compile of each URL goes through the
668
+ # on-disk bytecode cache and warms it. The in-memory cache is
669
+ # source-keyed and re-populated by every compile, so a changed body
670
+ # or a GC-aged-out entry costs ONE re-parse and is warm again — no
671
+ # sticky cliff. (Only the on-disk blob for a changed body stays
672
+ # unwarmed; acceptable, module URLs here are fingerprinted-
673
+ # immutable.) Realms share the isolate's cache, so the tracking
674
+ # applies to frame-realm compiles too. On a cold rebuild
675
+ # `@compiled_module_urls` is cleared and everything returns to the
676
+ # `cached_data` path.
677
+ if inline_src.nil? && @compiled_module_urls.key?(url_s)
678
+ m = target.compile_module(body, filename: url_s)
679
+ else
680
+ sha = Digest::SHA256.hexdigest(body)
681
+ version = self.class.cached_data_version_tag
682
+ cached = ScriptCache.lookup(sha, version, kind: :module)
683
+ m = target.compile_module(body, filename: url_s, cached_data: cached)
684
+ if cached.nil? || m.cache_rejected?
685
+ ScriptCache.queue_warm(target, sha, url_s, body, version, kind: :module, stale: !cached.nil?)
686
+ end
687
+ @compiled_module_urls[url_s] = true if inline_src.nil?
688
+ end
689
+ handles[url] = m
690
+ rescue RustyRacer::ParseError => e
691
+ @browser.log_console('error', "module parse error in #{url}: #{e.message}")
692
+ handles[url] = nil
693
+ end
694
+
695
+ def instantiate_native_module(m, importer_url, target, handles)
696
+ return unless m.status == :uninstantiated
697
+ browser = @browser
698
+ m.instantiate do |specifier, referrer|
699
+ resolved = browser.resolve_module_specifier(specifier, referrer || importer_url)
700
+ child = native_module_for(resolved, nil, target, handles)
701
+ raise "module not found: #{resolved}" unless child
702
+ child
703
+ end
704
+ end
705
+
706
+ # `import('x')` routes through this callback; rusty's native side
707
+ # finishes the dynamic import per the V8 host contract — it
708
+ # instantiates + evaluates the returned Module (TLA-aware, via the
709
+ # evaluation promise) before resolving the outer `import()` promise.
710
+ # The resolver is per-ISOLATE; rusty hands it the INITIATING realm's
711
+ # Context as the third argument, so a frame realm's `import()`
712
+ # compiles + links in that realm with its own handle cache — same
713
+ # realm-correctness as static `<script type=module>` via
714
+ # `attach_realm_esm_entry`.
715
+ def attach_native_module_loader(c)
716
+ c.attach('__csim_evalEsmEntry', ->(url, inline) {
717
+ RuntimeShared.safe_call { eval_esm_module(url, inline) }
718
+ nil
719
+ })
720
+ c.dynamic_import_resolver = ->(specifier, referrer, initiating) {
721
+ target, handles =
722
+ if initiating && initiating.id != 0
723
+ [initiating, realm_module_handles(initiating.id)]
724
+ else
725
+ [ctx, native_module_handles]
726
+ end
727
+ resolved = @browser.resolve_module_specifier(specifier, referrer)
728
+ m = native_module_for(resolved, nil, target, handles)
729
+ raise "module not found: #{resolved}" unless m
730
+ instantiate_native_module(m, resolved, target, handles)
731
+ m
732
+ }
733
+ end
734
+
735
+ # Per-realm module-handle caches, keyed by realm id (Module handles are
736
+ # context-bound). Shared by the realm's static `__csim_evalEsmEntry`
737
+ # and the isolate resolver's dynamic-import routing; dropped with the
738
+ # realm in the dispose paths.
739
+ def realm_module_handles(realm_id)
740
+ (@realm_module_handles ||= {})[realm_id] ||= {}
741
+ end
742
+
743
+ # Frame-document `<script type=module>` entry, bound to the realm.
744
+ def attach_realm_esm_entry(realm)
745
+ realm.attach('__csim_evalEsmEntry', ->(url, inline) {
746
+ RuntimeShared.safe_call {
747
+ eval_esm_module(url, inline, target: realm, handles: realm_module_handles(realm.id))
748
+ }
749
+ nil
750
+ })
751
+ end
752
+
753
+ # Override the JS-side `__csim_runScript` fallback with a Ruby host fn
754
+ # that bytecode-caches each script body in a process-wide hash + on-disk
755
+ # store (`Context#compile` + `Script#cached_data`). Discourse's main
756
+ # chunk is ~140 ms of parse + JIT per visit otherwise; the cache reduces
757
+ # it to a deserialize + run path. Worker isolates run on their own
758
+ # threads — `compile` from the main thread against a Worker isolate is
759
+ # unsafe — so the class-level `attach_host_fns` (used by `build_worker`)
760
+ # intentionally skips this attach.
761
+ # V8's bytecode cache only pays off above a body-size threshold
762
+ # — the rendezvous round-trip + Ruby-side SHA256 + compile +
763
+ # dispose runs ~150–300 µs, while `(0, eval)(body)` at V8
764
+ # globalThis for a tiny script is sub-microsecond. Above the
765
+ # threshold, V8 parse + JIT cold-path is multiple ms — worth
766
+ # the cache. Redmine's jQuery + Stimulus inline scripts
767
+ # (median ~400 B) dominated the regression: pre-threshold,
768
+ # routing every snippet through Ruby blew the 122-test suite
769
+ # from 56 s → 224 s. Threshold sweep:
770
+ #
771
+ # threshold | Redmine wall
772
+ # 1 KB | 143 s
773
+ # 8 KB | 103 s
774
+ # 32 KB | 90 s
775
+ # 64 KB | 62 s ← baseline parity
776
+ #
777
+ # 64 KB keeps Discourse's main Ember chunk (140 KB+) on the
778
+ # cache path while Stimulus / Trix / etc. shorts stay on the
779
+ # JS-only fast path. `CSIM_SCRIPT_CACHE_MIN_BYTES=0` forces
780
+ # the cache for everything (debug / cross-process bench).
781
+ SCRIPT_CACHE_MIN_BYTES = (ENV['CSIM_SCRIPT_CACHE_MIN_BYTES'] || '65536').to_i
782
+
783
+ def attach_run_script_with_cache(c)
784
+ version_tag = self.class.cached_data_version_tag
785
+ debug = ENV['CSIM_SCRIPT_CACHE_DEBUG']
786
+ # Big bodies → Ruby-side bytecode cache. The dispatcher below
787
+ # routes small bodies to a JS-only `(0, eval)` so they don't
788
+ # pay the rendezvous round-trip.
789
+ c.attach('__csim_runScriptCached', ->(label, body) {
790
+ RuntimeShared.safe_call {
791
+ # Trailing `;undefined` suppresses the script's COMPLETION VALUE so
792
+ # `script.run`'s return crosses the V8→Ruby boundary on the trivial
793
+ # marshalling fast path. Without it, a large inline script ending
794
+ # in a jQuery-ish expression returns a `ce.fn.init` (array-like,
795
+ # non-cloneable) that drags through the deep-copy filter —
796
+ # pure waste, since the value is discarded (`nil` below). The SHA keys
797
+ # the bytecode cache on the COMPILED source, so the suffix must be
798
+ # hashed and fed to `queue_warm` too (else cached_data is rejected).
799
+ src = "#{body}\n;undefined"
800
+ # No-cd warm path, mirroring `native_module_for`: once this
801
+ # isolate has compiled a (label, bytesize), re-visits compile
802
+ # straight against V8's source-keyed in-memory cache — skipping
803
+ # the SHA256 of a 140KB+ chunk per visit (rbspy: ~4.8% of the
804
+ # Discourse perf sample was Digest#update) AND the disk lookup.
805
+ # The key is a heuristic, but a false positive only costs a
806
+ # plain recompile of the true source — never wrong code.
807
+ key = [label.to_s, src.bytesize]
808
+ if @compiled_script_keys.key?(key)
809
+ script = c.compile(src, filename: label.to_s)
810
+ else
811
+ sha = Digest::SHA256.hexdigest(src)
812
+ cached = ScriptCache.lookup(sha, version_tag)
813
+ script = c.compile(src, filename: label.to_s, cached_data: cached)
814
+ $stderr.puts "[runScript] label=#{label.to_s[0,60]} hit=#{!cached.nil?} rejected=#{script.cache_rejected?}" if debug
815
+ # V8 forbids `produce_cache: true` from inside a host-fn
816
+ # callback so we queue misses + rejects for top-level
817
+ # produce via `ScriptCache.warm_pending!` after the
818
+ # current `V8Runtime#call` returns.
819
+ if cached.nil? || script.cache_rejected?
820
+ ScriptCache.queue_warm(c, sha, label, src, version_tag, stale: !cached.nil?)
821
+ end
822
+ @compiled_script_keys[key] = true if key
823
+ end
824
+ begin
825
+ script.run
826
+ ensure
827
+ script.dispose
828
+ end
829
+ }
830
+ nil
831
+ })
832
+ # Small bodies normally run JS-side via `(0, eval)(body)` — fast,
833
+ # no Ruby↔V8 boundary. But `(0, eval)` block-scopes a script's
834
+ # top-level `const`/`let`/`class` to the eval, so they vanish
835
+ # instead of landing in the realm's *shared* global lexical
836
+ # environment where a later `<script>` would see them. Real
837
+ # browsers (and our big-body `compile().run` path above) keep
838
+ # them. The shape that needs this is a leading lexical
839
+ # declaration: `<script>const CFG=…</script><script>…use CFG…` and
840
+ # every WPT helper pulled in via `// META: script=` that starts
841
+ # `const TABLE = […]` (sab.js's `createBuffer`, encodings.js's
842
+ # `encodings_table`, …). So route ONLY scripts whose first real
843
+ # statement is a top-level `const`/`let`/`class` through `ctx.eval`
844
+ # (a top-level V8 script → shared lexical env); everything else
845
+ # (IIFEs, `var`/`function` — which already leak to globalThis
846
+ # under `(0, eval)` — and plain calls) stays on the fast path. A
847
+ # later `(0, eval)` script can READ those bindings from the global
848
+ # lexical environment fine; only DEFINING them needed the
849
+ # real-script path. No bytecode cache here — the SHA + compile +
850
+ # dispose is the part that regressed tiny-script-heavy suites
851
+ # (Redmine 56→224 s); plain `ctx.eval` is rendezvous-cheap, and
852
+ # the leading-lexical gate keeps the boundary off the hot path for
853
+ # the ~95% of inline scripts that don't lead with a declaration.
854
+ # Limitation: a top-level `const` that is NOT the first statement
855
+ # (after other top-level code) won't be shared — rare, and the
856
+ # WPT helper corpus + the `<script>const CFG…` pattern both lead
857
+ # with the declaration.
858
+ # NOTE: do NOT wrap in `safe_call`. A JS throw from `c.eval`
859
+ # raises RustyRacer::RuntimeError, which rusty re-raises as a
860
+ # JS exception at the call site — so bridge.entry.js's
861
+ # `try { __csim_runScript(…) } catch (e)` sees it and runs its
862
+ # normal path (console diagnostic, `_ok=false`, fire the script
863
+ # `error` event), exactly as the JS-side `(0, eval)` does and
864
+ # as the QuickJS runner does. Swallowing here would turn a
865
+ # throwing leading-`const` inline script into a silent `load`.
866
+ c.attach('__csim_runScriptEval', ->(label, body) {
867
+ # Trailing `;undefined` makes the script's COMPLETION VALUE undefined so
868
+ # `c.eval`'s return crosses the V8→Ruby boundary on the trivial
869
+ # marshalling fast path. Without it, a leading-lexical inline script
870
+ # ending in a jQuery-ish expression (`const cfg=…; $(…)`) returns a
871
+ # `ce.fn.init` (array-like, non-cloneable) here, which falls into the
872
+ # deep-copy filter slow-path — pure waste, since the value
873
+ # is discarded (`nil` below). The `//# sourceURL` line is a comment and
874
+ # doesn't affect the completion value; lexical declarations persist as a
875
+ # side effect of eval, independent of the completion value.
876
+ c.eval("#{body}\n;undefined\n//# sourceURL=#{label.to_s.tr("\n", ' ')}")
877
+ nil
878
+ })
879
+ install_run_script_dispatcher(c)
880
+ end
881
+
882
+ # The JS-side `__csim_runScript` dispatcher routes each inline-script body
883
+ # to the bytecode-cache path, the shared-lexical `ctx.eval` path, or the
884
+ # JS-only `(0, eval)` fast path. It snapshots the CURRENT
885
+ # `__csim_runScriptCached` / `__csim_runScriptEval` host fns, so it must
886
+ # run after the attaches it captures (`attach_run_script_with_cache`
887
+ # installs it last for exactly that reason).
888
+ def install_run_script_dispatcher(c)
889
+ c.eval(<<~JS)
890
+ (function () {
891
+ const cached = globalThis.__csim_runScriptCached;
892
+ const runEval = globalThis.__csim_runScriptEval;
893
+ const threshold = #{SCRIPT_CACHE_MIN_BYTES};
894
+ // Leading top-level lexical declaration, after optional BOM /
895
+ // whitespace / line+block comments / a "use strict" prologue.
896
+ const LEADS_LEXICAL = /^[\\s\\uFEFF]*(?:(?:\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/)\\s*)*(?:["']use strict["'];?\\s*)?(?:export\\s+)?(?:const|let|class)[\\s{\\[]/;
897
+ // A "use strict" directive prologue. A classic <script> evaluates as a
898
+ // top-level Script, where top-level `var` / `function` declarations
899
+ // bind on the global object even in strict mode — but the JS-only
900
+ // `(0, eval)(body)` fast path runs them as an INDIRECT eval, and a
901
+ // strict indirect eval gets its OWN variable environment, so those
902
+ // declarations never reach globalThis (a later <script> can't see
903
+ // them). Route strict-prologue scripts through the real top-level
904
+ // `ctx.eval` path too, same as leading lexical declarations.
905
+ const LEADS_USE_STRICT = /^[\\s\\uFEFF]*(?:(?:\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/)\\s*)*["']use strict["']/;
906
+ globalThis.__csim_runScript = function (label, body) {
907
+ if (body.length >= threshold) return cached(label, body);
908
+ if (LEADS_LEXICAL.test(body) || LEADS_USE_STRICT.test(body)) return runEval(label || 'csim-eval', body);
909
+ (0, eval)(body + '\\n//# sourceURL=' + (label || 'csim-eval'));
910
+ };
911
+ })();
912
+ JS
913
+ end
914
+
915
+ # A fresh per-frame realm boots from the snapshot, so every
916
+ # `globalThis.…` assignment csim ran *post-snapshot* in `build_ctx` is
917
+ # missing (realm state). Re-seed the `__csim_yield` alias and the
918
+ # `__csim_installWorker()` post-snapshot init; the `__csim_runScript`
919
+ # dispatcher comes from `attach_run_script_with_cache` (realm-bound).
920
+ def reseed_realm_js(c)
921
+ c.eval("globalThis.__csim_yield = globalThis.#{HOST_NAMESPACE_NAME}.drainMicrotasks;")
922
+ c.eval('__csim_installWorker();')
923
+ end
924
+
925
+ # Class-level attach so Worker isolates (Ruby-thread-owned
926
+ # contexts that don't have a Runtime instance wrapping them)
927
+ # reuse the same `BROWSER_HOST_FNS` + `STDLIB_HOST_FNS` table
928
+ # the main runtime wires up.
929
+ def self.attach_host_fns(c, browser)
930
+ fns = {}
931
+ RuntimeShared::BROWSER_HOST_FNS.each {|name, body|
932
+ fns[name] = ->(*a) { RuntimeShared.safe_call { body.call(browser, *a) } }
933
+ }
934
+ fns.update(RuntimeShared::STDLIB_HOST_FNS)
935
+ # One rendezvous for the whole table (~50 fns) — this runs per cold
936
+ # build, per worker, and per warm realm reset.
937
+ c.attach_many(fns)
938
+ # `dispatchEventForUserAction` calls `__csim_yield` between listener
939
+ # invocations to match HTML spec "clean up after running script"
940
+ # microtask-checkpoint semantics. Alias it to the namespace's native
941
+ # in-isolate checkpoint so callers pay ~sub-µs instead of an
942
+ # attached-fn cross-thread round-trip.
943
+ c.eval("globalThis.__csim_yield = globalThis.#{HOST_NAMESPACE_NAME}.drainMicrotasks;")
944
+ # Register the bridge's recorder for V8's promise-reject notifications
945
+ # — the channel that surfaces rejections NO handler ever sees
946
+ # (fire-and-forget async functions, bare `Promise.reject`); the
947
+ # bridge's `.then`-wrap can't observe those. Post-snapshot: the host
948
+ # namespace doesn't exist while the snapshot is built, which is why
949
+ # unhandled-rejection.js leaves registration to us. Main realm only —
950
+ # the recorder routes per-realm via `contextGlobal` itself, and a
951
+ # frame-realm registration would dangle once that realm is disposed.
952
+ c.eval(<<~JS)
953
+ if (typeof globalThis.#{HOST_NAMESPACE_NAME}.setPromiseRejectHandler === 'function' &&
954
+ typeof globalThis.__csimPromiseRejected === 'function') {
955
+ globalThis.#{HOST_NAMESPACE_NAME}.setPromiseRejectHandler(globalThis.__csimPromiseRejected);
956
+ }
957
+ JS
958
+ end
959
+
960
+ # Worker-isolate factory: fresh isolate from the shared
961
+ # snapshot, host fns attached, `__csim_isWorker` flag set, +
962
+ # the per-worker postMessage host fn closed over `post_back`.
963
+ # Returns a uniform `WorkerRuntime` adapter that
964
+ # `Browser#run_worker` drives.
965
+ def self.build_worker(browser, post_back)
966
+ c = Ctx.new(snapshot: snapshot)
967
+ attach_host_fns(c, browser)
968
+ c.attach('__csim_workerPostMessage', ->(data) { post_back.call(data); nil })
969
+ # Worker's timer table is independent from main's; routing the
970
+ # worker's `setTimersActive` through `browser.timers_active=`
971
+ # races the main isolate's polling? gate, dropping main-thread
972
+ # pending XHRs the moment the worker's queue empties. The settle
973
+ # loop already polls `worker_pending?` for worker thread activity.
974
+ c.attach('__setTimersActive', ->(_flag) { nil })
975
+ c.eval('__csim_installWorkerScope();')
976
+ WorkerRuntime.new(
977
+ eval_fn: ->(s) { c.eval(s.to_s) },
978
+ call_fn: ->(n, *a) { c.call(n.to_s, *a) },
979
+ drain_microtasks: -> { c.perform_microtask_checkpoint },
980
+ drain_timers: -> { c.call('__drainTimers', 50) },
981
+ has_ready_timer: -> { !!c.call('__hasReadyTimer') },
982
+ dispose: -> { c.dispose rescue nil }
983
+ )
984
+ end
985
+ end
986
+ end
987
+ end