rusty_racer 0.2.3 → 0.2.4
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 +2 -1
- data/ext/rusty_racer/Cargo.toml +6 -2
- data/ext/rusty_racer/src/lib.rs +315 -80
- data/ext/rusty_racer/src/ops.rs +44 -3
- data/lib/rusty_racer/version.rb +1 -1
- data/lib/rusty_racer.rb +16 -7
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bc72e8a18f16fc78197991e4e29ff01ba94afb83ae26d3ea162903654f485dae
|
|
4
|
+
data.tar.gz: 06a00da38cb052af3a29fa772f93fa14f3e8f267f8a7c88723aad827f908a3d7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3e86f16c51dbe2b1096a7710ea105da2f95f68b640f9a3265f63b85ffcd6f630fb9642305a3afd88da1a559ce50f1238b4b1ff8c8e573d1c8ab20bfd684e03f0
|
|
7
|
+
data.tar.gz: aa1020630d83aa95c83837c8d1aa14bd3e56a207d955de314892f1c8e247a3519ad7101f9b6d6f1a9dab8f3f535c92bb59ab11cd342245be8f777bbdb21a60d3
|
data/README.md
CHANGED
|
@@ -217,7 +217,8 @@ warm isolate — a per-visit reset that avoids rebuilding the VM. Its contract:
|
|
|
217
217
|
(or to an empty realm, with no snapshot).
|
|
218
218
|
- **Runtime mutations are dropped.** Anything set on the realm at runtime is gone.
|
|
219
219
|
- **Host fns are dropped.** Functions `attach`/`attach_many`'d into the realm are
|
|
220
|
-
released
|
|
220
|
+
released — the isolate stops holding them, so whatever they captured becomes
|
|
221
|
+
collectable; re-attach them after a reset.
|
|
221
222
|
- **Modules and classic scripts are dropped.** Handles compiled in the realm die
|
|
222
223
|
with the old context.
|
|
223
224
|
- **The realm id and the shared same-origin token are preserved** — the id keeps
|
data/ext/rusty_racer/Cargo.toml
CHANGED
|
@@ -5,12 +5,16 @@
|
|
|
5
5
|
# links and loads with no custom archive.
|
|
6
6
|
[package]
|
|
7
7
|
name = "rusty_racer"
|
|
8
|
-
version = "0.2.
|
|
8
|
+
version = "0.2.4"
|
|
9
9
|
edition = "2024"
|
|
10
10
|
publish = false
|
|
11
11
|
|
|
12
|
+
# cdylib: the standalone gem extension (.so Ruby loads directly).
|
|
13
|
+
# lib (rlib): lets an embedder link rusty_racer as a Rust library and build its
|
|
14
|
+
# own cdylib — capybara-simulated links rusty_racer + a native DOM into one
|
|
15
|
+
# extension and calls rusty_racer::install_classes from its own magnus init.
|
|
12
16
|
[lib]
|
|
13
|
-
crate-type = ["cdylib"]
|
|
17
|
+
crate-type = ["lib", "cdylib"]
|
|
14
18
|
|
|
15
19
|
[dependencies]
|
|
16
20
|
# rb-sys: exposes magnus::rb_sys (protect — catch a Ruby raise from a host
|
data/ext/rusty_racer/src/lib.rs
CHANGED
|
@@ -31,10 +31,29 @@
|
|
|
31
31
|
// OUTERMOST op cancels a stale terminate so it can't poison the next op, while a
|
|
32
32
|
// nested op's cancel never erases a termination aimed at the suspended outer JS.
|
|
33
33
|
//
|
|
34
|
-
// Attached procs and the dynamic-import resolver are
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
34
|
+
// Attached procs and the dynamic-import resolver are kept alive by the ISOLATE'S
|
|
35
|
+
// OWN ROOTS ARRAY (Core.roots), which every wrapper marks — never by a GC root.
|
|
36
|
+
// A root would be a leak: a host fn closes over its embedder's world, and that
|
|
37
|
+
// world reaches back to this isolate's wrappers, so rooting the proc roots the
|
|
38
|
+
// whole cycle and mark-and-sweep can never break it (the wrapper's free would
|
|
39
|
+
// then never run, and the procs would never be released — one un-disposable
|
|
40
|
+
// isolate per embedder session). Marked from the wrappers instead, the cycle is
|
|
41
|
+
// ordinary Ruby garbage. `mark` PINS each entry (GC.compact must not move them:
|
|
42
|
+
// the copies living in Rust are invisible to the compactor and would go stale),
|
|
43
|
+
// which an RArray's own movable marking would not do.
|
|
44
|
+
//
|
|
45
|
+
// Two things stay OUTSIDE that array, deliberately:
|
|
46
|
+
// - the owner Thread (_owner_root) keeps its rb_gc_register_address, because the
|
|
47
|
+
// guarantee it buys has to outlive the last wrapper: Core::drop compares the
|
|
48
|
+
// raw owner VALUE when no wrapper is left to mark anything, and a freed Thread
|
|
49
|
+
// slot reused by a live thread would be a false owner match (see RootedThread).
|
|
50
|
+
// It is not the cycle above — a Thread reaches back to an isolate only if the
|
|
51
|
+
// embedder parks its world in that thread's thread-local storage.
|
|
52
|
+
// - an isolate whose last wrapper is collected on a thread OTHER than its owner
|
|
53
|
+
// still cannot be disposed (V8 forbids it) and is counted leaked, as it always
|
|
54
|
+
// was — see Core::drop. Collection now happens where it never used to, so that
|
|
55
|
+
// counter reports cases it used to miss entirely; disposing explicitly on the
|
|
56
|
+
// owner thread is still the only way to free a foreign-owned isolate promptly.
|
|
38
57
|
|
|
39
58
|
use std::cell::RefCell;
|
|
40
59
|
use std::collections::HashMap;
|
|
@@ -44,9 +63,10 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
44
63
|
use std::sync::{Arc, Mutex, Once, Weak};
|
|
45
64
|
|
|
46
65
|
use magnus::block::Proc;
|
|
47
|
-
use magnus::value::{BoxValue, ReprValue};
|
|
66
|
+
use magnus::value::{BoxValue, Opaque, ReprValue};
|
|
48
67
|
use magnus::{
|
|
49
|
-
Error, Exception, ExceptionClass, RHash, Ruby, TryConvert,
|
|
68
|
+
DataTypeFunctions, Error, Exception, ExceptionClass, RArray, RHash, Ruby, TryConvert,
|
|
69
|
+
TypedData, Value, function, gc, method, prelude::*,
|
|
50
70
|
};
|
|
51
71
|
|
|
52
72
|
mod marshal;
|
|
@@ -62,10 +82,14 @@ use watchdog::{
|
|
|
62
82
|
|
|
63
83
|
// A Ruby Proc rooted for as long as the Core holds it. BoxValue registers a
|
|
64
84
|
// stable heap address with rb_gc_register_address, which both MARKS the proc
|
|
65
|
-
// (the extension may hold the only reference — e.g. attach("f", -> {...}))
|
|
66
85
|
// and PINS it (GC.compact must not move it: the copies living in Rust are
|
|
67
86
|
// invisible to the compactor and would go stale).
|
|
68
87
|
//
|
|
88
|
+
// Only for a proc the isolate holds TRANSIENTLY — today just the instantiate
|
|
89
|
+
// resolver, parked in the slot for the length of one InstantiateModule op and
|
|
90
|
+
// restored after it. Anything held for the isolate's LIFE goes in the roots
|
|
91
|
+
// array instead (see the module header): a lasting root is a lasting leak.
|
|
92
|
+
//
|
|
69
93
|
// SAFETY of the manual Send: the !Send contents (and BoxValue's drop, which
|
|
70
94
|
// calls rb_gc_unregister_address) only run under the GVL. Two conventions
|
|
71
95
|
// keep that true — breaking either is silent UB, so don't:
|
|
@@ -122,13 +146,14 @@ macro_rules! istate {
|
|
|
122
146
|
pub(crate) use istate;
|
|
123
147
|
|
|
124
148
|
// One attach()'d host fn: the realm it was attached into — so resetting or
|
|
125
|
-
// disposing that realm can release
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
149
|
+
// disposing that realm can release it — whether it is still attached (the slot
|
|
150
|
+
// index stays valid as a host_fn_id either way), and the name it was attached
|
|
151
|
+
// under, kept only so a diagnostic can say which host function is involved (one
|
|
152
|
+
// String per attach, never per call). The PROC itself lives in the roots array
|
|
153
|
+
// at `proc_root_index(id)`, where the GC can see it without taking this lock.
|
|
129
154
|
struct ProcSlot {
|
|
130
155
|
context_id: i32,
|
|
131
|
-
|
|
156
|
+
live: bool,
|
|
132
157
|
name: String,
|
|
133
158
|
}
|
|
134
159
|
|
|
@@ -157,18 +182,33 @@ impl ProcTable {
|
|
|
157
182
|
}
|
|
158
183
|
|
|
159
184
|
// Release every live proc attached into |context_id| (its realm is gone),
|
|
160
|
-
// returning
|
|
161
|
-
//
|
|
162
|
-
|
|
185
|
+
// returning their ids so the caller can clear their roots-array entries (a Ruby
|
|
186
|
+
// write, which this table never does: it is locked on paths a GC must not have to
|
|
187
|
+
// wait behind) and only THEN put them on the free list. Idempotent: an
|
|
188
|
+
// already-released slot is skipped, so it can't be double-freed.
|
|
189
|
+
fn release(&mut self, context_id: i32) -> Vec<usize> {
|
|
190
|
+
let mut released = Vec::new();
|
|
163
191
|
for (id, slot) in self.slots.iter_mut().enumerate() {
|
|
164
|
-
if slot.context_id == context_id && slot.
|
|
165
|
-
slot.
|
|
166
|
-
|
|
192
|
+
if slot.context_id == context_id && slot.live {
|
|
193
|
+
slot.live = false;
|
|
194
|
+
released.push(id);
|
|
167
195
|
}
|
|
168
196
|
}
|
|
197
|
+
released
|
|
169
198
|
}
|
|
170
199
|
}
|
|
171
200
|
|
|
201
|
+
// The isolate's ROOTS ARRAY: every Ruby object the isolate must keep alive for as
|
|
202
|
+
// long as a wrapper of it is reachable, in one place the GC can be shown without
|
|
203
|
+
// taking any Rust lock (a mark that had to lock `procs` could deadlock against
|
|
204
|
+
// the thread whose allocation triggered the GC while holding it). Slot 0 is the
|
|
205
|
+
// dynamic-import resolver; the attached host fns follow, one per host_fn_id.
|
|
206
|
+
const ROOT_IMPORT_RESOLVER: isize = 0;
|
|
207
|
+
const ROOT_FIRST_PROC: isize = 1;
|
|
208
|
+
fn proc_root_index(host_fn_id: usize) -> isize {
|
|
209
|
+
ROOT_FIRST_PROC + host_fn_id as isize
|
|
210
|
+
}
|
|
211
|
+
|
|
172
212
|
// Look up a RustyRacer::<name> exception class at raise time. The classes are
|
|
173
213
|
// defined in lib/rusty_racer.rb (loaded after this extension), so they exist
|
|
174
214
|
// by the time any eval can raise. Falls back to Ruby's RuntimeError.
|
|
@@ -301,9 +341,9 @@ enum RubyCall<'a> {
|
|
|
301
341
|
impl RubyCall<'_> {
|
|
302
342
|
fn describe(&self, core: &Core) -> String {
|
|
303
343
|
match self {
|
|
304
|
-
// try_lock, not lock: nothing holds this across a callback (
|
|
305
|
-
//
|
|
306
|
-
//
|
|
344
|
+
// try_lock, not lock: nothing holds this across a callback (the procs
|
|
345
|
+
// themselves are read from the roots array, without this lock), but
|
|
346
|
+
// blocking here would turn a diagnostic into a hang.
|
|
307
347
|
Self::HostFn(id) => match core
|
|
308
348
|
.procs
|
|
309
349
|
.try_lock()
|
|
@@ -1118,13 +1158,7 @@ fn resolve_imported<'s>(
|
|
|
1118
1158
|
}
|
|
1119
1159
|
}
|
|
1120
1160
|
None => {
|
|
1121
|
-
|
|
1122
|
-
.dynamic_import_resolver
|
|
1123
|
-
.lock()
|
|
1124
|
-
.unwrap()
|
|
1125
|
-
.as_ref()
|
|
1126
|
-
.map(|r| r.get());
|
|
1127
|
-
match resolver {
|
|
1161
|
+
match core.dynamic_import_resolver() {
|
|
1128
1162
|
Some(p) => match with_gvl(core, RubyCall::Resolver(&spec), || {
|
|
1129
1163
|
resolve_module_via_ruby(core, p, &spec, &ref_url, Some(here.unwrap_or(0)))
|
|
1130
1164
|
// Rendering the message reads the Ruby exception, so it too
|
|
@@ -1235,13 +1269,7 @@ fn dynamic_import_cb<'s>(
|
|
|
1235
1269
|
return Some(promise);
|
|
1236
1270
|
}
|
|
1237
1271
|
let core = unsafe { &*core_ptr };
|
|
1238
|
-
let
|
|
1239
|
-
.dynamic_import_resolver
|
|
1240
|
-
.lock()
|
|
1241
|
-
.unwrap()
|
|
1242
|
-
.as_ref()
|
|
1243
|
-
.map(|r| r.get());
|
|
1244
|
-
let id = match resolver_proc {
|
|
1272
|
+
let id = match core.dynamic_import_resolver() {
|
|
1245
1273
|
// A raising resolver only fails the import() (it rejects generically);
|
|
1246
1274
|
// it must NOT abort the surrounding eval, so swallow the Err here.
|
|
1247
1275
|
Some(p) => with_gvl(core, RubyCall::Resolver(&spec), || {
|
|
@@ -1500,10 +1528,16 @@ struct Core {
|
|
|
1500
1528
|
// stack (bootstrap via callback_scope! onto the ambient scope). Bumped around
|
|
1501
1529
|
// each `run`.
|
|
1502
1530
|
depth: std::sync::atomic::AtomicU32,
|
|
1503
|
-
//
|
|
1504
|
-
//
|
|
1505
|
-
//
|
|
1506
|
-
//
|
|
1531
|
+
// Everything this isolate keeps alive on the Ruby side — the attached host fns
|
|
1532
|
+
// and the dynamic-import resolver — in one Array the wrappers MARK (and pin).
|
|
1533
|
+
// See the module header for why these must not be GC roots; ROOT_* for the
|
|
1534
|
+
// layout. Read on the V8 side without the GVL (the import hooks), which is
|
|
1535
|
+
// sound because the entries are pinned and nothing here allocates or raises.
|
|
1536
|
+
roots: Opaque<RArray>,
|
|
1537
|
+
// host_fn_id indexes ProcTable.slots — bookkeeping only (the proc itself is in
|
|
1538
|
+
// `roots`). Mutex (uncontended — single owner thread) so host_fn_callback can
|
|
1539
|
+
// reach it through Core (via the slot's core_ptr) while a &Core method also
|
|
1540
|
+
// holds it. reset/dispose releases the slots and recycles them.
|
|
1507
1541
|
procs: Mutex<ProcTable>,
|
|
1508
1542
|
// Default per-eval/call timeout (ms); 0 = none. eval(timeout_ms:)'s explicit
|
|
1509
1543
|
// value overrides it. Guards against an in-V8 infinite loop without a watchdog.
|
|
@@ -1515,9 +1549,6 @@ struct Core {
|
|
|
1515
1549
|
// the ceiling after each OOM (to this when set, else V8's captured default — see
|
|
1516
1550
|
// oom_initial_limit). Space-axis twin of default_timeout_ms.
|
|
1517
1551
|
memory_limit: usize,
|
|
1518
|
-
// Set by Context#dynamic_import_resolver=; called for a JS import() to map
|
|
1519
|
-
// (specifier, referrer) to an already-loaded Module. GC-rooted like procs.
|
|
1520
|
-
dynamic_import_resolver: Mutex<Option<RootedProc>>,
|
|
1521
1552
|
// The watchdog (armed per timed op, fires TerminateExecution via the handle)
|
|
1522
1553
|
// and its thread's join handle, held here so dispose/Drop — which run on the
|
|
1523
1554
|
// owner thread with no scope — can stop and join it before the isolate drops.
|
|
@@ -1528,7 +1559,8 @@ struct Core {
|
|
|
1528
1559
|
// The V8 isolate (one per Isolate): lifecycle + the isolate-level ops
|
|
1529
1560
|
// (terminate, microtask checkpoint, dynamic import). eval/call/etc. live on
|
|
1530
1561
|
// Context, which an Isolate hands out (a v8::Context).
|
|
1531
|
-
#[
|
|
1562
|
+
#[derive(TypedData)]
|
|
1563
|
+
#[magnus(class = "RustyRacer::Isolate", mark)]
|
|
1532
1564
|
struct Isolate {
|
|
1533
1565
|
core: Arc<Core>,
|
|
1534
1566
|
}
|
|
@@ -1537,7 +1569,8 @@ struct Isolate {
|
|
|
1537
1569
|
// extra one (id >= 1, via Isolate#create_context). eval/call/attach/
|
|
1538
1570
|
// compile_module run here. Its own `disposed` is per-context; the Core's is
|
|
1539
1571
|
// isolate-level.
|
|
1540
|
-
#[
|
|
1572
|
+
#[derive(TypedData)]
|
|
1573
|
+
#[magnus(class = "RustyRacer::Context", mark)]
|
|
1541
1574
|
struct Context {
|
|
1542
1575
|
core: Arc<Core>,
|
|
1543
1576
|
id: i32,
|
|
@@ -1552,7 +1585,8 @@ struct Snapshot {
|
|
|
1552
1585
|
}
|
|
1553
1586
|
|
|
1554
1587
|
// Context#compile_module result: a handle to a V8 module (by id).
|
|
1555
|
-
#[
|
|
1588
|
+
#[derive(TypedData)]
|
|
1589
|
+
#[magnus(class = "RustyRacer::Module", mark)]
|
|
1556
1590
|
struct JsModule {
|
|
1557
1591
|
core: Arc<Core>,
|
|
1558
1592
|
module_id: i32,
|
|
@@ -1564,7 +1598,8 @@ struct JsModule {
|
|
|
1564
1598
|
}
|
|
1565
1599
|
|
|
1566
1600
|
// Context#compile result: a handle to a classic compiled script (by id).
|
|
1567
|
-
#[
|
|
1601
|
+
#[derive(TypedData)]
|
|
1602
|
+
#[magnus(class = "RustyRacer::Script", mark)]
|
|
1568
1603
|
struct Script {
|
|
1569
1604
|
core: Arc<Core>,
|
|
1570
1605
|
script_id: i32,
|
|
@@ -1573,11 +1608,49 @@ struct Script {
|
|
|
1573
1608
|
cache_rejected: bool,
|
|
1574
1609
|
}
|
|
1575
1610
|
|
|
1611
|
+
// The four wrappers that hold an Arc<Core> all mark the isolate's roots, because any
|
|
1612
|
+
// one of them may be the last one reachable: Ruby code is free to keep only the
|
|
1613
|
+
// Context a `Isolate#context` handed it, or only a Module, and the host fns must
|
|
1614
|
+
// stay callable through it. Marking the same array four times costs nothing (the GC
|
|
1615
|
+
// stops at an already-marked object); missing it once would free a live proc.
|
|
1616
|
+
macro_rules! marks_core_roots {
|
|
1617
|
+
($t:ty) => {
|
|
1618
|
+
impl DataTypeFunctions for $t {
|
|
1619
|
+
fn mark(&self, marker: &gc::Marker) {
|
|
1620
|
+
self.core.mark_roots(marker);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
marks_core_roots!(Isolate);
|
|
1626
|
+
marks_core_roots!(Context);
|
|
1627
|
+
marks_core_roots!(JsModule);
|
|
1628
|
+
marks_core_roots!(Script);
|
|
1629
|
+
|
|
1576
1630
|
// Set true once V8 is initialized; Platform.set_flags! refuses after that
|
|
1577
1631
|
// (flags must be set before V8::initialize), like mini_racer's
|
|
1578
1632
|
// PlatformAlreadyInitialized.
|
|
1579
1633
|
static V8_INITED: AtomicBool = AtomicBool::new(false);
|
|
1580
1634
|
|
|
1635
|
+
// The shared default platform, kept so ops can pump its foreground task queue (see
|
|
1636
|
+
// `platform()` / op_pump_message_loop). V8 posts FinalizationRegistry cleanup — and other
|
|
1637
|
+
// deferred foreground work — as platform tasks that only run when the embedder pumps the
|
|
1638
|
+
// message loop; without a handle to the platform we could never drive them. The platform is
|
|
1639
|
+
// process-global and thread-safe by V8's own contract (it is shared across every isolate on
|
|
1640
|
+
// every owner thread), which is what makes the SharedRef sound to park in a `static`.
|
|
1641
|
+
struct SharedPlatform(v8::SharedRef<v8::Platform>);
|
|
1642
|
+
// SAFETY: v8's default platform is explicitly designed to be shared across isolates and their
|
|
1643
|
+
// owner threads; PumpMessageLoop is called per-isolate on that isolate's owner thread. The
|
|
1644
|
+
// SharedRef is a refcounted handle to that one shared object.
|
|
1645
|
+
unsafe impl Send for SharedPlatform {}
|
|
1646
|
+
unsafe impl Sync for SharedPlatform {}
|
|
1647
|
+
static PLATFORM: std::sync::OnceLock<SharedPlatform> = std::sync::OnceLock::new();
|
|
1648
|
+
|
|
1649
|
+
// The shared platform, once V8 is initialized. `None` before init (no op can run then anyway).
|
|
1650
|
+
pub(crate) fn platform() -> Option<&'static v8::SharedRef<v8::Platform>> {
|
|
1651
|
+
PLATFORM.get().map(|p| &p.0)
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1581
1654
|
fn init_v8() {
|
|
1582
1655
|
static ONCE: Once = Once::new();
|
|
1583
1656
|
ONCE.call_once(|| {
|
|
@@ -1590,6 +1663,10 @@ fn init_v8() {
|
|
|
1590
1663
|
Ordering::Relaxed,
|
|
1591
1664
|
);
|
|
1592
1665
|
let platform = v8::new_default_platform(0, false).make_shared();
|
|
1666
|
+
// Keep a handle before handing ownership to V8, so ops can pump the foreground task
|
|
1667
|
+
// queue (FinalizationRegistry cleanup etc.). make_shared yields a refcounted SharedRef;
|
|
1668
|
+
// clone one into the static and give the other to V8.
|
|
1669
|
+
let _ = PLATFORM.set(SharedPlatform(platform.clone()));
|
|
1593
1670
|
v8::V8::initialize_platform(platform);
|
|
1594
1671
|
v8::V8::initialize();
|
|
1595
1672
|
V8_INITED.store(true, Ordering::SeqCst);
|
|
@@ -1927,8 +2004,22 @@ unsafe extern "C" fn promise_reject_cb(message: v8::PromiseRejectMessage) {
|
|
|
1927
2004
|
// reset and create_context so realms can't drift apart. Returns the context
|
|
1928
2005
|
// Global AND its dedicated microtask queue; the caller owns the queue in
|
|
1929
2006
|
// V8State alongside the context (see V8State::queues for why per-realm).
|
|
2007
|
+
// An embedder-supplied hook run in every realm just after the host namespace is
|
|
2008
|
+
// installed, with the fresh realm's scope and context. Set once (OnceLock) by an
|
|
2009
|
+
// embedder that links rusty_racer as a library; None for standalone gem use. A
|
|
2010
|
+
// plain fn pointer so it stays Send + Sync with no allocation.
|
|
2011
|
+
pub type RealmInitHook = fn(&mut v8::PinScope<'_, '_, ()>, &v8::Global<v8::Context>, i32);
|
|
2012
|
+
static REALM_INIT_HOOK: std::sync::OnceLock<RealmInitHook> = std::sync::OnceLock::new();
|
|
2013
|
+
|
|
2014
|
+
// Register the per-realm init hook. Idempotent-ish: the first call wins (later
|
|
2015
|
+
// calls are ignored), which suits a single embedder wiring it once at boot.
|
|
2016
|
+
pub fn set_realm_init_hook(hook: RealmInitHook) {
|
|
2017
|
+
let _ = REALM_INIT_HOOK.set(hook);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
1930
2020
|
fn new_realm(
|
|
1931
2021
|
scope: &mut v8::PinScope<'_, '_, ()>,
|
|
2022
|
+
context_id: i32,
|
|
1932
2023
|
) -> (v8::Global<v8::Context>, v8::UniqueRef<v8::MicrotaskQueue>) {
|
|
1933
2024
|
// Explicit policy like the isolate's: rusty drives every drain by hand
|
|
1934
2025
|
// (auto_drain / NS.drainMicrotasks), so V8 must never auto-run this queue.
|
|
@@ -1980,6 +2071,15 @@ fn new_realm(
|
|
|
1980
2071
|
if let Some(name) = host_namespace {
|
|
1981
2072
|
install_host_namespace(scope, &fresh, &name);
|
|
1982
2073
|
}
|
|
2074
|
+
// Generic, DOM-agnostic extension seam: an embedder that links rusty_racer as a
|
|
2075
|
+
// library can register one hook (set_realm_init_hook) to run native setup in
|
|
2076
|
+
// every realm — including frame realms the engine creates internally. This is
|
|
2077
|
+
// how capybara-simulated installs its native DOM without rusty_racer knowing
|
|
2078
|
+
// anything about a DOM. Extension state lives in the embedder's OWN typed
|
|
2079
|
+
// isolate slot (rusty_v8 slots are keyed by TypeId), never in IsolateState.
|
|
2080
|
+
if let Some(hook) = REALM_INIT_HOOK.get() {
|
|
2081
|
+
hook(scope, &fresh, context_id);
|
|
2082
|
+
}
|
|
1983
2083
|
(fresh, queue)
|
|
1984
2084
|
}
|
|
1985
2085
|
|
|
@@ -2193,13 +2293,13 @@ impl Isolate {
|
|
|
2193
2293
|
// Core keeps its id + a stable raw ptr so `run` can open scopes on it. The
|
|
2194
2294
|
// isolate is thread-bound from here on (every op asserts the owner thread).
|
|
2195
2295
|
fn new(
|
|
2196
|
-
|
|
2296
|
+
ruby: &Ruby,
|
|
2197
2297
|
host_namespace: Option<String>,
|
|
2198
2298
|
snapshot: Option<magnus::typed_data::Obj<Snapshot>>,
|
|
2199
2299
|
timeout_ms: u64,
|
|
2200
2300
|
memory_limit: usize,
|
|
2201
2301
|
explicit_microtasks: bool,
|
|
2202
|
-
) -> Result<Self
|
|
2302
|
+
) -> Result<magnus::typed_data::Obj<Self>, Error> {
|
|
2203
2303
|
init_v8();
|
|
2204
2304
|
// A snapshot blob bakes globalThis state in: the first Context::new (in
|
|
2205
2305
|
// new_realm below) deserializes that default context for free.
|
|
@@ -2245,7 +2345,7 @@ impl Isolate {
|
|
|
2245
2345
|
// namespace from the slot (seeded above).
|
|
2246
2346
|
{
|
|
2247
2347
|
v8::scope!(let scope, &mut isolate);
|
|
2248
|
-
let (main_context, main_queue) = new_realm(scope);
|
|
2348
|
+
let (main_context, main_queue) = new_realm(scope, 0);
|
|
2249
2349
|
istate!(scope).realms.main_context = Some(main_context);
|
|
2250
2350
|
istate!(scope).realms.main_queue = Some(main_queue);
|
|
2251
2351
|
// The shared graveyard for retired realms' contexts (see V8State).
|
|
@@ -2275,6 +2375,10 @@ impl Isolate {
|
|
|
2275
2375
|
// owner check.
|
|
2276
2376
|
use magnus::rb_sys::{AsRawValue, FromRawValue};
|
|
2277
2377
|
let owner_thread = unsafe { Value::from_raw(rb_sys::rb_thread_current()) };
|
|
2378
|
+
// The roots array (see ROOT_IMPORT_RESOLVER): created empty, grown by the
|
|
2379
|
+
// first attach. Nothing roots it — the wrappers mark it, so it lives
|
|
2380
|
+
// exactly as long as this isolate is reachable from Ruby.
|
|
2381
|
+
let roots = ruby.ary_new();
|
|
2278
2382
|
let core = Arc::new_cyclic(|me| Core {
|
|
2279
2383
|
me: me.clone(),
|
|
2280
2384
|
shared: Mutex::new(Shared {
|
|
@@ -2288,10 +2392,10 @@ impl Isolate {
|
|
|
2288
2392
|
scan_start_field: std::sync::atomic::AtomicUsize::new(0),
|
|
2289
2393
|
installed_stack_limit: std::sync::atomic::AtomicUsize::new(0),
|
|
2290
2394
|
depth: std::sync::atomic::AtomicU32::new(0),
|
|
2395
|
+
roots: Opaque::from(roots),
|
|
2291
2396
|
procs: Mutex::new(ProcTable::default()),
|
|
2292
2397
|
default_timeout_ms: timeout_ms,
|
|
2293
2398
|
memory_limit,
|
|
2294
|
-
dynamic_import_resolver: Mutex::new(None),
|
|
2295
2399
|
watchdog,
|
|
2296
2400
|
watchdog_join: Mutex::new(Some(watchdog_join)),
|
|
2297
2401
|
});
|
|
@@ -2314,7 +2418,14 @@ impl Isolate {
|
|
|
2314
2418
|
// enter/exit stack (an out-of-order drop aborts). Instead each op enters
|
|
2315
2419
|
// around its run (Core::run) and teardown re-enters just before drop.
|
|
2316
2420
|
unsafe { (*core.iso_ptr.0).exit() };
|
|
2317
|
-
|
|
2421
|
+
// Wrap HERE rather than returning `Self` for magnus to wrap after this frame is
|
|
2422
|
+
// gone: wrapping allocates, and until the wrapper exists the roots array is
|
|
2423
|
+
// referenced only from the Arc<Core>'s malloc'd block, which Ruby does not scan.
|
|
2424
|
+
// `black_box` keeps the local VALUE live across that allocation — where the
|
|
2425
|
+
// conservative stack scan does reach it.
|
|
2426
|
+
let wrapped = ruby.obj_wrap(Isolate { core });
|
|
2427
|
+
std::hint::black_box(roots);
|
|
2428
|
+
Ok(wrapped)
|
|
2318
2429
|
}
|
|
2319
2430
|
}
|
|
2320
2431
|
|
|
@@ -2578,16 +2689,67 @@ impl Core {
|
|
|
2578
2689
|
.clone()
|
|
2579
2690
|
}
|
|
2580
2691
|
|
|
2692
|
+
// ── the roots array (see the module header + ROOT_IMPORT_RESOLVER) ──────
|
|
2693
|
+
// Keep `val` alive for as long as this isolate is reachable from Ruby. On a
|
|
2694
|
+
// Ruby thread with the GVL: storing can grow the Array, which allocates.
|
|
2695
|
+
fn root_store<T: magnus::IntoValue>(
|
|
2696
|
+
&self,
|
|
2697
|
+
ruby: &Ruby,
|
|
2698
|
+
index: isize,
|
|
2699
|
+
val: T,
|
|
2700
|
+
) -> Result<(), Error> {
|
|
2701
|
+
ruby.get_inner(self.roots).store(index, val)
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
// One entry, or None where nothing is stored (an unset resolver, a released
|
|
2705
|
+
// host fn). Reads the Array WITHOUT the GVL — V8 calls the import hooks with it
|
|
2706
|
+
// released — so it must not run one line of Ruby: `rb_ary_entry` plus
|
|
2707
|
+
// `Proc::from_value` (rb_obj_is_proc) and nothing else. NOT `RArray::entry`,
|
|
2708
|
+
// whose TryConvert would answer a nil slot by CALLING `nil.to_proc`, raising
|
|
2709
|
+
// NoMethodError and rescuing it — Ruby code, an allocation and a longjmp, with
|
|
2710
|
+
// no GVL held (measured: it corrupts the VM — "[BUG] unexpected situation" then
|
|
2711
|
+
// a SEGV — under any concurrent Ruby thread). The entries are pinned by
|
|
2712
|
+
// `mark_roots`, so the VALUE stays put until the caller has it under the GVL.
|
|
2713
|
+
fn root_proc(&self, index: isize) -> Option<Proc> {
|
|
2714
|
+
let roots = unsafe { Ruby::get_unchecked() }.get_inner(self.roots);
|
|
2715
|
+
Proc::from_value(roots.entry::<Value>(index).ok()?)
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
// Show the GC everything this isolate holds. Called from EVERY wrapper's mark
|
|
2719
|
+
// (Isolate, Context, Module, Script all keep an Arc<Core>, and any one of them
|
|
2720
|
+
// may be the last reachable): a wrapper that forgot to would leave a live
|
|
2721
|
+
// isolate's host fns unmarked. `mark` PINS — an Array marks its own elements
|
|
2722
|
+
// MOVABLE, and the copies Rust takes out of it (call_proc, root_proc) would go
|
|
2723
|
+
// stale if the compactor moved one. Takes no Rust lock, by construction.
|
|
2724
|
+
fn mark_roots(&self, marker: &gc::Marker) {
|
|
2725
|
+
let roots = unsafe { Ruby::get_unchecked() }.get_inner(self.roots);
|
|
2726
|
+
marker.mark(roots);
|
|
2727
|
+
// Entry by entry, exactly — not `mark_slice`, whose rb_gc_mark_locations is
|
|
2728
|
+
// CONSERVATIVE (it tests each word for heap-likeness) and measured no faster
|
|
2729
|
+
// here. The walk is O(entries) per wrapper per GC, MINOR GCs included (these
|
|
2730
|
+
// wrappers are not wb_protected, so they are marked every time): measured
|
|
2731
|
+
// 2026-09-13 with 600 procs, ~0.1 ms per GC at the 18 wrappers a real embedder
|
|
2732
|
+
// session holds (capybara-simulated: 1 Isolate + 17 Contexts), ~3 ms at 500. If
|
|
2733
|
+
// that ever bites, the fix is for the derived wrappers to mark the ISOLATE'S
|
|
2734
|
+
// Ruby object and let its own mark walk the array once — which needs Core to
|
|
2735
|
+
// hold that object (an Opaque<Value> nothing stores today), not just a rewrite
|
|
2736
|
+
// of this loop.
|
|
2737
|
+
for i in 0..roots.len() {
|
|
2738
|
+
if let Ok(v) = roots.entry::<Value>(i as isize) {
|
|
2739
|
+
marker.mark(v);
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2581
2744
|
fn call_proc(&self, ruby: &Ruby, host_fn_id: usize, args: &[JsVal]) -> Result<JsVal, String> {
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
};
|
|
2745
|
+
// Straight from the roots array — a released (or never-attached) id reads
|
|
2746
|
+
// back nil, which is the same "unknown host function" the slot table would
|
|
2747
|
+
// have reported, so the hot path takes no lock at all. `root_proc` and not
|
|
2748
|
+
// `RArray::entry`, for the reason given there: a nil slot must answer None,
|
|
2749
|
+
// never dispatch `to_proc` and raise.
|
|
2750
|
+
let proc = self
|
|
2751
|
+
.root_proc(proc_root_index(host_fn_id))
|
|
2752
|
+
.ok_or("unknown host function")?;
|
|
2591
2753
|
// Marshal into a Ruby Array, NOT a Vec<Value>: bare Values in a heap Vec
|
|
2592
2754
|
// are hidden from Ruby's GC mark phase (magnus's own RArray::to_vec doc
|
|
2593
2755
|
// spells this out). With several args, once arg N is parked in the Vec
|
|
@@ -2694,6 +2856,23 @@ impl Core {
|
|
|
2694
2856
|
Ok(())
|
|
2695
2857
|
}
|
|
2696
2858
|
|
|
2859
|
+
// Isolate#pump_message_loop: run every pending foreground platform task, without blocking.
|
|
2860
|
+
// These are the deferred tasks V8 posts to the platform's task runner rather than running
|
|
2861
|
+
// inline — most importantly FinalizationRegistry cleanup callbacks (posted after a GC finds a
|
|
2862
|
+
// registered target dead) and WeakRef clearing. A pure microtask checkpoint does NOT drive
|
|
2863
|
+
// them; only pumping the message loop does. Returns nothing; safe to call any time (a no-op
|
|
2864
|
+
// when the queue is empty).
|
|
2865
|
+
fn pump_message_loop(&self, ruby: &Ruby) -> Result<(), Error> {
|
|
2866
|
+
let reply = self.run(
|
|
2867
|
+
ruby,
|
|
2868
|
+
Request::PumpMessageLoop {
|
|
2869
|
+
timeout_ms: self.default_timeout_ms,
|
|
2870
|
+
},
|
|
2871
|
+
)?;
|
|
2872
|
+
self.reply_value(ruby, reply)?;
|
|
2873
|
+
Ok(())
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2697
2876
|
fn eval_t(
|
|
2698
2877
|
&self,
|
|
2699
2878
|
ruby: &Ruby,
|
|
@@ -2723,11 +2902,16 @@ impl Core {
|
|
|
2723
2902
|
name: String,
|
|
2724
2903
|
proc: Proc,
|
|
2725
2904
|
) -> Result<Value, Error> {
|
|
2905
|
+
// BEFORE the roots write, not just inside `run` below: storing can REALLOC the
|
|
2906
|
+
// array's element storage, which the import hooks read with the GVL released —
|
|
2907
|
+
// and a refused attach must not consume a slot or leave its proc rooted either.
|
|
2908
|
+
self.ensure_owner_and_live(ruby)?;
|
|
2726
2909
|
let host_fn_id = self.procs.lock().unwrap().alloc(ProcSlot {
|
|
2727
2910
|
context_id,
|
|
2728
|
-
|
|
2911
|
+
live: true,
|
|
2729
2912
|
name: name.clone(),
|
|
2730
2913
|
});
|
|
2914
|
+
self.root_store(ruby, proc_root_index(host_fn_id), proc)?;
|
|
2731
2915
|
let reply = self.run(
|
|
2732
2916
|
ruby,
|
|
2733
2917
|
Request::Attach {
|
|
@@ -2756,20 +2940,31 @@ impl Core {
|
|
|
2756
2940
|
if entries.is_empty() {
|
|
2757
2941
|
return Ok(ruby.qnil().as_value()); // nothing to install, skip the round-trip
|
|
2758
2942
|
}
|
|
2759
|
-
|
|
2943
|
+
self.ensure_owner_and_live(ruby)?; // before the roots writes — see `attach`
|
|
2944
|
+
let allocated: Vec<(String, usize, Proc)> = {
|
|
2760
2945
|
let mut procs = self.procs.lock().unwrap();
|
|
2761
2946
|
entries
|
|
2762
2947
|
.into_iter()
|
|
2763
2948
|
.map(|(name, proc)| {
|
|
2764
2949
|
let id = procs.alloc(ProcSlot {
|
|
2765
2950
|
context_id,
|
|
2766
|
-
|
|
2951
|
+
live: true,
|
|
2767
2952
|
name: name.clone(),
|
|
2768
2953
|
});
|
|
2769
|
-
(name, id)
|
|
2954
|
+
(name, id, proc)
|
|
2770
2955
|
})
|
|
2771
2956
|
.collect()
|
|
2772
2957
|
};
|
|
2958
|
+
// Stored OUTSIDE the table's lock: storing into the array can allocate (the
|
|
2959
|
+
// Array grows), and an allocation may GC — which marks through this isolate's
|
|
2960
|
+
// wrappers, a path that must never wait on `procs`. The Procs parked in the
|
|
2961
|
+
// Vec meanwhile are invisible to the GC (see call_proc's note), but the caller's
|
|
2962
|
+
// `table: RHash` argument is live in its own frame and marks every one of them.
|
|
2963
|
+
let mut named_ids: Vec<(String, usize)> = Vec::with_capacity(allocated.len());
|
|
2964
|
+
for (name, id, proc) in allocated {
|
|
2965
|
+
self.root_store(ruby, proc_root_index(id), proc)?;
|
|
2966
|
+
named_ids.push((name, id));
|
|
2967
|
+
}
|
|
2773
2968
|
let reply = self.run(
|
|
2774
2969
|
ruby,
|
|
2775
2970
|
Request::AttachMany {
|
|
@@ -2781,13 +2976,20 @@ impl Core {
|
|
|
2781
2976
|
self.reply_value(ruby, reply)
|
|
2782
2977
|
}
|
|
2783
2978
|
|
|
2784
|
-
//
|
|
2785
|
-
// realm is gone (reset or disposed), so the V8-side functions that
|
|
2786
|
-
//
|
|
2787
|
-
//
|
|
2788
|
-
// realms are indices into
|
|
2789
|
-
|
|
2790
|
-
|
|
2979
|
+
// Drop the procs attached into |context_id| out of the roots array — its
|
|
2980
|
+
// realm is gone (reset or disposed), so the V8-side functions that referenced
|
|
2981
|
+
// them are unreachable, and holding them would keep the embedder's world alive
|
|
2982
|
+
// for the rest of the isolate's life. Runs on a Ruby thread: it writes nil
|
|
2983
|
+
// into the Array. The slots stay: host_fn_ids of other realms are indices into
|
|
2984
|
+
// the same Vec.
|
|
2985
|
+
fn release_context_procs(&self, ruby: &Ruby, context_id: i32) {
|
|
2986
|
+
// Nil the entries BEFORE the ids go back on the free list, so no attach can
|
|
2987
|
+
// ever be handed an id whose old proc is still in the array.
|
|
2988
|
+
let released = self.procs.lock().unwrap().release(context_id);
|
|
2989
|
+
for &id in &released {
|
|
2990
|
+
let _ = self.root_store(ruby, proc_root_index(id), ruby.qnil());
|
|
2991
|
+
}
|
|
2992
|
+
self.procs.lock().unwrap().free.extend(released);
|
|
2791
2993
|
}
|
|
2792
2994
|
|
|
2793
2995
|
fn reset(&self, ruby: &Ruby, context_id: i32) -> Result<Value, Error> {
|
|
@@ -2795,7 +2997,7 @@ impl Core {
|
|
|
2795
2997
|
let out = self.reply_value(ruby, reply)?;
|
|
2796
2998
|
// Only on success — a refused reset (unknown/suspended realm) keeps
|
|
2797
2999
|
// its attached fns callable.
|
|
2798
|
-
self.release_context_procs(context_id);
|
|
3000
|
+
self.release_context_procs(ruby, context_id);
|
|
2799
3001
|
Ok(out)
|
|
2800
3002
|
}
|
|
2801
3003
|
|
|
@@ -2809,7 +3011,7 @@ impl Core {
|
|
|
2809
3011
|
fn dispose_context(&self, ruby: &Ruby, context_id: i32) -> Result<(), Error> {
|
|
2810
3012
|
let reply = self.run(ruby, Request::DisposeContext { context_id })?;
|
|
2811
3013
|
self.reply_value(ruby, reply)?;
|
|
2812
|
-
self.release_context_procs(context_id);
|
|
3014
|
+
self.release_context_procs(ruby, context_id);
|
|
2813
3015
|
Ok(())
|
|
2814
3016
|
}
|
|
2815
3017
|
|
|
@@ -2986,10 +3188,18 @@ impl Core {
|
|
|
2986
3188
|
code_cache_from_reply(ruby, reply)
|
|
2987
3189
|
}
|
|
2988
3190
|
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
3191
|
+
// Owner-thread only, like every other writer of the roots array (`attach`,
|
|
3192
|
+
// `attach_many`, `dispose`): the store can REALLOC the array's element storage, and
|
|
3193
|
+
// the import hooks read that storage with the GVL released.
|
|
3194
|
+
fn set_dynamic_import_resolver(&self, ruby: &Ruby, proc: Proc) -> Result<(), Error> {
|
|
3195
|
+
self.ensure_owner_and_live(ruby)?;
|
|
3196
|
+
self.root_store(ruby, ROOT_IMPORT_RESOLVER, proc)
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
// The resolver, or None if none was set. Read on the V8 side without the GVL —
|
|
3200
|
+
// see root_proc.
|
|
3201
|
+
fn dynamic_import_resolver(&self) -> Option<Proc> {
|
|
3202
|
+
self.root_proc(ROOT_IMPORT_RESOLVER)
|
|
2993
3203
|
}
|
|
2994
3204
|
|
|
2995
3205
|
// Terminate whatever is running. IsolateHandle is Send + refcounted —
|
|
@@ -3020,12 +3230,16 @@ impl Core {
|
|
|
3020
3230
|
// it entered, and OwnedIsolate's Drop asserts `self == GetCurrent()`
|
|
3021
3231
|
// (then exits). Between ops the isolate is exited, so we must enter here.
|
|
3022
3232
|
unsafe { (*self.iso_ptr.0).enter() };
|
|
3233
|
+
// Only the Rust-side bookkeeping. Clearing the roots ARRAY here would be
|
|
3234
|
+
// pointless work: teardown also runs from Core::drop, and by then the array
|
|
3235
|
+
// is garbage itself — the last wrapper marking it is exactly what went away.
|
|
3236
|
+
// `dispose` clears it explicitly instead, for the isolate the caller disposes
|
|
3237
|
+
// but keeps a handle to.
|
|
3023
3238
|
{
|
|
3024
3239
|
let mut procs = self.procs.lock().unwrap();
|
|
3025
3240
|
procs.slots.clear();
|
|
3026
3241
|
procs.free.clear();
|
|
3027
3242
|
}
|
|
3028
|
-
*self.dynamic_import_resolver.lock().unwrap() = None;
|
|
3029
3243
|
{
|
|
3030
3244
|
let st = istate!(unsafe { &mut *self.iso_ptr.0 });
|
|
3031
3245
|
st.realms = V8State::default();
|
|
@@ -3074,6 +3288,10 @@ impl Core {
|
|
|
3074
3288
|
shared.disposed = true;
|
|
3075
3289
|
}
|
|
3076
3290
|
self.teardown();
|
|
3291
|
+
// Release the host fns NOW rather than when the wrappers are collected: a
|
|
3292
|
+
// disposed isolate can never call one again, and each closes over as much
|
|
3293
|
+
// of the embedder's world as it happens to capture.
|
|
3294
|
+
let _ = ruby.get_inner(self.roots).clear();
|
|
3077
3295
|
Ok(())
|
|
3078
3296
|
}
|
|
3079
3297
|
}
|
|
@@ -3189,9 +3407,15 @@ impl Isolate {
|
|
|
3189
3407
|
fn low_memory_notification(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
|
|
3190
3408
|
rb_self.core.low_memory_notification(ruby)
|
|
3191
3409
|
}
|
|
3410
|
+
// Isolate#pump_message_loop: run pending foreground platform tasks (FinalizationRegistry
|
|
3411
|
+
// cleanup callbacks and WeakRef clearing among them), without blocking. Pair it with a GC
|
|
3412
|
+
// (low_memory_notification) or call it periodically so weak-collection callbacks actually fire.
|
|
3413
|
+
fn pump_message_loop(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
|
|
3414
|
+
rb_self.core.pump_message_loop(ruby)
|
|
3415
|
+
}
|
|
3192
3416
|
// dynamic_import_resolver = ->(specifier, referrer_url) { module } for import().
|
|
3193
|
-
fn set_dynamic_import_resolver(rb_self: &Self, proc: Proc) {
|
|
3194
|
-
rb_self.core.set_dynamic_import_resolver(proc)
|
|
3417
|
+
fn set_dynamic_import_resolver(ruby: &Ruby, rb_self: &Self, proc: Proc) -> Result<(), Error> {
|
|
3418
|
+
rb_self.core.set_dynamic_import_resolver(ruby, proc)
|
|
3195
3419
|
}
|
|
3196
3420
|
fn dispose(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
|
|
3197
3421
|
rb_self.core.dispose(ruby)
|
|
@@ -3697,6 +3921,16 @@ fn resolve_module_via_ruby(
|
|
|
3697
3921
|
|
|
3698
3922
|
#[magnus::init]
|
|
3699
3923
|
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
3924
|
+
install_classes(ruby)
|
|
3925
|
+
}
|
|
3926
|
+
|
|
3927
|
+
// Define the RustyRacer::* Ruby classes/modules. Split out of the magnus init so
|
|
3928
|
+
// an embedder that links rusty_racer as a LIBRARY (rather than loading its gem
|
|
3929
|
+
// .so) can build its own cdylib and call this from its own `#[magnus::init]` —
|
|
3930
|
+
// e.g. capybara-simulated, which links rusty_racer + a native DOM into one
|
|
3931
|
+
// extension. Standalone gem use goes through `init` above; both define the exact
|
|
3932
|
+
// same surface.
|
|
3933
|
+
pub fn install_classes(ruby: &Ruby) -> Result<(), Error> {
|
|
3700
3934
|
let module = ruby.define_module("RustyRacer")?;
|
|
3701
3935
|
|
|
3702
3936
|
// The isolate (VM) + its isolate-level ops; hands out Contexts.
|
|
@@ -3720,6 +3954,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
3720
3954
|
"low_memory_notification",
|
|
3721
3955
|
method!(Isolate::low_memory_notification, 0),
|
|
3722
3956
|
)?;
|
|
3957
|
+
isolate.define_method("pump_message_loop", method!(Isolate::pump_message_loop, 0))?;
|
|
3723
3958
|
isolate.define_method("dispose", method!(Isolate::dispose, 0))?;
|
|
3724
3959
|
isolate.define_method("disposed?", method!(Isolate::disposed, 0))?;
|
|
3725
3960
|
|
data/ext/rusty_racer/src/ops.rs
CHANGED
|
@@ -184,6 +184,13 @@ pub(crate) enum Request {
|
|
|
184
184
|
// near-heap-limit callback, and tells reclaimable garbage apart from a real
|
|
185
185
|
// leak (memory that survives this is genuinely retained).
|
|
186
186
|
LowMemoryNotification,
|
|
187
|
+
// Run pending foreground platform tasks (Platform::PumpMessageLoop) without blocking —
|
|
188
|
+
// the deferred work V8 posts to its task runner rather than running inline, most
|
|
189
|
+
// importantly FinalizationRegistry cleanup callbacks (arbitrary JS, so time-capped by
|
|
190
|
+
// timeout_ms). Realm-independent.
|
|
191
|
+
PumpMessageLoop {
|
|
192
|
+
timeout_ms: u64,
|
|
193
|
+
},
|
|
187
194
|
}
|
|
188
195
|
|
|
189
196
|
// compile_module result: the module's id plus any produced bytecode cache and
|
|
@@ -439,7 +446,8 @@ fn request_realm(state: &IsolateState, request: &Request) -> Option<i32> {
|
|
|
439
446
|
| Request::ScriptCodeCache { .. }
|
|
440
447
|
| Request::ModuleCodeCache { .. }
|
|
441
448
|
| Request::HeapStatistics
|
|
442
|
-
| Request::LowMemoryNotification
|
|
449
|
+
| Request::LowMemoryNotification
|
|
450
|
+
| Request::PumpMessageLoop { .. } => None,
|
|
443
451
|
}
|
|
444
452
|
}
|
|
445
453
|
|
|
@@ -544,6 +552,7 @@ fn dispatch_one(
|
|
|
544
552
|
Request::ModuleCodeCache { module_id } => op_module_code_cache(scope, module_id),
|
|
545
553
|
Request::HeapStatistics => op_heap_statistics(scope),
|
|
546
554
|
Request::LowMemoryNotification => op_low_memory_notification(scope),
|
|
555
|
+
Request::PumpMessageLoop { timeout_ms } => op_pump_message_loop(scope, timeout_ms),
|
|
547
556
|
}
|
|
548
557
|
}
|
|
549
558
|
|
|
@@ -571,6 +580,38 @@ fn op_low_memory_notification(scope: &mut v8::PinScope<'_, '_, ()>) -> VmReply {
|
|
|
571
580
|
VmReply::Done(Ok(JsVal::Undefined))
|
|
572
581
|
}
|
|
573
582
|
|
|
583
|
+
// Drain the platform's foreground task queue: run every pending task, then stop (never blocks
|
|
584
|
+
// waiting for work). This is what actually fires FinalizationRegistry cleanup callbacks — V8
|
|
585
|
+
// posts them here after a GC finds a registered target dead, and PerformMicrotaskCheckpoint does
|
|
586
|
+
// NOT run them. Runs on the isolate's owner thread, with the isolate entered (the cleanup task
|
|
587
|
+
// enters the target's realm itself). A no-op when the platform is uninitialized or the queue empty.
|
|
588
|
+
//
|
|
589
|
+
// A cleanup callback is ARBITRARY user JS, so it gets the same bracket every other JS-running op has:
|
|
590
|
+
// a watchdog time-caps a runaway (`while(true){}`) callback and makes it terminable, and the
|
|
591
|
+
// `pump_message_loop(…, false)` loop stays bounded because a fired terminate blocks further JS. After
|
|
592
|
+
// the tasks run, a microtask checkpoint drains any microtasks they queued (a promise reaction), so
|
|
593
|
+
// cleanup-triggered continuations settle in this op — matching the binding's auto-microtask model.
|
|
594
|
+
fn op_pump_message_loop(scope: &mut v8::PinScope<'_, '_, ()>, timeout_ms: u64) -> VmReply {
|
|
595
|
+
let watchdog = arm_watchdog(scope, timeout_ms);
|
|
596
|
+
if let Some(platform) = crate::platform() {
|
|
597
|
+
while v8::Platform::pump_message_loop(platform, scope, false) {}
|
|
598
|
+
}
|
|
599
|
+
if let Some(ctx) = context_for(istate!(scope), 0) {
|
|
600
|
+
let context = v8::Local::new(scope, &ctx);
|
|
601
|
+
let scope = &mut v8::ContextScope::new(scope, context);
|
|
602
|
+
checkpoint_draining(scope);
|
|
603
|
+
}
|
|
604
|
+
let fired = disarm_watchdog(scope, watchdog);
|
|
605
|
+
if fired {
|
|
606
|
+
istate!(scope).watchdog_fired = true;
|
|
607
|
+
}
|
|
608
|
+
VmReply::Done(if fired {
|
|
609
|
+
Err(VmError::Terminated)
|
|
610
|
+
} else {
|
|
611
|
+
Ok(JsVal::Undefined)
|
|
612
|
+
})
|
|
613
|
+
}
|
|
614
|
+
|
|
574
615
|
#[allow(clippy::too_many_arguments)]
|
|
575
616
|
fn op_eval(
|
|
576
617
|
scope: &mut v8::PinScope<'_, '_, ()>,
|
|
@@ -775,7 +816,7 @@ fn op_reset(scope: &mut v8::PinScope<'_, '_, ()>, context_id: i32) -> VmReply {
|
|
|
775
816
|
"cannot reset a realm while a request for it is suspended on the V8 stack".into(),
|
|
776
817
|
)))
|
|
777
818
|
} else {
|
|
778
|
-
let (fresh, fresh_queue) = new_realm(scope);
|
|
819
|
+
let (fresh, fresh_queue) = new_realm(scope, context_id);
|
|
779
820
|
{
|
|
780
821
|
let realms = &mut istate!(scope).realms;
|
|
781
822
|
// Swap in the fresh realm and PARK the old context + queue in
|
|
@@ -812,7 +853,7 @@ fn op_create_context(scope: &mut v8::PinScope<'_, '_, ()>) -> VmReply {
|
|
|
812
853
|
realms.next_context_id += 1;
|
|
813
854
|
id
|
|
814
855
|
};
|
|
815
|
-
let (fresh, fresh_queue) = new_realm(scope);
|
|
856
|
+
let (fresh, fresh_queue) = new_realm(scope, id);
|
|
816
857
|
istate!(scope).realms.contexts.insert(id, fresh);
|
|
817
858
|
istate!(scope).realms.queues.insert(id, fresh_queue);
|
|
818
859
|
VmReply::Done(Ok(JsVal::Int(id as i64)))
|
data/lib/rusty_racer/version.rb
CHANGED
data/lib/rusty_racer.rb
CHANGED
|
@@ -14,10 +14,18 @@ require_relative "rusty_racer/version"
|
|
|
14
14
|
# flat fallback.
|
|
15
15
|
versioned = "rusty_racer/#{RUBY_VERSION[/\d+\.\d+/]}/rusty_racer"
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
# Skip the native load when an embedder has already defined the classes. Linking
|
|
18
|
+
# rusty_racer as a library into another extension's cdylib (e.g.
|
|
19
|
+
# capybara-simulated, which bundles rusty_racer + its native DOM) calls
|
|
20
|
+
# install_classes from that extension's own init; loading this gem's separate .so
|
|
21
|
+
# on top would put a SECOND V8 runtime in the process (unsound). This file is then
|
|
22
|
+
# required only for the pure-Ruby API wrappers below.
|
|
23
|
+
unless defined?(RustyRacer::Isolate)
|
|
24
|
+
if File.exist?(File.join(__dir__, "#{versioned}.#{RbConfig::CONFIG['DLEXT']}"))
|
|
25
|
+
require_relative versioned
|
|
26
|
+
else
|
|
27
|
+
require "rusty_racer/rusty_racer"
|
|
28
|
+
end
|
|
21
29
|
end
|
|
22
30
|
|
|
23
31
|
module RustyRacer
|
|
@@ -99,10 +107,11 @@ module RustyRacer
|
|
|
99
107
|
# the binding's job (V8's host contract), and static imports met while linking
|
|
100
108
|
# resolve through this same block (also with the realm as the 3rd arg).
|
|
101
109
|
# (Module#instantiate's own resolve block keeps its 2-arg form.)
|
|
102
|
-
#
|
|
103
|
-
#
|
|
110
|
+
# The native side keeps the proc alive itself (in the isolate's roots array,
|
|
111
|
+
# marked by its wrappers), so it is NOT held in an ivar here: that second
|
|
112
|
+
# reference would outlive `dispose`, which exists to drop whatever the resolver
|
|
113
|
+
# captured the moment the isolate can no longer call it.
|
|
104
114
|
def dynamic_import_resolver=(resolver)
|
|
105
|
-
@dynamic_import_resolver = resolver
|
|
106
115
|
_set_dynamic_import_resolver(resolver)
|
|
107
116
|
end
|
|
108
117
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rusty_racer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Keita Urashima
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-09-28 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rb_sys
|