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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9a799692e0bcc6c18bbba590786cd92b0f3cd5c05c65be4da085ec3a739ae2ee
4
- data.tar.gz: 0ca41e25987f9f721437abbfc1278c43d7aa688c996a1824cb342d4b1672ed70
3
+ metadata.gz: bc72e8a18f16fc78197991e4e29ff01ba94afb83ae26d3ea162903654f485dae
4
+ data.tar.gz: 06a00da38cb052af3a29fa772f93fa14f3e8f267f8a7c88723aad827f908a3d7
5
5
  SHA512:
6
- metadata.gz: c63a41df38fbacdfc440a4bb61ff8f780315d2ce267a55d698505a7601557ba76a3762896dd12a4989aaf34d27ba2a7eb9c751a7249e7a71ae572a37d5af6133
7
- data.tar.gz: e8deb0c3a86b25577996ee84d15f2163f6a2a8eab952d3c8592e4513f6e2cb483f3734ec7947dff22bad19cea9c5239284edd910473058baea5761921c510b8f
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 (their GC roots freed); re-attach them after a reset.
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
@@ -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.3"
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
@@ -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 GC-rooted via
35
- // rb_gc_register_address (see RootedProc): marked, so the extension may hold the
36
- // only reference, and pinned, so GC.compact cannot move them behind the
37
- // extension's back.
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, Value, function, method, prelude::*,
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 the GC root — the rooted proc itself (None
126
- // once released; the slot index stays valid as a host_fn_id), and the name it
127
- // was attached under, kept only so a diagnostic can say which host function is
128
- // involved (one String per attach, never per call).
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
- proc: Option<RootedProc>,
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 each slot to the free list. Idempotent: an already-released slot
161
- // has proc == None and is skipped, so it can't be double-freed.
162
- fn release(&mut self, context_id: i32) {
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.proc.is_some() {
165
- slot.proc = None;
166
- self.free.push(id);
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 (call_proc
305
- // takes the proc out under the lock and releases it before calling),
306
- // but blocking here would turn a diagnostic into a hang.
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
- let resolver = core
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 resolver_proc = core
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
- // host_fn_id indexes ProcTable.slots. Mutex (uncontended — single owner
1504
- // thread) so host_fn_callback can reach it through Core (via the slot's
1505
- // core_ptr) while a &Core method also holds it. Each proc is GC-rooted while
1506
- // live — see RootedProc/ProcSlot; reset/dispose releases roots, recycles slots.
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
- #[magnus::wrap(class = "RustyRacer::Isolate")]
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
- #[magnus::wrap(class = "RustyRacer::Context")]
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
- #[magnus::wrap(class = "RustyRacer::Module")]
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
- #[magnus::wrap(class = "RustyRacer::Script")]
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
- _ruby: &Ruby,
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, Error> {
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
- Ok(Isolate { core })
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
- let proc = {
2583
- let procs = self.procs.lock().unwrap();
2584
- procs
2585
- .slots
2586
- .get(host_fn_id)
2587
- .and_then(|slot| slot.proc.as_ref())
2588
- .ok_or("unknown host function")?
2589
- .get()
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
- proc: Some(RootedProc(BoxValue::new(proc))),
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
- let named_ids: Vec<(String, usize)> = {
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
- proc: Some(RootedProc(BoxValue::new(proc))),
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
- // Release the GC roots of the procs attached into |context_id| — its
2785
- // realm is gone (reset or disposed), so the V8-side functions that
2786
- // referenced them are unreachable. Runs on a Ruby thread (a RootedProc
2787
- // drop unregisters its GC address). The slots stay: host_fn_ids of other
2788
- // realms are indices into the same Vec.
2789
- fn release_context_procs(&self, context_id: i32) {
2790
- self.procs.lock().unwrap().release(context_id);
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
- fn set_dynamic_import_resolver(&self, proc: Proc) {
2990
- // The old RootedProc (if any) drops here, unregistering its address —
2991
- // we are on a Ruby thread, so that's GVL-safe.
2992
- *self.dynamic_import_resolver.lock().unwrap() = Some(RootedProc(BoxValue::new(proc)));
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
 
@@ -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 => None,
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)))
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RustyRacer
4
- VERSION = "0.2.3"
4
+ VERSION = "0.2.4"
5
5
  end
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
- if File.exist?(File.join(__dir__, "#{versioned}.#{RbConfig::CONFIG['DLEXT']}"))
18
- require_relative versioned
19
- else
20
- require "rusty_racer/rusty_racer"
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
- # Held in an ivar so the proc stays alive for the isolate's lifetime (the
103
- # native side only keeps a weak handle).
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.3
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-08-26 00:00:00.000000000 Z
11
+ date: 2026-09-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys