rusty_racer 0.2.2 → 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: 03be69f3c1409a64a73622db3063447bbea7813f393227d508739f0c636c6859
4
- data.tar.gz: 7a7ecb5203032be6f8b27133444ce8c3663eb7d9d49aa86726be8c28acf19425
3
+ metadata.gz: bc72e8a18f16fc78197991e4e29ff01ba94afb83ae26d3ea162903654f485dae
4
+ data.tar.gz: 06a00da38cb052af3a29fa772f93fa14f3e8f267f8a7c88723aad827f908a3d7
5
5
  SHA512:
6
- metadata.gz: 57fb55d034906210851ea93be3ad933ee366dfccd1419d4f7f712ed5407082565ccda4ed0e0abadde12ec9af1b958ffab908cbdfb40ef5efa188ed8d2f47154e
7
- data.tar.gz: 56983cedc5a48dc0a9cf1570da7a7241a6be6a3a8c80ca7114204e6764bc377a0d72814147e318c64974093b625148e8260d8e75168a76e1fa22711a94e5ed69
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
@@ -337,11 +338,37 @@ What happens next depends on what the stranded operation's Fiber does:
337
338
  The isolate cannot be disposed after that; it is leaked, with a warning saying
338
339
  why. If you still hold the Fiber, **`Fiber#kill` recovers it**: it resumes the
339
340
  Fiber to unwind it, so the stranded operation finishes in order and the isolate
340
- goes back to normal. (The one thing that defeats that is a script which catches
341
- the error the killed callback throws and carries on — the kill only lands once
342
- that operation ends, just as it would wait out any Ruby call in progress, and
343
- until then the script can call further host functions, which run on the
344
- already-killed Fiber.)
341
+ goes back to normal. The operation itself fails on the way out — see below for
342
+ why a kill reaches it as an ordinary error.
343
+
344
+ ### Killing a thread or Fiber inside a host function
345
+
346
+ **A `Thread#kill` or `Fiber#kill` that lands while a host function's Ruby is
347
+ running does not kill it.** It surfaces as `RustyRacer::RuntimeError: Error:
348
+ Fatal` from the `eval`/`call` that was in flight, and the thread keeps going —
349
+ and CRuby has already marked that thread as killed, so **a second `Thread#kill`
350
+ is a no-op**. `Thread#raise` still works, and is the way out if you have to
351
+ reach in from outside.
352
+
353
+ A kill inside a **module resolver** is quieter still: the specifier it was
354
+ resolving simply fails to link, so the error names the import
355
+ (`failed to resolve module specifier "./dep.js" imported from /app.js`) with no
356
+ mention of a kill at all.
357
+
358
+ This is not a choice, it is the boundary. Ruby unwinds a kill with `TAG_FATAL`,
359
+ which arrives while V8's C++ frames are on the stack — and a `longjmp` through
360
+ those is not survivable, so the callback has to catch it like any other error.
361
+ Nor can it be put back afterwards: `rb_jump_tag` does not carry the unwind with
362
+ it, it re-enters whatever the VM still holds in `$!`, and anything protected
363
+ that ran in between (one more host function raising is enough) has cleared it.
364
+ Jumping into that is a segfault in the interpreter rather than a late kill. 0.2.2
365
+ shipped an attempt at this and had to be withdrawn in 0.2.3.
366
+
367
+ So: if you need to stop work that calls into an isolate, **stop it at a Ruby
368
+ boundary you control** — a flag the host function checks, a `Queue` it reads —
369
+ rather than by killing the thread from outside. `Isolate#terminate` stops the
370
+ *JavaScript* (at V8's next interrupt check, see above), which is the other half
371
+ of the same job.
345
372
 
346
373
  ## Installation
347
374
 
@@ -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.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
@@ -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()
@@ -472,19 +512,6 @@ fn current_ruby_thread() -> usize {
472
512
  unsafe { rb_sys::rb_thread_current() as usize }
473
513
  }
474
514
 
475
- // Identity of the calling FIBER, as its object_id — monotonic and never reused,
476
- // unlike the VALUE, which is a heap address a collected Fiber hands back (and a
477
- // Fiber stranded with a pending kill DOES get collected). Ids are a Fixnum on
478
- // every platform this gem ships for, so the usize compared is an immediate, not
479
- // a pointer that could alias in its turn. Assigning one can allocate, and this
480
- // runs with V8 frames live and outside any rb_protect of ours, so it takes its
481
- // own: 0 on failure, which reads as "no Fiber" at both ends and so drops a kill
482
- // rather than misdelivers one. MUST be called with the GVL held.
483
- fn current_fiber_id() -> usize {
484
- magnus::rb_sys::protect(|| unsafe { rb_sys::rb_obj_id(rb_sys::rb_fiber_current()) })
485
- .map_or(0, |id| id as usize)
486
- }
487
-
488
515
  // Reduce a magnus Error to a single Exception INSTANCE so it can be GC-rooted and
489
516
  // re-raised later WITH ITS ORIGINAL CLASS. A Ruby proc's raise is already an
490
517
  // instance; an Error::new(class, msg) from our own code becomes an instance of
@@ -560,7 +587,7 @@ fn host_fn_callback(
560
587
  ruby.qnil().as_raw()
561
588
  }) {
562
589
  Ok(_) => out.unwrap_or_else(|| Err("host function did not complete".into())),
563
- Err(e) => Err(core.swallow(e)),
590
+ Err(e) => Err(format!("{e}")),
564
591
  }
565
592
  });
566
593
  match result {
@@ -1117,10 +1144,8 @@ fn resolve_imported<'s>(
1117
1144
  // calls into Ruby (so it can allocate, so it can GC) and BoxValue::new
1118
1145
  // registers a GC root — neither is safe with the GVL released.
1119
1146
  match with_gvl(core, RubyCall::Resolver(&spec), || {
1120
- resolve_module_via_ruby(core, resolve, &spec, &ref_url, None).map_err(|e| {
1121
- core.note_fatal(&e);
1122
- error_to_exception(&e).map(BoxValue::new)
1123
- })
1147
+ resolve_module_via_ruby(core, resolve, &spec, &ref_url, None)
1148
+ .map_err(|e| error_to_exception(&e).map(BoxValue::new))
1124
1149
  }) {
1125
1150
  Ok(id) => id,
1126
1151
  // Stash the resolver's own raised exception (GC-rooted) so the
@@ -1133,18 +1158,12 @@ fn resolve_imported<'s>(
1133
1158
  }
1134
1159
  }
1135
1160
  None => {
1136
- let resolver = core
1137
- .dynamic_import_resolver
1138
- .lock()
1139
- .unwrap()
1140
- .as_ref()
1141
- .map(|r| r.get());
1142
- match resolver {
1161
+ match core.dynamic_import_resolver() {
1143
1162
  Some(p) => match with_gvl(core, RubyCall::Resolver(&spec), || {
1144
1163
  resolve_module_via_ruby(core, p, &spec, &ref_url, Some(here.unwrap_or(0)))
1145
1164
  // Rendering the message reads the Ruby exception, so it too
1146
1165
  // stays inside with_gvl.
1147
- .map_err(|e| core.swallow(e))
1166
+ .map_err(|e| e.to_string())
1148
1167
  }) {
1149
1168
  Ok(id) => id,
1150
1169
  // Unlike the static branch there is no Ruby frame waiting to
@@ -1250,25 +1269,13 @@ fn dynamic_import_cb<'s>(
1250
1269
  return Some(promise);
1251
1270
  }
1252
1271
  let core = unsafe { &*core_ptr };
1253
- let resolver_proc = core
1254
- .dynamic_import_resolver
1255
- .lock()
1256
- .unwrap()
1257
- .as_ref()
1258
- .map(|r| r.get());
1259
- let id = match resolver_proc {
1272
+ let id = match core.dynamic_import_resolver() {
1260
1273
  // A raising resolver only fails the import() (it rejects generically);
1261
- // it must NOT abort the surrounding eval, so swallow the Err here — but
1262
- // note_fatal first, since this is the one with_gvl site that throws its
1263
- // Err away and a Thread#kill would go with it.
1274
+ // it must NOT abort the surrounding eval, so swallow the Err here.
1264
1275
  Some(p) => with_gvl(core, RubyCall::Resolver(&spec), || {
1265
- resolve_module_via_ruby(core, p, &spec, &referrer, Some(initiating)).unwrap_or_else(
1266
- |e| {
1267
- core.note_fatal(&e);
1268
- None
1269
- },
1270
- )
1271
- }),
1276
+ resolve_module_via_ruby(core, p, &spec, &referrer, Some(initiating))
1277
+ })
1278
+ .unwrap_or(None),
1272
1279
  None => None,
1273
1280
  };
1274
1281
  match id {
@@ -1515,27 +1522,22 @@ struct Core {
1515
1522
  // op. Owner-thread only, like the isolate itself; AtomicUsize for shared
1516
1523
  // &Core access.
1517
1524
  installed_stack_limit: std::sync::atomic::AtomicUsize,
1518
- // Set when a callback's Ruby code unwound with TAG_FATAL — a Thread#kill —
1519
- // which magnus catches like any other error because a longjmp through V8's
1520
- // C++ frames is not survivable. Holds the object_id of the
1521
- // Fiber that must finish the kill (never a raw VALUE: a stranded Fiber
1522
- // holding a pending kill does get collected, and its address can then be
1523
- // handed to a new one, whereas an object_id is monotonic and never reused);
1524
- // 0 = nothing pending.
1525
- // See Core::note_fatal / Core::resume_pending_fatal. Atomic for the same
1526
- // reason as the rest: &Core is shared, though only the owner thread ever
1527
- // touches this.
1528
- pending_fatal: std::sync::atomic::AtomicUsize,
1529
1525
  // Re-entry depth for THIS isolate, readable without a scope (the runner needs
1530
1526
  // it to choose the scope kind before any scope exists): 0 = top-level op
1531
1527
  // (open a fresh HandleScope from iso_ptr); >0 = a host callback is on the V8
1532
1528
  // stack (bootstrap via callback_scope! onto the ambient scope). Bumped around
1533
1529
  // each `run`.
1534
1530
  depth: std::sync::atomic::AtomicU32,
1535
- // host_fn_id indexes ProcTable.slots. Mutex (uncontended — single owner
1536
- // thread) so host_fn_callback can reach it through Core (via the slot's
1537
- // core_ptr) while a &Core method also holds it. Each proc is GC-rooted while
1538
- // 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.
1539
1541
  procs: Mutex<ProcTable>,
1540
1542
  // Default per-eval/call timeout (ms); 0 = none. eval(timeout_ms:)'s explicit
1541
1543
  // value overrides it. Guards against an in-V8 infinite loop without a watchdog.
@@ -1547,9 +1549,6 @@ struct Core {
1547
1549
  // the ceiling after each OOM (to this when set, else V8's captured default — see
1548
1550
  // oom_initial_limit). Space-axis twin of default_timeout_ms.
1549
1551
  memory_limit: usize,
1550
- // Set by Context#dynamic_import_resolver=; called for a JS import() to map
1551
- // (specifier, referrer) to an already-loaded Module. GC-rooted like procs.
1552
- dynamic_import_resolver: Mutex<Option<RootedProc>>,
1553
1552
  // The watchdog (armed per timed op, fires TerminateExecution via the handle)
1554
1553
  // and its thread's join handle, held here so dispose/Drop — which run on the
1555
1554
  // owner thread with no scope — can stop and join it before the isolate drops.
@@ -1560,7 +1559,8 @@ struct Core {
1560
1559
  // The V8 isolate (one per Isolate): lifecycle + the isolate-level ops
1561
1560
  // (terminate, microtask checkpoint, dynamic import). eval/call/etc. live on
1562
1561
  // Context, which an Isolate hands out (a v8::Context).
1563
- #[magnus::wrap(class = "RustyRacer::Isolate")]
1562
+ #[derive(TypedData)]
1563
+ #[magnus(class = "RustyRacer::Isolate", mark)]
1564
1564
  struct Isolate {
1565
1565
  core: Arc<Core>,
1566
1566
  }
@@ -1569,7 +1569,8 @@ struct Isolate {
1569
1569
  // extra one (id >= 1, via Isolate#create_context). eval/call/attach/
1570
1570
  // compile_module run here. Its own `disposed` is per-context; the Core's is
1571
1571
  // isolate-level.
1572
- #[magnus::wrap(class = "RustyRacer::Context")]
1572
+ #[derive(TypedData)]
1573
+ #[magnus(class = "RustyRacer::Context", mark)]
1573
1574
  struct Context {
1574
1575
  core: Arc<Core>,
1575
1576
  id: i32,
@@ -1584,7 +1585,8 @@ struct Snapshot {
1584
1585
  }
1585
1586
 
1586
1587
  // Context#compile_module result: a handle to a V8 module (by id).
1587
- #[magnus::wrap(class = "RustyRacer::Module")]
1588
+ #[derive(TypedData)]
1589
+ #[magnus(class = "RustyRacer::Module", mark)]
1588
1590
  struct JsModule {
1589
1591
  core: Arc<Core>,
1590
1592
  module_id: i32,
@@ -1596,7 +1598,8 @@ struct JsModule {
1596
1598
  }
1597
1599
 
1598
1600
  // Context#compile result: a handle to a classic compiled script (by id).
1599
- #[magnus::wrap(class = "RustyRacer::Script")]
1601
+ #[derive(TypedData)]
1602
+ #[magnus(class = "RustyRacer::Script", mark)]
1600
1603
  struct Script {
1601
1604
  core: Arc<Core>,
1602
1605
  script_id: i32,
@@ -1605,11 +1608,49 @@ struct Script {
1605
1608
  cache_rejected: bool,
1606
1609
  }
1607
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
+
1608
1630
  // Set true once V8 is initialized; Platform.set_flags! refuses after that
1609
1631
  // (flags must be set before V8::initialize), like mini_racer's
1610
1632
  // PlatformAlreadyInitialized.
1611
1633
  static V8_INITED: AtomicBool = AtomicBool::new(false);
1612
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
+
1613
1654
  fn init_v8() {
1614
1655
  static ONCE: Once = Once::new();
1615
1656
  ONCE.call_once(|| {
@@ -1622,6 +1663,10 @@ fn init_v8() {
1622
1663
  Ordering::Relaxed,
1623
1664
  );
1624
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()));
1625
1670
  v8::V8::initialize_platform(platform);
1626
1671
  v8::V8::initialize();
1627
1672
  V8_INITED.store(true, Ordering::SeqCst);
@@ -1959,8 +2004,22 @@ unsafe extern "C" fn promise_reject_cb(message: v8::PromiseRejectMessage) {
1959
2004
  // reset and create_context so realms can't drift apart. Returns the context
1960
2005
  // Global AND its dedicated microtask queue; the caller owns the queue in
1961
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
+
1962
2020
  fn new_realm(
1963
2021
  scope: &mut v8::PinScope<'_, '_, ()>,
2022
+ context_id: i32,
1964
2023
  ) -> (v8::Global<v8::Context>, v8::UniqueRef<v8::MicrotaskQueue>) {
1965
2024
  // Explicit policy like the isolate's: rusty drives every drain by hand
1966
2025
  // (auto_drain / NS.drainMicrotasks), so V8 must never auto-run this queue.
@@ -2012,6 +2071,15 @@ fn new_realm(
2012
2071
  if let Some(name) = host_namespace {
2013
2072
  install_host_namespace(scope, &fresh, &name);
2014
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
+ }
2015
2083
  (fresh, queue)
2016
2084
  }
2017
2085
 
@@ -2225,13 +2293,13 @@ impl Isolate {
2225
2293
  // Core keeps its id + a stable raw ptr so `run` can open scopes on it. The
2226
2294
  // isolate is thread-bound from here on (every op asserts the owner thread).
2227
2295
  fn new(
2228
- _ruby: &Ruby,
2296
+ ruby: &Ruby,
2229
2297
  host_namespace: Option<String>,
2230
2298
  snapshot: Option<magnus::typed_data::Obj<Snapshot>>,
2231
2299
  timeout_ms: u64,
2232
2300
  memory_limit: usize,
2233
2301
  explicit_microtasks: bool,
2234
- ) -> Result<Self, Error> {
2302
+ ) -> Result<magnus::typed_data::Obj<Self>, Error> {
2235
2303
  init_v8();
2236
2304
  // A snapshot blob bakes globalThis state in: the first Context::new (in
2237
2305
  // new_realm below) deserializes that default context for free.
@@ -2277,7 +2345,7 @@ impl Isolate {
2277
2345
  // namespace from the slot (seeded above).
2278
2346
  {
2279
2347
  v8::scope!(let scope, &mut isolate);
2280
- let (main_context, main_queue) = new_realm(scope);
2348
+ let (main_context, main_queue) = new_realm(scope, 0);
2281
2349
  istate!(scope).realms.main_context = Some(main_context);
2282
2350
  istate!(scope).realms.main_queue = Some(main_queue);
2283
2351
  // The shared graveyard for retired realms' contexts (see V8State).
@@ -2307,6 +2375,10 @@ impl Isolate {
2307
2375
  // owner check.
2308
2376
  use magnus::rb_sys::{AsRawValue, FromRawValue};
2309
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();
2310
2382
  let core = Arc::new_cyclic(|me| Core {
2311
2383
  me: me.clone(),
2312
2384
  shared: Mutex::new(Shared {
@@ -2319,12 +2391,11 @@ impl Isolate {
2319
2391
  iso_ptr,
2320
2392
  scan_start_field: std::sync::atomic::AtomicUsize::new(0),
2321
2393
  installed_stack_limit: std::sync::atomic::AtomicUsize::new(0),
2322
- pending_fatal: std::sync::atomic::AtomicUsize::new(0),
2323
2394
  depth: std::sync::atomic::AtomicU32::new(0),
2395
+ roots: Opaque::from(roots),
2324
2396
  procs: Mutex::new(ProcTable::default()),
2325
2397
  default_timeout_ms: timeout_ms,
2326
2398
  memory_limit,
2327
- dynamic_import_resolver: Mutex::new(None),
2328
2399
  watchdog,
2329
2400
  watchdog_join: Mutex::new(Some(watchdog_join)),
2330
2401
  });
@@ -2347,7 +2418,14 @@ impl Isolate {
2347
2418
  // enter/exit stack (an out-of-order drop aborts). Instead each op enters
2348
2419
  // around its run (Core::run) and teardown re-enters just before drop.
2349
2420
  unsafe { (*core.iso_ptr.0).exit() };
2350
- 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)
2351
2429
  }
2352
2430
  }
2353
2431
 
@@ -2580,58 +2658,10 @@ impl Core {
2580
2658
  }
2581
2659
  }
2582
2660
 
2583
- // Finish a Thread#kill that a callback had to swallow (see note_fatal). This
2584
- // is a longjmp — the same rb_jump_tag magnus performs for an Error::Jump
2585
- // handed back to it — so nothing between here and Ruby runs Drop, and it may
2586
- // only be called where no frame in between still owns anything that matters.
2587
- // That rules out the end of `run`: instantiate_module parks the op's resolver
2588
- // across the call and restores it afterwards, and skipping THAT would leave a
2589
- // dead op's resolver parked for a later dynamic import to call. The end of
2590
- // reply_value is past every such restore, and reaching it is what every
2591
- // operation that can run Ruby at all does.
2592
- //
2593
- // An operation that returns Err from `run` — only a panic, which poisons the
2594
- // isolate — doesn't reach it, and the kill is then dropped by whichever
2595
- // operation gets here next (see the Fiber check). Frames above still leak
2596
- // whatever they hold (an argument String, say), which is why every caller with
2597
- // cleanup of its own runs that cleanup BEFORE calling this.
2598
- fn resume_pending_fatal<T>(&self, outcome: Result<T, Error>) -> Result<T, Error> {
2599
- let pending = self.pending_fatal.swap(0, Ordering::SeqCst);
2600
- // Nothing pending — the case every op takes, and one atomic load.
2601
- if pending == 0 {
2602
- return outcome;
2603
- }
2604
- // A kill belongs to the Fiber whose callback swallowed it, and finishing
2605
- // it anywhere else would kill an unrelated caller — the main thread, say,
2606
- // mid-eval, with no exception and no message. A mismatch is an ordinary
2607
- // outcome, not a should-not-happen: when the script CATCHES the error the
2608
- // killed callback threw, that operation may never end, and the next one to
2609
- // get here is somebody else's. Drop the kill rather than deliver it to the
2610
- // wrong Fiber. (CRuby's own Fiber#kill bookkeeping still terminates such a
2611
- // Fiber on its next resume; only its return value is lost.)
2612
- if pending != current_fiber_id() {
2613
- return outcome;
2614
- }
2615
- drop(outcome);
2616
- // enum ruby_tag_type's RUBY_TAG_FATAL; not in rb_sys's bindings
2617
- // (bindgen skips the anonymous enum), and stable since 1.9.
2618
- const RUBY_TAG_FATAL: std::os::raw::c_int = 8;
2619
- unsafe { rb_sys::rb_jump_tag(RUBY_TAG_FATAL) };
2620
- }
2621
-
2622
2661
  // Map a terminal reply to a Ruby value (the common eval/call/run shape).
2623
2662
  // &self so a Terminated outcome can pick up the JS stack the watchdog snapshot
2624
2663
  // captured (see timeout_interrupt / take_timeout_backtrace).
2625
2664
  fn reply_value(&self, ruby: &Ruby, reply: VmReply) -> Result<Value, Error> {
2626
- let outcome = self.reply_value_deferred(ruby, reply);
2627
- // Every operation that can run Ruby — and so can have a Thread#kill
2628
- // swallowed by one of its callbacks — ends here, except the few whose own
2629
- // cleanup has to run first; those call the two halves in order.
2630
- self.resume_pending_fatal(outcome)
2631
- }
2632
-
2633
- // reply_value without finishing a swallowed kill.
2634
- fn reply_value_deferred(&self, ruby: &Ruby, reply: VmReply) -> Result<Value, Error> {
2635
2665
  match reply {
2636
2666
  VmReply::Done(Ok(val)) => jsval_to_ruby(ruby, &val),
2637
2667
  // Clone (don't take): a nested timeout's terminate unwinds the whole op
@@ -2659,74 +2689,67 @@ impl Core {
2659
2689
  .clone()
2660
2690
  }
2661
2691
 
2662
- // A Ruby error raised under a live op cannot be allowed to longjmp — it would
2663
- // cross V8's C++ frames — so every callback catches it and throws it into JS
2664
- // as a message instead. That is right for an exception and wrong for exactly
2665
- // one thing: Thread#kill unwinds with TAG_FATAL, which magnus catches like
2666
- // anything else, and a killed thread that is merely told about it in
2667
- // JavaScript is not killed at all — it goes on to run whatever comes next,
2668
- // while the op's caller is handed a fabricated `RustyRacer::RuntimeError:
2669
- // Error: Fatal`. Remember it here; Core::run re-asserts it the moment the op
2670
- // is over and there are no V8 frames left to cross, which is what magnus
2671
- // would have done with the jump if it could have.
2672
- //
2673
- // Only Fatal. The other jumps (a `break` or `next` out of a host proc) have
2674
- // no live block to return to by the time we could resume them, so they stay
2675
- // what they are today: an error on the JavaScript side.
2676
- //
2677
- // The kill lands at the END of the operation, not at the callback: the
2678
- // callback still has to return through V8, and what it returns is an ordinary
2679
- // JavaScript exception, which the script may catch and carry on from — during
2680
- // which it can call further host functions, running Ruby on the already-killed
2681
- // thread.
2682
- //
2683
- // Terminating the isolate here to make that uncatchable was tried and
2684
- // REJECTED, for a reason worth writing down because the obvious objection is
2685
- // the wrong one. V8 does see it: a termination requested from inside a host
2686
- // callback survives the callback and is observed at the next interrupt check,
2687
- // which is any JavaScript function entry or a loop long enough to exhaust
2688
- // Ignition's interrupt budget — only a degenerate tail of host calls and
2689
- // literals escapes. The problem is what happens when it escapes: the request
2690
- // stays ARMED, and the sweep that clears a stale one runs only at an outermost
2691
- // request (see service_request), which is exactly what a stranded operation
2692
- // leaves the isolate without. It would then terminate an unrelated later
2693
- // operation, which is a worse failure than the one being fixed — and it does
2694
- // not even close the window it was for, since a script looping on host calls
2695
- // alone reaches no interrupt check either.
2696
- fn note_fatal(&self, e: &Error) {
2697
- if !matches!(
2698
- e.error_type(),
2699
- magnus::error::ErrorType::Jump(magnus::error::Tag::Fatal)
2700
- ) {
2701
- return;
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
+ }
2702
2741
  }
2703
- // Which Fiber has to finish the kill: this one, the one the killed
2704
- // callback was running on. Checked again at the other end, so a pending
2705
- // kill can never land on an unrelated caller.
2706
- self.pending_fatal
2707
- .store(current_fiber_id(), Ordering::SeqCst);
2708
- }
2709
-
2710
- // note_fatal + the message the callback will throw, for the map_err sites.
2711
- // Rendering is safe to do here, outside any rb_protect with V8 frames live:
2712
- // magnus formats an exception from its class name and address rather than
2713
- // calling #message or #inspect, so a caller whose own class raises from those
2714
- // (checked) cannot longjmp out of this.
2715
- fn swallow(&self, e: Error) -> String {
2716
- self.note_fatal(&e);
2717
- e.to_string()
2718
2742
  }
2719
2743
 
2720
2744
  fn call_proc(&self, ruby: &Ruby, host_fn_id: usize, args: &[JsVal]) -> Result<JsVal, String> {
2721
- let proc = {
2722
- let procs = self.procs.lock().unwrap();
2723
- procs
2724
- .slots
2725
- .get(host_fn_id)
2726
- .and_then(|slot| slot.proc.as_ref())
2727
- .ok_or("unknown host function")?
2728
- .get()
2729
- };
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")?;
2730
2753
  // Marshal into a Ruby Array, NOT a Vec<Value>: bare Values in a heap Vec
2731
2754
  // are hidden from Ruby's GC mark phase (magnus's own RArray::to_vec doc
2732
2755
  // spells this out). With several args, once arg N is parked in the Vec
@@ -2738,16 +2761,16 @@ impl Core {
2738
2761
  let ruby_args = ruby.ary_new_capa(args.len());
2739
2762
  for v in args {
2740
2763
  ruby_args
2741
- .push(jsval_to_ruby(ruby, v).map_err(|e| self.swallow(e))?)
2742
- .map_err(|e| self.swallow(e))?;
2764
+ .push(jsval_to_ruby(ruby, v).map_err(|e| e.to_string())?)
2765
+ .map_err(|e| e.to_string())?;
2743
2766
  }
2744
2767
  // SAFETY: ruby_args is a live local (so GC keeps it and its elements) and
2745
2768
  // is not mutated while the slice is borrowed — as_slice's contract. A VM
2746
2769
  // op the proc issues re-enters Core::run (depth > 0) directly — no nested
2747
2770
  // frame bookkeeping is needed any more (the call stack IS the nesting).
2748
2771
  let result: Result<Value, Error> = proc.call(unsafe { ruby_args.as_slice() });
2749
- let value = result.map_err(|e| self.swallow(e))?;
2750
- ruby_to_jsval(value).map_err(|e| self.swallow(e))
2772
+ let value = result.map_err(|e| e.to_string())?;
2773
+ ruby_to_jsval(value).map_err(|e| e.to_string())
2751
2774
  }
2752
2775
 
2753
2776
  // Context#call (and call_void). Resolves a dotted function path
@@ -2833,6 +2856,23 @@ impl Core {
2833
2856
  Ok(())
2834
2857
  }
2835
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
+
2836
2876
  fn eval_t(
2837
2877
  &self,
2838
2878
  ruby: &Ruby,
@@ -2862,11 +2902,16 @@ impl Core {
2862
2902
  name: String,
2863
2903
  proc: Proc,
2864
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)?;
2865
2909
  let host_fn_id = self.procs.lock().unwrap().alloc(ProcSlot {
2866
2910
  context_id,
2867
- proc: Some(RootedProc(BoxValue::new(proc))),
2911
+ live: true,
2868
2912
  name: name.clone(),
2869
2913
  });
2914
+ self.root_store(ruby, proc_root_index(host_fn_id), proc)?;
2870
2915
  let reply = self.run(
2871
2916
  ruby,
2872
2917
  Request::Attach {
@@ -2895,20 +2940,31 @@ impl Core {
2895
2940
  if entries.is_empty() {
2896
2941
  return Ok(ruby.qnil().as_value()); // nothing to install, skip the round-trip
2897
2942
  }
2898
- 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)> = {
2899
2945
  let mut procs = self.procs.lock().unwrap();
2900
2946
  entries
2901
2947
  .into_iter()
2902
2948
  .map(|(name, proc)| {
2903
2949
  let id = procs.alloc(ProcSlot {
2904
2950
  context_id,
2905
- proc: Some(RootedProc(BoxValue::new(proc))),
2951
+ live: true,
2906
2952
  name: name.clone(),
2907
2953
  });
2908
- (name, id)
2954
+ (name, id, proc)
2909
2955
  })
2910
2956
  .collect()
2911
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
+ }
2912
2968
  let reply = self.run(
2913
2969
  ruby,
2914
2970
  Request::AttachMany {
@@ -2920,31 +2976,29 @@ impl Core {
2920
2976
  self.reply_value(ruby, reply)
2921
2977
  }
2922
2978
 
2923
- // Release the GC roots of the procs attached into |context_id| — its
2924
- // realm is gone (reset or disposed), so the V8-side functions that
2925
- // referenced them are unreachable. Runs on a Ruby thread (a RootedProc
2926
- // drop unregisters its GC address). The slots stay: host_fn_ids of other
2927
- // realms are indices into the same Vec.
2928
- fn release_context_procs(&self, context_id: i32) {
2929
- 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);
2930
2993
  }
2931
2994
 
2932
2995
  fn reset(&self, ruby: &Ruby, context_id: i32) -> Result<Value, Error> {
2933
2996
  let reply = self.run(ruby, Request::Reset { context_id })?;
2934
- // reply_value_deferred, not reply_value: releasing the procs has to happen
2935
- // before a swallowed kill is finished, or the jump skips it and this
2936
- // realm's procs stay GC-rooted and their slots unrecycled for the life of
2937
- // the isolate — a permanent leak in a process that goes on running, since
2938
- // a killed FIBER leaves its thread alive.
2939
- let out = self.reply_value_deferred(ruby, reply);
2997
+ let out = self.reply_value(ruby, reply)?;
2940
2998
  // Only on success — a refused reset (unknown/suspended realm) keeps
2941
- // its attached fns callable. No `?`: BOTH arms have to reach
2942
- // resume_pending_fatal, or a kill swallowed during a reset that then
2943
- // fails is left for some later operation, which drops it.
2944
- if out.is_ok() {
2945
- self.release_context_procs(context_id);
2946
- }
2947
- self.resume_pending_fatal(out)
2999
+ // its attached fns callable.
3000
+ self.release_context_procs(ruby, context_id);
3001
+ Ok(out)
2948
3002
  }
2949
3003
 
2950
3004
  // Build a new context; returns its id (replied as an Int).
@@ -2956,12 +3010,9 @@ impl Core {
2956
3010
 
2957
3011
  fn dispose_context(&self, ruby: &Ruby, context_id: i32) -> Result<(), Error> {
2958
3012
  let reply = self.run(ruby, Request::DisposeContext { context_id })?;
2959
- // Deferred, and both arms delivered, for the same reasons as reset's.
2960
- let out = self.reply_value_deferred(ruby, reply);
2961
- if out.is_ok() {
2962
- self.release_context_procs(context_id);
2963
- }
2964
- self.resume_pending_fatal(out.map(|_| ()))
3013
+ self.reply_value(ruby, reply)?;
3014
+ self.release_context_procs(ruby, context_id);
3015
+ Ok(())
2965
3016
  }
2966
3017
 
2967
3018
  // Thin ESM primitives. compile_module returns the new module's id.
@@ -3038,10 +3089,7 @@ impl Core {
3038
3089
  // Reclaim THIS op's resolver error and restore the outer op's pair.
3039
3090
  let (_, resolver_err) = self.swap_instantiate(saved_resolve, saved_err);
3040
3091
  if let Some(exc) = resolver_err {
3041
- // Through resume_pending_fatal like the tail below: this return is
3042
- // just as much the end of the op, and skipping it would leave a
3043
- // swallowed kill for some later op to assert.
3044
- return self.resume_pending_fatal(Err(Error::from(*exc)));
3092
+ return Err(Error::from(*exc));
3045
3093
  }
3046
3094
  self.reply_value(ruby, reply?)
3047
3095
  }
@@ -3140,10 +3188,18 @@ impl Core {
3140
3188
  code_cache_from_reply(ruby, reply)
3141
3189
  }
3142
3190
 
3143
- fn set_dynamic_import_resolver(&self, proc: Proc) {
3144
- // The old RootedProc (if any) drops here, unregistering its address —
3145
- // we are on a Ruby thread, so that's GVL-safe.
3146
- *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)
3147
3203
  }
3148
3204
 
3149
3205
  // Terminate whatever is running. IsolateHandle is Send + refcounted —
@@ -3174,12 +3230,16 @@ impl Core {
3174
3230
  // it entered, and OwnedIsolate's Drop asserts `self == GetCurrent()`
3175
3231
  // (then exits). Between ops the isolate is exited, so we must enter here.
3176
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.
3177
3238
  {
3178
3239
  let mut procs = self.procs.lock().unwrap();
3179
3240
  procs.slots.clear();
3180
3241
  procs.free.clear();
3181
3242
  }
3182
- *self.dynamic_import_resolver.lock().unwrap() = None;
3183
3243
  {
3184
3244
  let st = istate!(unsafe { &mut *self.iso_ptr.0 });
3185
3245
  st.realms = V8State::default();
@@ -3228,6 +3288,10 @@ impl Core {
3228
3288
  shared.disposed = true;
3229
3289
  }
3230
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();
3231
3295
  Ok(())
3232
3296
  }
3233
3297
  }
@@ -3266,7 +3330,7 @@ impl Drop for Core {
3266
3330
  so it cannot be disposed and is leaked.\n\
3267
3331
  The usual cause is a host function that yielded out of a Fiber \
3268
3332
  which was never resumed;\n\
3269
- `Fiber#kill` unwinds one cleanly if you still hold it. See\n\
3333
+ `Fiber#kill` recovers one if you still hold it. See\n\
3270
3334
  https://github.com/ursm/rusty_racer#fibers\n"
3271
3335
  );
3272
3336
  LEAKED_ISOLATES.fetch_add(1, Ordering::Relaxed);
@@ -3343,9 +3407,15 @@ impl Isolate {
3343
3407
  fn low_memory_notification(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
3344
3408
  rb_self.core.low_memory_notification(ruby)
3345
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
+ }
3346
3416
  // dynamic_import_resolver = ->(specifier, referrer_url) { module } for import().
3347
- fn set_dynamic_import_resolver(rb_self: &Self, proc: Proc) {
3348
- 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)
3349
3419
  }
3350
3420
  fn dispose(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
3351
3421
  rb_self.core.dispose(ruby)
@@ -3851,6 +3921,16 @@ fn resolve_module_via_ruby(
3851
3921
 
3852
3922
  #[magnus::init]
3853
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> {
3854
3934
  let module = ruby.define_module("RustyRacer")?;
3855
3935
 
3856
3936
  // The isolate (VM) + its isolate-level ops; hands out Contexts.
@@ -3874,6 +3954,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
3874
3954
  "low_memory_notification",
3875
3955
  method!(Isolate::low_memory_notification, 0),
3876
3956
  )?;
3957
+ isolate.define_method("pump_message_loop", method!(Isolate::pump_message_loop, 0))?;
3877
3958
  isolate.define_method("dispose", method!(Isolate::dispose, 0))?;
3878
3959
  isolate.define_method("disposed?", method!(Isolate::disposed, 0))?;
3879
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.2"
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.2
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keita Urashima
8
- autorequire:
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
@@ -54,7 +54,7 @@ metadata:
54
54
  source_code_uri: https://github.com/ursm/rusty_racer
55
55
  bug_tracker_uri: https://github.com/ursm/rusty_racer/issues
56
56
  rubygems_mfa_required: 'true'
57
- post_install_message:
57
+ post_install_message:
58
58
  rdoc_options: []
59
59
  require_paths:
60
60
  - lib
@@ -70,7 +70,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
70
70
  version: '0'
71
71
  requirements: []
72
72
  rubygems_version: 3.5.22
73
- signing_key:
73
+ signing_key:
74
74
  specification_version: 4
75
75
  summary: Embed V8 in Ruby via rusty_v8 + Magnus (rb-sys)
76
76
  test_files: []