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.
- checksums.yaml +4 -4
- data/README.md +303 -158
- data/lib/capybara/simulated/asset_cache.rb +232 -0
- data/lib/capybara/simulated/browser.rb +3409 -845
- data/lib/capybara/simulated/driver.rb +341 -134
- data/lib/capybara/simulated/errors.rb +9 -5
- data/lib/capybara/simulated/js/bridge.bundle.js +19409 -0
- data/lib/capybara/simulated/js/snapshot_stubs.js +110 -0
- data/lib/capybara/simulated/node.rb +151 -163
- data/lib/capybara/simulated/quickjs_runtime.rb +424 -0
- data/lib/capybara/simulated/runtime_shared.rb +183 -0
- data/lib/capybara/simulated/script_cache.rb +168 -0
- data/lib/capybara/simulated/sourcemap.rb +119 -0
- data/lib/capybara/simulated/stack_resolver.rb +97 -0
- data/lib/capybara/simulated/trace.rb +111 -0
- data/lib/capybara/simulated/v8_runtime.rb +987 -0
- data/lib/capybara/simulated/version.rb +3 -1
- data/lib/capybara/simulated/webauthn_state.rb +367 -0
- data/lib/capybara/simulated/whitespace_normalizer.rb +45 -0
- data/lib/capybara/simulated/worker_runtime.rb +30 -0
- data/lib/capybara/simulated.rb +31 -4
- data/lib/capybara-simulated.rb +2 -0
- data/vendor/js/vendor.bundle.js +13 -0
- metadata +24 -32
- data/vendor/esbuild-wasm/LICENSE.md +0 -21
- data/vendor/esbuild-wasm/bin/esbuild +0 -91
- data/vendor/esbuild-wasm/esbuild.wasm +0 -0
- data/vendor/esbuild-wasm/lib/main.js +0 -2337
- data/vendor/esbuild-wasm/wasm_exec.js +0 -575
- data/vendor/esbuild-wasm/wasm_exec_node.js +0 -40
- data/vendor/js/bundle-modules.mjs +0 -168
- data/vendor/js/csim.bundle.js +0 -91560
- data/vendor/js/entry.mjs +0 -23
- data/vendor/js/prelude.js +0 -190
- 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
|