rusty_racer 0.2.3 → 0.2.5

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.
@@ -31,28 +31,48 @@
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;
41
60
  use std::ffi::c_void;
42
61
  use std::ptr::null_mut;
43
- use std::sync::atomic::{AtomicBool, Ordering};
62
+ use std::sync::atomic::{AtomicBool, AtomicU64, 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;
53
73
  use marshal::{JsVal, js_to_jsval, jsval_to_js, jsval_to_ruby, ruby_to_jsval};
54
74
  mod ops;
55
- use ops::{Compiled, Request, VmReply, run_source, service_request};
75
+ use ops::{Compiled, Completion, Request, VmReply, run_source, service_request};
56
76
  mod stack;
57
77
  use stack::{STACK_DEBUG, StackScope, current_real_isolate, discover_scan_start_field};
58
78
  mod watchdog;
@@ -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,22 +146,58 @@ 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, which
150
+ // occupant of the slot it is (`HostFnKey`), and the name it was attached under,
151
+ // kept only so a diagnostic can say which host function is involved (one String
152
+ // per attach, never per call). The PROC itself lives in the roots array at
153
+ // `proc_root_index(index)`, 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,
157
+ generation: u32,
132
158
  name: String,
133
159
  }
134
160
 
135
- // The isolate's host-fn registry, indexed by host_fn_id. `free` lists slots
161
+ // What a host function's V8 External carries: its slot, and the slot's generation
162
+ // when it was attached. A slot is reused once its realm is released, but a function
163
+ // of that realm can outlive the release — the embedder may still hold its document
164
+ // — so a call has to prove it is the slot's CURRENT occupant, or it would run
165
+ // whichever proc the slot was handed to next.
166
+ #[derive(Clone, Copy, Debug, PartialEq)]
167
+ pub(crate) struct HostFnKey {
168
+ index: u32,
169
+ generation: u32,
170
+ }
171
+
172
+ impl HostFnKey {
173
+ // (…both halves in one pointer-sized word: V8 builds for 64-bit targets alone.
174
+ // The word is stored as is only while V8's sandbox is off — the v8 crate's
175
+ // opt-in `v8_enable_sandbox`, which this crate leaves off; a sandboxed External
176
+ // goes through a pointer table that keeps tags in the high bits.)
177
+ pub(crate) fn to_external(self) -> *mut c_void {
178
+ const { assert!(usize::BITS >= 64) };
179
+ (((self.generation as u64) << 32) | self.index as u64) as usize as *mut c_void
180
+ }
181
+
182
+ fn from_external(value: *mut c_void) -> Self {
183
+ let value = value as usize as u64;
184
+ Self {
185
+ index: value as u32,
186
+ generation: (value >> 32) as u32,
187
+ }
188
+ }
189
+
190
+ fn index(self) -> usize {
191
+ self.index as usize
192
+ }
193
+ }
194
+
195
+ // The isolate's host-fn registry, indexed by HostFnKey::index. `free` lists slots
136
196
  // emptied by release_context_procs so a later attach reuses them instead of
137
197
  // growing `slots` forever — a long-lived process that re-navigates iframe
138
- // realms would otherwise leak one slot per attach. Reuse is safe because a slot
139
- // is only freed when its realm (and the V8 functions that carried its id) is
140
- // gone, so no stale V8 external can still call a recycled id.
198
+ // realms would otherwise leak one slot per attach. A reused slot's generation
199
+ // moves on, so a function of the released realm that is still reachable calls
200
+ // a key the table no longer answers (`current`) instead of the new occupant.
141
201
  #[derive(Default)]
142
202
  struct ProcTable {
143
203
  slots: Vec<ProcSlot>,
@@ -145,30 +205,67 @@ struct ProcTable {
145
205
  }
146
206
 
147
207
  impl ProcTable {
148
- // Take a free slot if one exists, else grow. Returns the host_fn_id.
149
- fn alloc(&mut self, slot: ProcSlot) -> usize {
150
- if let Some(id) = self.free.pop() {
151
- self.slots[id] = slot;
152
- id
153
- } else {
154
- self.slots.push(slot);
155
- self.slots.len() - 1
208
+ // Take a free slot if one exists, its generation the next, else grow.
209
+ fn alloc(&mut self, context_id: i32, name: String) -> HostFnKey {
210
+ let (index, generation) = match self.free.pop() {
211
+ Some(index) => (index, self.slots[index].generation.wrapping_add(1)),
212
+ None => {
213
+ self.slots.push(ProcSlot {
214
+ context_id,
215
+ live: false,
216
+ generation: 0,
217
+ name: String::new(),
218
+ });
219
+ (self.slots.len() - 1, 0)
220
+ }
221
+ };
222
+ self.slots[index] = ProcSlot {
223
+ context_id,
224
+ live: true,
225
+ generation,
226
+ name,
227
+ };
228
+ HostFnKey {
229
+ index: index as u32,
230
+ generation,
156
231
  }
157
232
  }
158
233
 
234
+ // Whether `key` is its slot's live occupant.
235
+ fn current(&self, key: HostFnKey) -> bool {
236
+ self.slots
237
+ .get(key.index())
238
+ .is_some_and(|slot| slot.live && slot.generation == key.generation)
239
+ }
240
+
159
241
  // 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) {
242
+ // returning their ids so the caller can clear their roots-array entries (a Ruby
243
+ // write, which this table never does: it is locked on paths a GC must not have to
244
+ // wait behind) and only THEN put them on the free list. Idempotent: an
245
+ // already-released slot is skipped, so it can't be double-freed.
246
+ fn release(&mut self, context_id: i32) -> Vec<usize> {
247
+ let mut released = Vec::new();
163
248
  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);
249
+ if slot.context_id == context_id && slot.live {
250
+ slot.live = false;
251
+ released.push(id);
167
252
  }
168
253
  }
254
+ released
169
255
  }
170
256
  }
171
257
 
258
+ // The isolate's ROOTS ARRAY: every Ruby object the isolate must keep alive for as
259
+ // long as a wrapper of it is reachable, in one place the GC can be shown without
260
+ // taking any Rust lock (a mark that had to lock `procs` could deadlock against
261
+ // the thread whose allocation triggered the GC while holding it). Slot 0 is the
262
+ // dynamic-import resolver; the attached host fns follow, one per proc slot.
263
+ const ROOT_IMPORT_RESOLVER: isize = 0;
264
+ const ROOT_FIRST_PROC: isize = 1;
265
+ fn proc_root_index(index: usize) -> isize {
266
+ ROOT_FIRST_PROC + index as isize
267
+ }
268
+
172
269
  // Look up a RustyRacer::<name> exception class at raise time. The classes are
173
270
  // defined in lib/rusty_racer.rb (loaded after this extension), so they exist
174
271
  // by the time any eval can raise. Falls back to Ruby's RuntimeError.
@@ -181,13 +278,18 @@ fn err_class(ruby: &Ruby, name: &str) -> ExceptionClass {
181
278
 
182
279
  #[derive(Debug)]
183
280
  enum VmError {
184
- Parse(String), // compile-time failure -> RustyRacer::ParseError
281
+ // compile-time failure -> RustyRacer::ParseError; the SyntaxError V8 made of
282
+ // it kept as for a JsError (`keep_thrown`)
283
+ Parse(String, Option<u64>),
185
284
  Runtime(String), // internal failure (no JS stack) -> RustyRacer::RuntimeError
186
285
  // A thrown JS exception: its message plus the JS stack frames, which become
187
- // the Ruby exception's backtrace -> RustyRacer::RuntimeError.
286
+ // the Ruby exception's backtrace -> RustyRacer::RuntimeError — and the value
287
+ // thrown, kept (`keep_thrown`), so a host fn the error propagates out of
288
+ // throws it on.
188
289
  JsError {
189
290
  message: String,
190
291
  backtrace: Vec<String>,
292
+ thrown: Option<u64>,
191
293
  },
192
294
  Terminated, // watchdog/stop -> RustyRacer::ScriptTerminatedError
193
295
  OutOfMemory, // memory_limit hit -> RustyRacer::V8OutOfMemoryError
@@ -290,9 +392,9 @@ where
290
392
  // instead of saying "a host function". Rendered only on that path, so an
291
393
  // ordinary callback pays for a pointer and nothing else.
292
394
  enum RubyCall<'a> {
293
- // An attach()'d host function, by host_fn_id — the name it was attached
395
+ // An attach()'d host function, by its key — the name it was attached
294
396
  // under lives in the isolate's proc table.
295
- HostFn(usize),
397
+ HostFn(HostFnKey),
296
398
  // A module resolver (a static instantiate block, a dynamic auto-link, or
297
399
  // import()), named by the specifier it was handed.
298
400
  Resolver(&'a str),
@@ -301,15 +403,17 @@ enum RubyCall<'a> {
301
403
  impl RubyCall<'_> {
302
404
  fn describe(&self, core: &Core) -> String {
303
405
  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.
307
- Self::HostFn(id) => match core
308
- .procs
309
- .try_lock()
310
- .ok()
311
- .and_then(|procs| procs.slots.get(*id).map(|slot| slot.name.clone()))
312
- {
406
+ // try_lock, not lock: nothing holds this across a callback (`call_proc`
407
+ // holds it for one comparison, before the proc runs), but blocking here
408
+ // would turn a diagnostic into a hang. A slot handed on since names
409
+ // nobody: its name is another function's.
410
+ Self::HostFn(id) => match core.procs.try_lock().ok().and_then(|procs| {
411
+ procs
412
+ .slots
413
+ .get(id.index())
414
+ .filter(|slot| slot.generation == id.generation)
415
+ .map(|slot| slot.name.clone())
416
+ }) {
313
417
  Some(name) => format!("The host function `{name}`"),
314
418
  None => "A host function".to_string(),
315
419
  },
@@ -506,8 +610,8 @@ fn host_fn_callback(
506
610
  args: v8::FunctionCallbackArguments<'_>,
507
611
  mut rv: v8::ReturnValue<'_, v8::Value>,
508
612
  ) {
509
- let host_fn_id = match v8::Local::<v8::External>::try_from(args.data()) {
510
- Ok(e) => e.value() as usize,
613
+ let host_fn = match v8::Local::<v8::External>::try_from(args.data()) {
614
+ Ok(e) => HostFnKey::from_external(e.value()),
511
615
  Err(_) => return,
512
616
  };
513
617
  let mut js_args = Vec::with_capacity(args.length() as usize);
@@ -534,20 +638,22 @@ fn host_fn_callback(
534
638
  return;
535
639
  }
536
640
  let core = unsafe { &*core_ptr };
537
- let result: Result<JsVal, String> = with_gvl(core, RubyCall::HostFn(host_fn_id), || {
641
+ let result: Result<JsVal, HostFnFailure> = with_gvl(core, RubyCall::HostFn(host_fn), || {
538
642
  let ruby = Ruby::get().unwrap();
539
643
  // rb_protect so a Ruby exception raised during arg/return marshalling
540
644
  // (ary_new_capa / jsval_to_ruby / ruby_to_jsval can raise on OOM etc.)
541
645
  // is CAUGHT here instead of longjmp-ing through V8's C++ frames. The
542
646
  // proc's own raise is already a magnus Err; this covers the rest.
543
647
  use magnus::rb_sys::AsRawValue;
544
- let mut out: Option<Result<JsVal, String>> = None;
648
+ let mut out: Option<Result<JsVal, HostFnFailure>> = None;
545
649
  match magnus::rb_sys::protect(|| {
546
- out = Some(core.call_proc(&ruby, host_fn_id, &js_args));
650
+ out = Some(core.call_proc(&ruby, host_fn, &js_args));
547
651
  ruby.qnil().as_raw()
548
652
  }) {
549
- Ok(_) => out.unwrap_or_else(|| Err("host function did not complete".into())),
550
- Err(e) => Err(format!("{e}")),
653
+ Ok(_) => {
654
+ out.unwrap_or_else(|| Err("host function did not complete".to_string().into()))
655
+ }
656
+ Err(e) => Err(format!("{e}").into()),
551
657
  }
552
658
  });
553
659
  match result {
@@ -555,8 +661,30 @@ fn host_fn_callback(
555
661
  let v = jsval_to_js(scope, val);
556
662
  rv.set(v);
557
663
  }
558
- // The proc raised (or marshalling failed): surface as a JS exception.
559
- Err(message) => throw_js_error(scope, &message),
664
+ // The proc raised (or marshalling failed): surface as a JS exception —
665
+ // the value JS threw, where the error is one a JS throw became.
666
+ Err(failure) => match failure.thrown.and_then(|serial| take_thrown(scope, serial)) {
667
+ Some(thrown) => {
668
+ scope.throw_exception(thrown);
669
+ }
670
+ None => throw_js_error(scope, &failure.message),
671
+ },
672
+ }
673
+ }
674
+
675
+ // How a host fn failed: the Ruby error's text, and the serial of the JS value
676
+ // it carries when a JS throw is what it became (`keep_thrown`).
677
+ struct HostFnFailure {
678
+ message: String,
679
+ thrown: Option<u64>,
680
+ }
681
+
682
+ impl From<String> for HostFnFailure {
683
+ fn from(message: String) -> Self {
684
+ HostFnFailure {
685
+ message,
686
+ thrown: None,
687
+ }
560
688
  }
561
689
  }
562
690
 
@@ -656,7 +784,87 @@ fn capture_js_error(
656
784
  let backtrace = stack_str
657
785
  .map(|s| parse_js_stack(s.as_str()))
658
786
  .unwrap_or_default();
659
- VmError::JsError { message, backtrace }
787
+ let thrown = keep_thrown(scope, exception);
788
+ VmError::JsError {
789
+ message,
790
+ backtrace,
791
+ thrown,
792
+ }
793
+ }
794
+
795
+ // A value JS failed with that no TryCatch caught — an errored module's
796
+ // exception, a rejected promise's reason — as a JsError: its text, no stack
797
+ // frames, and the value kept as a thrown one is.
798
+ fn rejected_with(scope: &mut v8::PinScope<'_, '_>, value: v8::Local<v8::Value>) -> VmError {
799
+ VmError::JsError {
800
+ message: value.to_rust_string_lossy(scope),
801
+ backtrace: vec![],
802
+ thrown: keep_thrown(scope, Some(value)),
803
+ }
804
+ }
805
+
806
+ // A compile failure as a ParseError: V8's message, the location it recorded
807
+ // (always the file, the line when it has one), and the SyntaxError kept as a
808
+ // thrown value is.
809
+ fn parse_error(
810
+ scope: &mut v8::PinScope<'_, '_>,
811
+ exception: Option<v8::Local<v8::Value>>,
812
+ message: Option<v8::Local<v8::Message>>,
813
+ filename: &str,
814
+ fallback: &str,
815
+ ) -> VmError {
816
+ let msg = exception
817
+ .map(|e| e.to_rust_string_lossy(scope))
818
+ .unwrap_or_else(|| fallback.to_string());
819
+ let res = message
820
+ .and_then(|m| m.get_script_resource_name(scope))
821
+ .filter(|v| v.is_string())
822
+ .map(|v| v.to_rust_string_lossy(scope))
823
+ .unwrap_or_else(|| filename.to_string());
824
+ let loc = match message.and_then(|m| m.get_line_number(scope)) {
825
+ Some(line) => format!(" at {res}:{line}"),
826
+ None => format!(" at {res}"),
827
+ };
828
+ VmError::Parse(format!("{msg}{loc}"), keep_thrown(scope, exception))
829
+ }
830
+
831
+ // Keep the value JS threw, so a host fn that lets the Ruby error it became
832
+ // propagate throws THAT value on — its identity, class and stack — rather than a
833
+ // fresh Error made of its text: a script a host fn ran for JS fails in JS as the
834
+ // script did. One slot per isolate, since only the latest thrown value can still
835
+ // be on its way back out, and only until the outermost op returns (`Core::run`),
836
+ // since only a host fn inside it can still ask — kept longer, it would pin the
837
+ // value, and its realm, for as long as nothing else threw. The serial (carried
838
+ // by the Ruby exception, see `carrying_thrown`) tells it from a stale one, and is
839
+ // drawn process-wide, so an error another isolate's op raised never matches this
840
+ // one's slot: its value cannot cross isolates. None on an isolate with no state
841
+ // (the snapshot creator's), where no host fn can ask.
842
+ fn keep_thrown(
843
+ scope: &mut v8::PinScope<'_, '_>,
844
+ exception: Option<v8::Local<v8::Value>>,
845
+ ) -> Option<u64> {
846
+ static SERIAL: AtomicU64 = AtomicU64::new(0);
847
+ let exception = exception?;
848
+ let kept = v8::Global::new(scope, exception);
849
+ let state = scope.get_slot_mut::<IsolateState>()?;
850
+ let serial = SERIAL.fetch_add(1, Ordering::Relaxed) + 1;
851
+ state.thrown = Some((serial, kept));
852
+ Some(serial)
853
+ }
854
+
855
+ // The Ruby exception's kept thrown value (`keep_thrown`), when it still is the
856
+ // latest: taken, as it is thrown once.
857
+ fn take_thrown<'s>(
858
+ scope: &mut v8::PinScope<'s, '_>,
859
+ serial: u64,
860
+ ) -> Option<v8::Local<'s, v8::Value>> {
861
+ let state = scope.get_slot_mut::<IsolateState>()?;
862
+ let (kept_serial, _) = state.thrown.as_ref()?;
863
+ if *kept_serial != serial {
864
+ return None;
865
+ }
866
+ let (_, kept) = state.thrown.take()?;
867
+ Some(v8::Local::new(scope, kept))
660
868
  }
661
869
 
662
870
  // ---------------------------------------------------------------------------
@@ -824,6 +1032,8 @@ struct IsolateState {
824
1032
  // the ScriptTerminatedError can name what was running. Taken (cleared) when the
825
1033
  // terminated op's error is built; None for any non-timeout outcome.
826
1034
  timeout_backtrace: Option<Vec<String>>,
1035
+ // The latest value JS threw out of an op, with its serial (`keep_thrown`).
1036
+ thrown: Option<(u64, v8::Global<v8::Value>)>,
827
1037
  }
828
1038
 
829
1039
  impl IsolateState {
@@ -848,6 +1058,7 @@ impl IsolateState {
848
1058
  oom_fired: false,
849
1059
  oom_initial_limit: 0,
850
1060
  timeout_backtrace: None,
1061
+ thrown: None,
851
1062
  }
852
1063
  }
853
1064
  }
@@ -933,7 +1144,7 @@ fn transfer_registry() -> &'static Mutex<HashMap<u64, SendBackingStore>> {
933
1144
  // Monotonic token source. Starts at 1 so 0 is free as the JS-side "not
934
1145
  // transferable — fall back to a copy" sentinel. A token is f64-exact for ~2^53
935
1146
  // transfers, which a process will never reach.
936
- static NEXT_TRANSFER_TOKEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
1147
+ static NEXT_TRANSFER_TOKEN: AtomicU64 = AtomicU64::new(1);
937
1148
 
938
1149
  // Drain EVERY realm's microtask queue once. Each realm has its own queue (see
939
1150
  // V8State::queues), so a single scope.perform_microtask_checkpoint() — which only
@@ -1118,13 +1329,7 @@ fn resolve_imported<'s>(
1118
1329
  }
1119
1330
  }
1120
1331
  None => {
1121
- let resolver = core
1122
- .dynamic_import_resolver
1123
- .lock()
1124
- .unwrap()
1125
- .as_ref()
1126
- .map(|r| r.get());
1127
- match resolver {
1332
+ match core.dynamic_import_resolver() {
1128
1333
  Some(p) => match with_gvl(core, RubyCall::Resolver(&spec), || {
1129
1334
  resolve_module_via_ruby(core, p, &spec, &ref_url, Some(here.unwrap_or(0)))
1130
1335
  // Rendering the message reads the Ruby exception, so it too
@@ -1235,13 +1440,7 @@ fn dynamic_import_cb<'s>(
1235
1440
  return Some(promise);
1236
1441
  }
1237
1442
  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 {
1443
+ let id = match core.dynamic_import_resolver() {
1245
1444
  // A raising resolver only fails the import() (it rejects generically);
1246
1445
  // it must NOT abort the surrounding eval, so swallow the Err here.
1247
1446
  Some(p) => with_gvl(core, RubyCall::Resolver(&spec), || {
@@ -1500,10 +1699,16 @@ struct Core {
1500
1699
  // stack (bootstrap via callback_scope! onto the ambient scope). Bumped around
1501
1700
  // each `run`.
1502
1701
  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.
1702
+ // Everything this isolate keeps alive on the Ruby side — the attached host fns
1703
+ // and the dynamic-import resolver — in one Array the wrappers MARK (and pin).
1704
+ // See the module header for why these must not be GC roots; ROOT_* for the
1705
+ // layout. Read on the V8 side without the GVL (the import hooks), which is
1706
+ // sound because the entries are pinned and nothing here allocates or raises.
1707
+ roots: Opaque<RArray>,
1708
+ // A HostFnKey indexes ProcTable.slots — bookkeeping only (the proc itself is in
1709
+ // `roots`). Mutex (uncontended — single owner thread) so host_fn_callback can
1710
+ // reach it through Core (via the slot's core_ptr) while a &Core method also
1711
+ // holds it. reset/dispose releases the slots and recycles them.
1507
1712
  procs: Mutex<ProcTable>,
1508
1713
  // Default per-eval/call timeout (ms); 0 = none. eval(timeout_ms:)'s explicit
1509
1714
  // value overrides it. Guards against an in-V8 infinite loop without a watchdog.
@@ -1515,9 +1720,6 @@ struct Core {
1515
1720
  // the ceiling after each OOM (to this when set, else V8's captured default — see
1516
1721
  // oom_initial_limit). Space-axis twin of default_timeout_ms.
1517
1722
  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
1723
  // The watchdog (armed per timed op, fires TerminateExecution via the handle)
1522
1724
  // and its thread's join handle, held here so dispose/Drop — which run on the
1523
1725
  // owner thread with no scope — can stop and join it before the isolate drops.
@@ -1528,7 +1730,8 @@ struct Core {
1528
1730
  // The V8 isolate (one per Isolate): lifecycle + the isolate-level ops
1529
1731
  // (terminate, microtask checkpoint, dynamic import). eval/call/etc. live on
1530
1732
  // Context, which an Isolate hands out (a v8::Context).
1531
- #[magnus::wrap(class = "RustyRacer::Isolate")]
1733
+ #[derive(TypedData)]
1734
+ #[magnus(class = "RustyRacer::Isolate", mark)]
1532
1735
  struct Isolate {
1533
1736
  core: Arc<Core>,
1534
1737
  }
@@ -1537,7 +1740,8 @@ struct Isolate {
1537
1740
  // extra one (id >= 1, via Isolate#create_context). eval/call/attach/
1538
1741
  // compile_module run here. Its own `disposed` is per-context; the Core's is
1539
1742
  // isolate-level.
1540
- #[magnus::wrap(class = "RustyRacer::Context")]
1743
+ #[derive(TypedData)]
1744
+ #[magnus(class = "RustyRacer::Context", mark)]
1541
1745
  struct Context {
1542
1746
  core: Arc<Core>,
1543
1747
  id: i32,
@@ -1552,7 +1756,8 @@ struct Snapshot {
1552
1756
  }
1553
1757
 
1554
1758
  // Context#compile_module result: a handle to a V8 module (by id).
1555
- #[magnus::wrap(class = "RustyRacer::Module")]
1759
+ #[derive(TypedData)]
1760
+ #[magnus(class = "RustyRacer::Module", mark)]
1556
1761
  struct JsModule {
1557
1762
  core: Arc<Core>,
1558
1763
  module_id: i32,
@@ -1564,7 +1769,8 @@ struct JsModule {
1564
1769
  }
1565
1770
 
1566
1771
  // Context#compile result: a handle to a classic compiled script (by id).
1567
- #[magnus::wrap(class = "RustyRacer::Script")]
1772
+ #[derive(TypedData)]
1773
+ #[magnus(class = "RustyRacer::Script", mark)]
1568
1774
  struct Script {
1569
1775
  core: Arc<Core>,
1570
1776
  script_id: i32,
@@ -1573,11 +1779,49 @@ struct Script {
1573
1779
  cache_rejected: bool,
1574
1780
  }
1575
1781
 
1782
+ // The four wrappers that hold an Arc<Core> all mark the isolate's roots, because any
1783
+ // one of them may be the last one reachable: Ruby code is free to keep only the
1784
+ // Context a `Isolate#context` handed it, or only a Module, and the host fns must
1785
+ // stay callable through it. Marking the same array four times costs nothing (the GC
1786
+ // stops at an already-marked object); missing it once would free a live proc.
1787
+ macro_rules! marks_core_roots {
1788
+ ($t:ty) => {
1789
+ impl DataTypeFunctions for $t {
1790
+ fn mark(&self, marker: &gc::Marker) {
1791
+ self.core.mark_roots(marker);
1792
+ }
1793
+ }
1794
+ };
1795
+ }
1796
+ marks_core_roots!(Isolate);
1797
+ marks_core_roots!(Context);
1798
+ marks_core_roots!(JsModule);
1799
+ marks_core_roots!(Script);
1800
+
1576
1801
  // Set true once V8 is initialized; Platform.set_flags! refuses after that
1577
1802
  // (flags must be set before V8::initialize), like mini_racer's
1578
1803
  // PlatformAlreadyInitialized.
1579
1804
  static V8_INITED: AtomicBool = AtomicBool::new(false);
1580
1805
 
1806
+ // The shared default platform, kept so ops can pump its foreground task queue (see
1807
+ // `platform()` / op_pump_message_loop). V8 posts FinalizationRegistry cleanup — and other
1808
+ // deferred foreground work — as platform tasks that only run when the embedder pumps the
1809
+ // message loop; without a handle to the platform we could never drive them. The platform is
1810
+ // process-global and thread-safe by V8's own contract (it is shared across every isolate on
1811
+ // every owner thread), which is what makes the SharedRef sound to park in a `static`.
1812
+ struct SharedPlatform(v8::SharedRef<v8::Platform>);
1813
+ // SAFETY: v8's default platform is explicitly designed to be shared across isolates and their
1814
+ // owner threads; PumpMessageLoop is called per-isolate on that isolate's owner thread. The
1815
+ // SharedRef is a refcounted handle to that one shared object.
1816
+ unsafe impl Send for SharedPlatform {}
1817
+ unsafe impl Sync for SharedPlatform {}
1818
+ static PLATFORM: std::sync::OnceLock<SharedPlatform> = std::sync::OnceLock::new();
1819
+
1820
+ // The shared platform, once V8 is initialized. `None` before init (no op can run then anyway).
1821
+ pub(crate) fn platform() -> Option<&'static v8::SharedRef<v8::Platform>> {
1822
+ PLATFORM.get().map(|p| &p.0)
1823
+ }
1824
+
1581
1825
  fn init_v8() {
1582
1826
  static ONCE: Once = Once::new();
1583
1827
  ONCE.call_once(|| {
@@ -1590,6 +1834,10 @@ fn init_v8() {
1590
1834
  Ordering::Relaxed,
1591
1835
  );
1592
1836
  let platform = v8::new_default_platform(0, false).make_shared();
1837
+ // Keep a handle before handing ownership to V8, so ops can pump the foreground task
1838
+ // queue (FinalizationRegistry cleanup etc.). make_shared yields a refcounted SharedRef;
1839
+ // clone one into the static and give the other to V8.
1840
+ let _ = PLATFORM.set(SharedPlatform(platform.clone()));
1593
1841
  v8::V8::initialize_platform(platform);
1594
1842
  v8::V8::initialize();
1595
1843
  V8_INITED.store(true, Ordering::SeqCst);
@@ -1927,8 +2175,22 @@ unsafe extern "C" fn promise_reject_cb(message: v8::PromiseRejectMessage) {
1927
2175
  // reset and create_context so realms can't drift apart. Returns the context
1928
2176
  // Global AND its dedicated microtask queue; the caller owns the queue in
1929
2177
  // V8State alongside the context (see V8State::queues for why per-realm).
2178
+ // An embedder-supplied hook run in every realm just after the host namespace is
2179
+ // installed, with the fresh realm's scope and context. Set once (OnceLock) by an
2180
+ // embedder that links rusty_racer as a library; None for standalone gem use. A
2181
+ // plain fn pointer so it stays Send + Sync with no allocation.
2182
+ pub type RealmInitHook = fn(&mut v8::PinScope<'_, '_, ()>, &v8::Global<v8::Context>, i32);
2183
+ static REALM_INIT_HOOK: std::sync::OnceLock<RealmInitHook> = std::sync::OnceLock::new();
2184
+
2185
+ // Register the per-realm init hook. Idempotent-ish: the first call wins (later
2186
+ // calls are ignored), which suits a single embedder wiring it once at boot.
2187
+ pub fn set_realm_init_hook(hook: RealmInitHook) {
2188
+ let _ = REALM_INIT_HOOK.set(hook);
2189
+ }
2190
+
1930
2191
  fn new_realm(
1931
2192
  scope: &mut v8::PinScope<'_, '_, ()>,
2193
+ context_id: i32,
1932
2194
  ) -> (v8::Global<v8::Context>, v8::UniqueRef<v8::MicrotaskQueue>) {
1933
2195
  // Explicit policy like the isolate's: rusty drives every drain by hand
1934
2196
  // (auto_drain / NS.drainMicrotasks), so V8 must never auto-run this queue.
@@ -1980,6 +2242,15 @@ fn new_realm(
1980
2242
  if let Some(name) = host_namespace {
1981
2243
  install_host_namespace(scope, &fresh, &name);
1982
2244
  }
2245
+ // Generic, DOM-agnostic extension seam: an embedder that links rusty_racer as a
2246
+ // library can register one hook (set_realm_init_hook) to run native setup in
2247
+ // every realm — including frame realms the engine creates internally. This is
2248
+ // how capybara-simulated installs its native DOM without rusty_racer knowing
2249
+ // anything about a DOM. Extension state lives in the embedder's OWN typed
2250
+ // isolate slot (rusty_v8 slots are keyed by TypeId), never in IsolateState.
2251
+ if let Some(hook) = REALM_INIT_HOOK.get() {
2252
+ hook(scope, &fresh, context_id);
2253
+ }
1983
2254
  (fresh, queue)
1984
2255
  }
1985
2256
 
@@ -2153,11 +2424,11 @@ fn build_snapshot(code: &str, base: Option<Vec<u8>>, warmup: bool) -> Result<Vec
2153
2424
  // leaves behind); nothing reads its completion value, so
2154
2425
  // don't marshal it — a bundle ending in an expression must
2155
2426
  // not fail the snapshot over a value no one wants.
2156
- true,
2427
+ Completion::Void,
2157
2428
  )
2158
2429
  {
2159
2430
  err = Some(match e {
2160
- VmError::Parse(m) | VmError::Runtime(m) => m,
2431
+ VmError::Parse(m, _) | VmError::Runtime(m) => m,
2161
2432
  VmError::JsError { message, .. } => message,
2162
2433
  VmError::Terminated => "snapshot code was terminated".to_string(),
2163
2434
  // Unreachable: the snapshot-creator is a separate isolate
@@ -2193,13 +2464,13 @@ impl Isolate {
2193
2464
  // Core keeps its id + a stable raw ptr so `run` can open scopes on it. The
2194
2465
  // isolate is thread-bound from here on (every op asserts the owner thread).
2195
2466
  fn new(
2196
- _ruby: &Ruby,
2467
+ ruby: &Ruby,
2197
2468
  host_namespace: Option<String>,
2198
2469
  snapshot: Option<magnus::typed_data::Obj<Snapshot>>,
2199
2470
  timeout_ms: u64,
2200
2471
  memory_limit: usize,
2201
2472
  explicit_microtasks: bool,
2202
- ) -> Result<Self, Error> {
2473
+ ) -> Result<magnus::typed_data::Obj<Self>, Error> {
2203
2474
  init_v8();
2204
2475
  // A snapshot blob bakes globalThis state in: the first Context::new (in
2205
2476
  // new_realm below) deserializes that default context for free.
@@ -2245,7 +2516,7 @@ impl Isolate {
2245
2516
  // namespace from the slot (seeded above).
2246
2517
  {
2247
2518
  v8::scope!(let scope, &mut isolate);
2248
- let (main_context, main_queue) = new_realm(scope);
2519
+ let (main_context, main_queue) = new_realm(scope, 0);
2249
2520
  istate!(scope).realms.main_context = Some(main_context);
2250
2521
  istate!(scope).realms.main_queue = Some(main_queue);
2251
2522
  // The shared graveyard for retired realms' contexts (see V8State).
@@ -2275,6 +2546,10 @@ impl Isolate {
2275
2546
  // owner check.
2276
2547
  use magnus::rb_sys::{AsRawValue, FromRawValue};
2277
2548
  let owner_thread = unsafe { Value::from_raw(rb_sys::rb_thread_current()) };
2549
+ // The roots array (see ROOT_IMPORT_RESOLVER): created empty, grown by the
2550
+ // first attach. Nothing roots it — the wrappers mark it, so it lives
2551
+ // exactly as long as this isolate is reachable from Ruby.
2552
+ let roots = ruby.ary_new();
2278
2553
  let core = Arc::new_cyclic(|me| Core {
2279
2554
  me: me.clone(),
2280
2555
  shared: Mutex::new(Shared {
@@ -2288,10 +2563,10 @@ impl Isolate {
2288
2563
  scan_start_field: std::sync::atomic::AtomicUsize::new(0),
2289
2564
  installed_stack_limit: std::sync::atomic::AtomicUsize::new(0),
2290
2565
  depth: std::sync::atomic::AtomicU32::new(0),
2566
+ roots: Opaque::from(roots),
2291
2567
  procs: Mutex::new(ProcTable::default()),
2292
2568
  default_timeout_ms: timeout_ms,
2293
2569
  memory_limit,
2294
- dynamic_import_resolver: Mutex::new(None),
2295
2570
  watchdog,
2296
2571
  watchdog_join: Mutex::new(Some(watchdog_join)),
2297
2572
  });
@@ -2314,7 +2589,14 @@ impl Isolate {
2314
2589
  // enter/exit stack (an out-of-order drop aborts). Instead each op enters
2315
2590
  // around its run (Core::run) and teardown re-enters just before drop.
2316
2591
  unsafe { (*core.iso_ptr.0).exit() };
2317
- Ok(Isolate { core })
2592
+ // Wrap HERE rather than returning `Self` for magnus to wrap after this frame is
2593
+ // gone: wrapping allocates, and until the wrapper exists the roots array is
2594
+ // referenced only from the Arc<Core>'s malloc'd block, which Ruby does not scan.
2595
+ // `black_box` keeps the local VALUE live across that allocation — where the
2596
+ // conservative stack scan does reach it.
2597
+ let wrapped = ruby.obj_wrap(Isolate { core });
2598
+ std::hint::black_box(roots);
2599
+ Ok(wrapped)
2318
2600
  }
2319
2601
  }
2320
2602
 
@@ -2530,6 +2812,9 @@ impl Core {
2530
2812
  // on an op.
2531
2813
  if reply.is_none() {
2532
2814
  self.shared.lock().unwrap().disposed = true;
2815
+ } else if depth == 0 {
2816
+ // No host fn is left to throw a kept value on (`keep_thrown`).
2817
+ istate!(unsafe { &mut *iso }).thrown = None;
2533
2818
  }
2534
2819
  self.depth.fetch_sub(1, Ordering::SeqCst);
2535
2820
  reply
@@ -2578,16 +2863,76 @@ impl Core {
2578
2863
  .clone()
2579
2864
  }
2580
2865
 
2581
- 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
- };
2866
+ // ── the roots array (see the module header + ROOT_IMPORT_RESOLVER) ──────
2867
+ // Keep `val` alive for as long as this isolate is reachable from Ruby. On a
2868
+ // Ruby thread with the GVL: storing can grow the Array, which allocates.
2869
+ fn root_store<T: magnus::IntoValue>(
2870
+ &self,
2871
+ ruby: &Ruby,
2872
+ index: isize,
2873
+ val: T,
2874
+ ) -> Result<(), Error> {
2875
+ ruby.get_inner(self.roots).store(index, val)
2876
+ }
2877
+
2878
+ // One entry, or None where nothing is stored (an unset resolver, a released
2879
+ // host fn). Reads the Array WITHOUT the GVL — V8 calls the import hooks with it
2880
+ // released — so it must not run one line of Ruby: `rb_ary_entry` plus
2881
+ // `Proc::from_value` (rb_obj_is_proc) and nothing else. NOT `RArray::entry`,
2882
+ // whose TryConvert would answer a nil slot by CALLING `nil.to_proc`, raising
2883
+ // NoMethodError and rescuing it — Ruby code, an allocation and a longjmp, with
2884
+ // no GVL held (measured: it corrupts the VM — "[BUG] unexpected situation" then
2885
+ // a SEGV — under any concurrent Ruby thread). The entries are pinned by
2886
+ // `mark_roots`, so the VALUE stays put until the caller has it under the GVL.
2887
+ fn root_proc(&self, index: isize) -> Option<Proc> {
2888
+ let roots = unsafe { Ruby::get_unchecked() }.get_inner(self.roots);
2889
+ Proc::from_value(roots.entry::<Value>(index).ok()?)
2890
+ }
2891
+
2892
+ // Show the GC everything this isolate holds. Called from EVERY wrapper's mark
2893
+ // (Isolate, Context, Module, Script all keep an Arc<Core>, and any one of them
2894
+ // may be the last reachable): a wrapper that forgot to would leave a live
2895
+ // isolate's host fns unmarked. `mark` PINS — an Array marks its own elements
2896
+ // MOVABLE, and the copies Rust takes out of it (call_proc, root_proc) would go
2897
+ // stale if the compactor moved one. Takes no Rust lock, by construction.
2898
+ fn mark_roots(&self, marker: &gc::Marker) {
2899
+ let roots = unsafe { Ruby::get_unchecked() }.get_inner(self.roots);
2900
+ marker.mark(roots);
2901
+ // Entry by entry, exactly — not `mark_slice`, whose rb_gc_mark_locations is
2902
+ // CONSERVATIVE (it tests each word for heap-likeness) and measured no faster
2903
+ // here. The walk is O(entries) per wrapper per GC, MINOR GCs included (these
2904
+ // wrappers are not wb_protected, so they are marked every time): measured
2905
+ // 2026-09-13 with 600 procs, ~0.1 ms per GC at the 18 wrappers a real embedder
2906
+ // session holds (capybara-simulated: 1 Isolate + 17 Contexts), ~3 ms at 500. If
2907
+ // that ever bites, the fix is for the derived wrappers to mark the ISOLATE'S
2908
+ // Ruby object and let its own mark walk the array once — which needs Core to
2909
+ // hold that object (an Opaque<Value> nothing stores today), not just a rewrite
2910
+ // of this loop.
2911
+ for i in 0..roots.len() {
2912
+ if let Ok(v) = roots.entry::<Value>(i as isize) {
2913
+ marker.mark(v);
2914
+ }
2915
+ }
2916
+ }
2917
+
2918
+ fn call_proc(
2919
+ &self,
2920
+ ruby: &Ruby,
2921
+ host_fn: HostFnKey,
2922
+ args: &[JsVal],
2923
+ ) -> Result<JsVal, HostFnFailure> {
2924
+ // A function of a released realm is refused here, before its slot's roots
2925
+ // entry is read: that entry may by now hold another realm's proc. (The lock
2926
+ // is uncontended and held for the comparison alone — nothing here allocates,
2927
+ // so no GC can need it meanwhile.)
2928
+ if !self.procs.lock().unwrap().current(host_fn) {
2929
+ return Err("unknown host function".to_string().into());
2930
+ }
2931
+ // `root_proc` and not `RArray::entry`, for the reason given there: a nil
2932
+ // slot must answer None, never dispatch `to_proc` and raise.
2933
+ let proc = self
2934
+ .root_proc(proc_root_index(host_fn.index()))
2935
+ .ok_or_else(|| "unknown host function".to_string())?;
2591
2936
  // Marshal into a Ruby Array, NOT a Vec<Value>: bare Values in a heap Vec
2592
2937
  // are hidden from Ruby's GC mark phase (magnus's own RArray::to_vec doc
2593
2938
  // spells this out). With several args, once arg N is parked in the Vec
@@ -2607,8 +2952,11 @@ impl Core {
2607
2952
  // op the proc issues re-enters Core::run (depth > 0) directly — no nested
2608
2953
  // frame bookkeeping is needed any more (the call stack IS the nesting).
2609
2954
  let result: Result<Value, Error> = proc.call(unsafe { ruby_args.as_slice() });
2610
- let value = result.map_err(|e| e.to_string())?;
2611
- ruby_to_jsval(value).map_err(|e| e.to_string())
2955
+ let value = result.map_err(|e| HostFnFailure {
2956
+ message: e.to_string(),
2957
+ thrown: thrown_serial_of(&e),
2958
+ })?;
2959
+ ruby_to_jsval(value).map_err(|e| e.to_string().into())
2612
2960
  }
2613
2961
 
2614
2962
  // Context#call (and call_void). Resolves a dotted function path
@@ -2694,6 +3042,23 @@ impl Core {
2694
3042
  Ok(())
2695
3043
  }
2696
3044
 
3045
+ // Isolate#pump_message_loop: run every pending foreground platform task, without blocking.
3046
+ // These are the deferred tasks V8 posts to the platform's task runner rather than running
3047
+ // inline — most importantly FinalizationRegistry cleanup callbacks (posted after a GC finds a
3048
+ // registered target dead) and WeakRef clearing. A pure microtask checkpoint does NOT drive
3049
+ // them; only pumping the message loop does. Returns nothing; safe to call any time (a no-op
3050
+ // when the queue is empty).
3051
+ fn pump_message_loop(&self, ruby: &Ruby) -> Result<(), Error> {
3052
+ let reply = self.run(
3053
+ ruby,
3054
+ Request::PumpMessageLoop {
3055
+ timeout_ms: self.default_timeout_ms,
3056
+ },
3057
+ )?;
3058
+ self.reply_value(ruby, reply)?;
3059
+ Ok(())
3060
+ }
3061
+
2697
3062
  fn eval_t(
2698
3063
  &self,
2699
3064
  ruby: &Ruby,
@@ -2701,7 +3066,7 @@ impl Core {
2701
3066
  source: String,
2702
3067
  filename: String,
2703
3068
  timeout_ms: u64,
2704
- void: bool,
3069
+ completion: Completion,
2705
3070
  ) -> Result<Value, Error> {
2706
3071
  let reply = self.run(
2707
3072
  ruby,
@@ -2710,7 +3075,7 @@ impl Core {
2710
3075
  source,
2711
3076
  filename,
2712
3077
  timeout_ms,
2713
- void,
3078
+ completion,
2714
3079
  },
2715
3080
  )?;
2716
3081
  self.reply_value(ruby, reply)
@@ -2723,17 +3088,18 @@ impl Core {
2723
3088
  name: String,
2724
3089
  proc: Proc,
2725
3090
  ) -> Result<Value, Error> {
2726
- let host_fn_id = self.procs.lock().unwrap().alloc(ProcSlot {
2727
- context_id,
2728
- proc: Some(RootedProc(BoxValue::new(proc))),
2729
- name: name.clone(),
2730
- });
3091
+ // BEFORE the roots write, not just inside `run` below: storing can REALLOC the
3092
+ // array's element storage, which the import hooks read with the GVL released —
3093
+ // and a refused attach must not consume a slot or leave its proc rooted either.
3094
+ self.ensure_owner_and_live(ruby)?;
3095
+ let host_fn = self.procs.lock().unwrap().alloc(context_id, name.clone());
3096
+ self.root_store(ruby, proc_root_index(host_fn.index()), proc)?;
2731
3097
  let reply = self.run(
2732
3098
  ruby,
2733
3099
  Request::Attach {
2734
3100
  context_id,
2735
3101
  name,
2736
- host_fn_id,
3102
+ host_fn,
2737
3103
  timeout_ms: self.default_timeout_ms,
2738
3104
  },
2739
3105
  )?;
@@ -2742,7 +3108,7 @@ impl Core {
2742
3108
 
2743
3109
  // attach_many: install several host fns in ONE round-trip to the V8 thread
2744
3110
  // (a fresh realm needs ~dozens; one rendezvous instead of one per fn). Slots
2745
- // are allocated up front so each carries a stable host_fn_id; entries are
3111
+ // are allocated up front so each carries a stable key; entries are
2746
3112
  // applied IN ORDER and a build/attach failure aborts the batch with that
2747
3113
  // (name-tagged) error WITHOUT rolling back earlier entries (see the
2748
3114
  // AttachMany arm). On that error path the unused slots are reclaimed at the
@@ -2756,38 +3122,50 @@ impl Core {
2756
3122
  if entries.is_empty() {
2757
3123
  return Ok(ruby.qnil().as_value()); // nothing to install, skip the round-trip
2758
3124
  }
2759
- let named_ids: Vec<(String, usize)> = {
3125
+ self.ensure_owner_and_live(ruby)?; // before the roots writes — see `attach`
3126
+ let allocated: Vec<(String, HostFnKey, Proc)> = {
2760
3127
  let mut procs = self.procs.lock().unwrap();
2761
3128
  entries
2762
3129
  .into_iter()
2763
- .map(|(name, proc)| {
2764
- let id = procs.alloc(ProcSlot {
2765
- context_id,
2766
- proc: Some(RootedProc(BoxValue::new(proc))),
2767
- name: name.clone(),
2768
- });
2769
- (name, id)
2770
- })
3130
+ .map(|(name, proc)| (name.clone(), procs.alloc(context_id, name), proc))
2771
3131
  .collect()
2772
3132
  };
3133
+ // Stored OUTSIDE the table's lock: storing into the array can allocate (the
3134
+ // Array grows), and an allocation may GC — which marks through this isolate's
3135
+ // wrappers, a path that must never wait on `procs`. The Procs parked in the
3136
+ // Vec meanwhile are invisible to the GC (see call_proc's note), but the caller's
3137
+ // `table: RHash` argument is live in its own frame and marks every one of them.
3138
+ let mut named_keys: Vec<(String, HostFnKey)> = Vec::with_capacity(allocated.len());
3139
+ for (name, key, proc) in allocated {
3140
+ self.root_store(ruby, proc_root_index(key.index()), proc)?;
3141
+ named_keys.push((name, key));
3142
+ }
2773
3143
  let reply = self.run(
2774
3144
  ruby,
2775
3145
  Request::AttachMany {
2776
3146
  context_id,
2777
- entries: named_ids,
3147
+ entries: named_keys,
2778
3148
  timeout_ms: self.default_timeout_ms,
2779
3149
  },
2780
3150
  )?;
2781
3151
  self.reply_value(ruby, reply)
2782
3152
  }
2783
3153
 
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);
3154
+ // Drop the procs attached into |context_id| out of the roots array — its
3155
+ // realm is gone (reset or disposed), and holding them would keep the embedder's
3156
+ // world alive for the rest of the isolate's life. A V8 function of that realm
3157
+ // may still be reachable (the embedder can hold the old global), but its key
3158
+ // stops answering (`ProcTable::current`), so it never calls a proc again.
3159
+ // Runs on a Ruby thread: it writes nil into the Array. The slots stay: the
3160
+ // keys of other realms are indices into the same Vec.
3161
+ fn release_context_procs(&self, ruby: &Ruby, context_id: i32) {
3162
+ // Nil the entries BEFORE the ids go back on the free list, so no attach can
3163
+ // ever be handed an id whose old proc is still in the array.
3164
+ let released = self.procs.lock().unwrap().release(context_id);
3165
+ for &id in &released {
3166
+ let _ = self.root_store(ruby, proc_root_index(id), ruby.qnil());
3167
+ }
3168
+ self.procs.lock().unwrap().free.extend(released);
2791
3169
  }
2792
3170
 
2793
3171
  fn reset(&self, ruby: &Ruby, context_id: i32) -> Result<Value, Error> {
@@ -2795,7 +3173,7 @@ impl Core {
2795
3173
  let out = self.reply_value(ruby, reply)?;
2796
3174
  // Only on success — a refused reset (unknown/suspended realm) keeps
2797
3175
  // its attached fns callable.
2798
- self.release_context_procs(context_id);
3176
+ self.release_context_procs(ruby, context_id);
2799
3177
  Ok(out)
2800
3178
  }
2801
3179
 
@@ -2809,7 +3187,7 @@ impl Core {
2809
3187
  fn dispose_context(&self, ruby: &Ruby, context_id: i32) -> Result<(), Error> {
2810
3188
  let reply = self.run(ruby, Request::DisposeContext { context_id })?;
2811
3189
  self.reply_value(ruby, reply)?;
2812
- self.release_context_procs(context_id);
3190
+ self.release_context_procs(ruby, context_id);
2813
3191
  Ok(())
2814
3192
  }
2815
3193
 
@@ -2892,12 +3270,18 @@ impl Core {
2892
3270
  self.reply_value(ruby, reply?)
2893
3271
  }
2894
3272
 
2895
- fn evaluate_module(&self, ruby: &Ruby, module_id: i32) -> Result<Value, Error> {
3273
+ fn evaluate_module(
3274
+ &self,
3275
+ ruby: &Ruby,
3276
+ module_id: i32,
3277
+ report: Option<String>,
3278
+ ) -> Result<Value, Error> {
2896
3279
  let reply = self.run(
2897
3280
  ruby,
2898
3281
  Request::EvaluateModule {
2899
3282
  module_id,
2900
3283
  timeout_ms: self.default_timeout_ms,
3284
+ report,
2901
3285
  },
2902
3286
  )?;
2903
3287
  self.reply_value(ruby, reply)
@@ -2986,10 +3370,18 @@ impl Core {
2986
3370
  code_cache_from_reply(ruby, reply)
2987
3371
  }
2988
3372
 
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)));
3373
+ // Owner-thread only, like every other writer of the roots array (`attach`,
3374
+ // `attach_many`, `dispose`): the store can REALLOC the array's element storage, and
3375
+ // the import hooks read that storage with the GVL released.
3376
+ fn set_dynamic_import_resolver(&self, ruby: &Ruby, proc: Proc) -> Result<(), Error> {
3377
+ self.ensure_owner_and_live(ruby)?;
3378
+ self.root_store(ruby, ROOT_IMPORT_RESOLVER, proc)
3379
+ }
3380
+
3381
+ // The resolver, or None if none was set. Read on the V8 side without the GVL —
3382
+ // see root_proc.
3383
+ fn dynamic_import_resolver(&self) -> Option<Proc> {
3384
+ self.root_proc(ROOT_IMPORT_RESOLVER)
2993
3385
  }
2994
3386
 
2995
3387
  // Terminate whatever is running. IsolateHandle is Send + refcounted —
@@ -3020,12 +3412,16 @@ impl Core {
3020
3412
  // it entered, and OwnedIsolate's Drop asserts `self == GetCurrent()`
3021
3413
  // (then exits). Between ops the isolate is exited, so we must enter here.
3022
3414
  unsafe { (*self.iso_ptr.0).enter() };
3415
+ // Only the Rust-side bookkeeping. Clearing the roots ARRAY here would be
3416
+ // pointless work: teardown also runs from Core::drop, and by then the array
3417
+ // is garbage itself — the last wrapper marking it is exactly what went away.
3418
+ // `dispose` clears it explicitly instead, for the isolate the caller disposes
3419
+ // but keeps a handle to.
3023
3420
  {
3024
3421
  let mut procs = self.procs.lock().unwrap();
3025
3422
  procs.slots.clear();
3026
3423
  procs.free.clear();
3027
3424
  }
3028
- *self.dynamic_import_resolver.lock().unwrap() = None;
3029
3425
  {
3030
3426
  let st = istate!(unsafe { &mut *self.iso_ptr.0 });
3031
3427
  st.realms = V8State::default();
@@ -3074,6 +3470,10 @@ impl Core {
3074
3470
  shared.disposed = true;
3075
3471
  }
3076
3472
  self.teardown();
3473
+ // Release the host fns NOW rather than when the wrappers are collected: a
3474
+ // disposed isolate can never call one again, and each closes over as much
3475
+ // of the embedder's world as it happens to capture.
3476
+ let _ = ruby.get_inner(self.roots).clear();
3077
3477
  Ok(())
3078
3478
  }
3079
3479
  }
@@ -3189,9 +3589,15 @@ impl Isolate {
3189
3589
  fn low_memory_notification(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
3190
3590
  rb_self.core.low_memory_notification(ruby)
3191
3591
  }
3592
+ // Isolate#pump_message_loop: run pending foreground platform tasks (FinalizationRegistry
3593
+ // cleanup callbacks and WeakRef clearing among them), without blocking. Pair it with a GC
3594
+ // (low_memory_notification) or call it periodically so weak-collection callbacks actually fire.
3595
+ fn pump_message_loop(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
3596
+ rb_self.core.pump_message_loop(ruby)
3597
+ }
3192
3598
  // 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);
3599
+ fn set_dynamic_import_resolver(ruby: &Ruby, rb_self: &Self, proc: Proc) -> Result<(), Error> {
3600
+ rb_self.core.set_dynamic_import_resolver(ruby, proc)
3195
3601
  }
3196
3602
  fn dispose(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
3197
3603
  rb_self.core.dispose(ruby)
@@ -3219,14 +3625,14 @@ impl Context {
3219
3625
  Ok(())
3220
3626
  }
3221
3627
  // timeout_ms 0 = use the isolate's default; an explicit value overrides it.
3222
- // |void| discards the completion value instead of marshalling it.
3628
+ // |completion| 0 marshals the completion value, 1 discards it, 2 keeps a primitive alone (`Completion`).
3223
3629
  fn eval(
3224
3630
  ruby: &Ruby,
3225
3631
  rb_self: &Self,
3226
3632
  source: String,
3227
3633
  timeout_ms: u64,
3228
3634
  filename: String,
3229
- void: bool,
3635
+ completion: u8,
3230
3636
  ) -> Result<Value, Error> {
3231
3637
  rb_self.check_live(ruby)?;
3232
3638
  let timeout = if timeout_ms == 0 {
@@ -3234,9 +3640,14 @@ impl Context {
3234
3640
  } else {
3235
3641
  timeout_ms
3236
3642
  };
3643
+ let completion = match completion {
3644
+ 1 => Completion::Void,
3645
+ 2 => Completion::Primitive,
3646
+ _ => Completion::Value,
3647
+ };
3237
3648
  rb_self
3238
3649
  .core
3239
- .eval_t(ruby, rb_self.id, source, filename, timeout, void)
3650
+ .eval_t(ruby, rb_self.id, source, filename, timeout, completion)
3240
3651
  }
3241
3652
  fn call(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result<Value, Error> {
3242
3653
  rb_self.check_live(ruby)?;
@@ -3425,9 +3836,12 @@ impl JsModule {
3425
3836
  .core
3426
3837
  .instantiate_module(ruby, rb_self.module_id, resolver)
3427
3838
  }
3428
- fn evaluate(ruby: &Ruby, rb_self: &Self) -> Result<Value, Error> {
3839
+ // |report|: nil, or the name of the context global that reports the evaluation's rejection (`Request::EvaluateModule`).
3840
+ fn evaluate(ruby: &Ruby, rb_self: &Self, report: Option<String>) -> Result<Value, Error> {
3429
3841
  rb_self.check_live(ruby)?;
3430
- rb_self.core.evaluate_module(ruby, rb_self.module_id)
3842
+ rb_self
3843
+ .core
3844
+ .evaluate_module(ruby, rb_self.module_id, report)
3431
3845
  }
3432
3846
  fn namespace(ruby: &Ruby, rb_self: &Self) -> Result<Value, Error> {
3433
3847
  rb_self.check_live(ruby)?;
@@ -3584,9 +3998,15 @@ fn binary_bytes(
3584
3998
 
3585
3999
  fn vm_err(ruby: &Ruby, e: VmError) -> Error {
3586
4000
  match e {
3587
- VmError::Parse(m) => Error::new(err_class(ruby, "ParseError"), m),
4001
+ VmError::Parse(m, thrown) => {
4002
+ carrying_thrown(Error::new(err_class(ruby, "ParseError"), m), thrown)
4003
+ }
3588
4004
  VmError::Runtime(m) => Error::new(err_class(ruby, "RuntimeError"), m),
3589
- VmError::JsError { message, backtrace } => js_runtime_error(ruby, message, backtrace),
4005
+ VmError::JsError {
4006
+ message,
4007
+ backtrace,
4008
+ thrown,
4009
+ } => carrying_thrown(js_runtime_error(ruby, message, backtrace), thrown),
3590
4010
  VmError::Terminated => Error::new(
3591
4011
  err_class(ruby, "ScriptTerminatedError"),
3592
4012
  "JavaScript was terminated (timeout or stop)",
@@ -3628,6 +4048,42 @@ fn terminated_error(ruby: &Ruby, js_backtrace: Option<Vec<String>>) -> Error {
3628
4048
  }
3629
4049
  }
3630
4050
 
4051
+ // The ivar a Ruby exception carries its thrown value's serial in (`keep_thrown`),
4052
+ // beside the message it was raised with.
4053
+ const THROWN_IVAR: &str = "@__rusty_racer_thrown";
4054
+
4055
+ // |error| as an instance carrying |thrown|'s serial, for a host fn it propagates
4056
+ // out of to throw the kept value on (`take_thrown`).
4057
+ fn carrying_thrown(error: Error, thrown: Option<u64>) -> Error {
4058
+ let Some(serial) = thrown else {
4059
+ return error;
4060
+ };
4061
+ match error_to_exception(&error) {
4062
+ Some(exc) => {
4063
+ let Ok(message) = exc.funcall::<_, _, Value>("message", ()) else {
4064
+ return Error::from(exc);
4065
+ };
4066
+ let _ = exc
4067
+ .funcall::<_, _, Value>("instance_variable_set", (THROWN_IVAR, (serial, message)));
4068
+ Error::from(exc)
4069
+ }
4070
+ None => error,
4071
+ }
4072
+ }
4073
+
4074
+ // The serial of the thrown value a host fn's raised |error| carries, if any — not
4075
+ // when Ruby re-raised it with a message of its own (`raise e, 'other'` copies
4076
+ // the ivar): that error says something the value does not.
4077
+ fn thrown_serial_of(error: &Error) -> Option<u64> {
4078
+ let exc = error_to_exception(error)?;
4079
+ let (serial, message): (u64, Value) = exc
4080
+ .funcall::<_, _, Option<(u64, Value)>>("instance_variable_get", (THROWN_IVAR,))
4081
+ .ok()
4082
+ .flatten()?;
4083
+ let now: Value = exc.funcall("message", ()).ok()?;
4084
+ now.eql(message).ok()?.then_some(serial)
4085
+ }
4086
+
3631
4087
  // Build a RustyRacer::RuntimeError carrying the JS stack as its Ruby backtrace.
3632
4088
  // Constructs the exception instance so we can set_backtrace before raising;
3633
4089
  // falls back to a plain Error if any of that fails.
@@ -3697,6 +4153,16 @@ fn resolve_module_via_ruby(
3697
4153
 
3698
4154
  #[magnus::init]
3699
4155
  fn init(ruby: &Ruby) -> Result<(), Error> {
4156
+ install_classes(ruby)
4157
+ }
4158
+
4159
+ // Define the RustyRacer::* Ruby classes/modules. Split out of the magnus init so
4160
+ // an embedder that links rusty_racer as a LIBRARY (rather than loading its gem
4161
+ // .so) can build its own cdylib and call this from its own `#[magnus::init]` —
4162
+ // e.g. capybara-simulated, which links rusty_racer + a native DOM into one
4163
+ // extension. Standalone gem use goes through `init` above; both define the exact
4164
+ // same surface.
4165
+ pub fn install_classes(ruby: &Ruby) -> Result<(), Error> {
3700
4166
  let module = ruby.define_module("RustyRacer")?;
3701
4167
 
3702
4168
  // The isolate (VM) + its isolate-level ops; hands out Contexts.
@@ -3720,13 +4186,14 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
3720
4186
  "low_memory_notification",
3721
4187
  method!(Isolate::low_memory_notification, 0),
3722
4188
  )?;
4189
+ isolate.define_method("pump_message_loop", method!(Isolate::pump_message_loop, 0))?;
3723
4190
  isolate.define_method("dispose", method!(Isolate::dispose, 0))?;
3724
4191
  isolate.define_method("disposed?", method!(Isolate::disposed, 0))?;
3725
4192
 
3726
4193
  // A v8::Context (realm): eval/call/attach/compile_module.
3727
4194
  let context = module.define_class("Context", ruby.class_object())?;
3728
4195
  // Backs both keyword-arg wrappers in lib: Context#eval(source, timeout_ms:,
3729
- // filename:) and #eval_void, which differ only in the 4th arg (the void flag).
4196
+ // filename:), #eval_void and #eval_primitive, which differ only in the 4th arg (what they keep of the completion value).
3730
4197
  context.define_method("_eval", method!(Context::eval, 4))?;
3731
4198
  context.define_method("call", method!(Context::call, -1))?;
3732
4199
  context.define_method("call_void", method!(Context::call_void, -1))?;
@@ -3761,7 +4228,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
3761
4228
  // Thin ES-module handle: Context#compile_module -> instantiate/evaluate.
3762
4229
  let jsmodule = module.define_class("Module", ruby.class_object())?;
3763
4230
  jsmodule.define_method("_instantiate", method!(JsModule::instantiate, 1))?;
3764
- jsmodule.define_method("evaluate", method!(JsModule::evaluate, 0))?;
4231
+ jsmodule.define_method("_evaluate", method!(JsModule::evaluate, 1))?;
3765
4232
  jsmodule.define_method("namespace", method!(JsModule::namespace, 0))?;
3766
4233
  jsmodule.define_method("_status", method!(JsModule::status, 0))?;
3767
4234
  jsmodule.define_method("graph_async?", method!(JsModule::graph_async, 0))?;