rusty_racer 0.1.13 → 0.1.15

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.
@@ -46,18 +46,18 @@ use std::sync::{Arc, Mutex, Once, Weak};
46
46
  use magnus::block::Proc;
47
47
  use magnus::value::{BoxValue, ReprValue};
48
48
  use magnus::{
49
- function, method, prelude::*, Error, Exception, ExceptionClass, RHash, Ruby, TryConvert, Value,
49
+ Error, Exception, ExceptionClass, RHash, Ruby, TryConvert, Value, function, method, prelude::*,
50
50
  };
51
51
 
52
52
  mod marshal;
53
- use marshal::{js_to_jsval, jsval_to_js, jsval_to_ruby, ruby_to_jsval, JsVal};
53
+ use marshal::{JsVal, js_to_jsval, jsval_to_js, jsval_to_ruby, ruby_to_jsval};
54
54
  mod ops;
55
- use ops::{run_source, service_request, Compiled, Request, VmReply};
55
+ use ops::{Compiled, Request, VmReply, run_source, service_request};
56
56
  mod stack;
57
- use stack::{current_real_isolate, discover_scan_start_field, set_v8_stack_limit, STACK_DEBUG};
57
+ use stack::{STACK_DEBUG, StackScope, current_real_isolate, discover_scan_start_field};
58
58
  mod watchdog;
59
59
  use watchdog::{
60
- arm_watchdog, disarm_watchdog, run_js_bracketed, watchdog_loop, WatchdogShared, WATCHDOG_DEBUG,
60
+ WATCHDOG_DEBUG, WatchdogShared, arm_watchdog, disarm_watchdog, run_js_bracketed, watchdog_loop,
61
61
  };
62
62
 
63
63
  // A Ruby Proc rooted for as long as the Core holds it. BoxValue registers a
@@ -203,7 +203,11 @@ enum VmError {
203
203
  // Core::run, once the op has unwound, forces a GC to reclaim and resets the
204
204
  // ceiling — see the OOM recovery there. The bump is a no-op-after-the-fact:
205
205
  // doubling here, GC + reset after, so the limit keeps protecting later ops.
206
- unsafe extern "C" fn near_heap_limit_cb(data: *mut c_void, current_heap_limit: usize, initial: usize) -> usize {
206
+ unsafe extern "C" fn near_heap_limit_cb(
207
+ data: *mut c_void,
208
+ current_heap_limit: usize,
209
+ initial: usize,
210
+ ) -> usize {
207
211
  let isolate = unsafe { &mut *(data as *mut v8::Isolate) };
208
212
  // get_slot_mut (not istate!): this runs as an extern "C" callback from V8's
209
213
  // C++ allocator, where a panic would unwind across the FFI boundary. The slot
@@ -263,7 +267,10 @@ where
263
267
  job.r = Some((job.f.take().unwrap())());
264
268
  null_mut()
265
269
  }
266
- let mut job = Job::<F, R> { f: Some(f), r: None };
270
+ let mut job = Job::<F, R> {
271
+ f: Some(f),
272
+ r: None,
273
+ };
267
274
  unsafe {
268
275
  rb_sys::rb_thread_call_without_gvl(
269
276
  Some(run::<F, R>),
@@ -295,7 +302,10 @@ where
295
302
  job.r = Some((job.f.take().unwrap())());
296
303
  null_mut()
297
304
  }
298
- let mut job = Job::<F, R> { f: Some(f), r: None };
305
+ let mut job = Job::<F, R> {
306
+ f: Some(f),
307
+ r: None,
308
+ };
299
309
  unsafe {
300
310
  rb_sys::rb_thread_call_with_gvl(Some(run::<F, R>), &mut job as *mut _ as *mut c_void);
301
311
  }
@@ -433,12 +443,12 @@ fn parse_js_stack(stack: &str) -> Vec<String> {
433
443
  }
434
444
  // "NAME (LOC)" -> "LOC:in 'NAME'". Split on the FIRST " (" so a LOC
435
445
  // path that itself contains parentheses stays intact.
436
- if frame.ends_with(')') {
437
- if let Some(open) = frame.find(" (") {
438
- let name = &frame[..open];
439
- let loc = &frame[open + 2..frame.len() - 1];
440
- return Some(format!("{loc}:in '{name}'"));
441
- }
446
+ if frame.ends_with(')')
447
+ && let Some(open) = frame.find(" (")
448
+ {
449
+ let name = &frame[..open];
450
+ let loc = &frame[open + 2..frame.len() - 1];
451
+ return Some(format!("{loc}:in '{name}'"));
442
452
  }
443
453
  Some(frame.to_string())
444
454
  })
@@ -458,21 +468,18 @@ fn capture_js_error(
458
468
  .map(|e| e.to_rust_string_lossy(scope))
459
469
  .unwrap_or_else(|| "unexpected failure".to_string());
460
470
  let mut stack_str = None;
461
- if let Some(e) = exception {
462
- if let Some(obj) = e.to_object(scope) {
463
- if let Some(key) = v8::String::new(scope, "stack") {
464
- if let Some(s) = obj.get(scope, key.into()) {
465
- if s.is_string() {
466
- stack_str = Some(s.to_rust_string_lossy(scope));
467
- }
468
- }
469
- }
470
- }
471
+ if let Some(e) = exception
472
+ && let Some(obj) = e.to_object(scope)
473
+ && let Some(key) = v8::String::new(scope, "stack")
474
+ && let Some(s) = obj.get(scope, key.into())
475
+ && s.is_string()
476
+ {
477
+ stack_str = Some(s.to_rust_string_lossy(scope));
471
478
  }
472
- if stack_str.is_none() {
473
- if let Some(s) = fallback_stack {
474
- stack_str = Some(s.to_rust_string_lossy(scope));
475
- }
479
+ if stack_str.is_none()
480
+ && let Some(s) = fallback_stack
481
+ {
482
+ stack_str = Some(s.to_rust_string_lossy(scope));
476
483
  }
477
484
  let backtrace = stack_str
478
485
  .map(|s| parse_js_stack(s.as_str()))
@@ -487,7 +494,17 @@ fn capture_js_error(
487
494
  fn module_origin<'s>(scope: &v8::PinScope<'s, '_>, url: &str) -> v8::ScriptOrigin<'s> {
488
495
  let name = v8::String::new(scope, url).unwrap();
489
496
  v8::ScriptOrigin::new(
490
- scope, name.into(), 0, 0, false, -1, None, false, false, /*is_module*/ true, None,
497
+ scope,
498
+ name.into(),
499
+ 0,
500
+ 0,
501
+ false,
502
+ -1,
503
+ None,
504
+ false,
505
+ false,
506
+ /*is_module*/ true,
507
+ None,
491
508
  )
492
509
  }
493
510
 
@@ -773,7 +790,7 @@ fn drain_all_realms(scope: &mut v8::PinScope<'_, '_>) {
773
790
  .collect()
774
791
  };
775
792
  for q in queues {
776
- unsafe { (*q).perform_checkpoint(&mut ***scope) };
793
+ unsafe { (*q).perform_checkpoint(scope) };
777
794
  }
778
795
  }
779
796
 
@@ -833,7 +850,10 @@ fn drop_context_artifacts(state: &mut IsolateState, context_id: i32) {
833
850
  }
834
851
 
835
852
  // A script's (unbound handle, owning context id), for running it in that context.
836
- fn script_handle(state: &IsolateState, script_id: i32) -> Option<(v8::Global<v8::UnboundScript>, i32)> {
853
+ fn script_handle(
854
+ state: &IsolateState,
855
+ script_id: i32,
856
+ ) -> Option<(v8::Global<v8::UnboundScript>, i32)> {
837
857
  state
838
858
  .scripts
839
859
  .by_id
@@ -1081,7 +1101,9 @@ fn finish_dynamic_import(
1081
1101
  Some(exc) => {
1082
1102
  resolver.reject(tc, exc);
1083
1103
  }
1084
- None => reject_with_error(tc, resolver, "failed to link dynamically imported module"),
1104
+ None => {
1105
+ reject_with_error(tc, resolver, "failed to link dynamically imported module")
1106
+ }
1085
1107
  }
1086
1108
  return;
1087
1109
  }
@@ -1097,7 +1119,11 @@ fn finish_dynamic_import(
1097
1119
  match module.evaluate(tc) {
1098
1120
  Some(value) => {
1099
1121
  let Ok(eval_promise) = v8::Local::<v8::Promise>::try_from(value) else {
1100
- reject_with_error(tc, resolver, "module evaluation did not yield a promise");
1122
+ reject_with_error(
1123
+ tc,
1124
+ resolver,
1125
+ "module evaluation did not yield a promise",
1126
+ );
1101
1127
  return;
1102
1128
  };
1103
1129
  // Settle the import() promise from the evaluation promise
@@ -1229,10 +1255,16 @@ struct Core {
1229
1255
  // the owner thread.
1230
1256
  iso_ptr: IsoPtr,
1231
1257
  // Address of V8's conservative-GC-scan stack_start field (see
1232
- // discover_scan_start_field), or 0 if discovery failed. set_v8_stack_limit
1233
- // writes the fiber region top here per fiber op so a GC scan stays mapped.
1234
- // Set once at creation, then read-only; AtomicUsize for shared &Core access.
1258
+ // discover_scan_start_field), or 0 if discovery failed. StackScope writes the
1259
+ // fiber region top here per fiber op so a GC scan stays mapped. Set once at
1260
+ // creation, then read-only; AtomicUsize for shared &Core access.
1235
1261
  scan_start_field: std::sync::atomic::AtomicUsize,
1262
+ // The stack limit currently installed in V8 for this isolate. StackScope
1263
+ // keeps it here because V8 exposes no getter and a nested op running on
1264
+ // another stack has to put the enclosing op's limit back. 0 before the first
1265
+ // op. Owner-thread only, like the isolate itself; AtomicUsize for shared
1266
+ // &Core access.
1267
+ installed_stack_limit: std::sync::atomic::AtomicUsize,
1236
1268
  // Re-entry depth for THIS isolate, readable without a scope (the runner needs
1237
1269
  // it to choose the scope kind before any scope exists): 0 = top-level op
1238
1270
  // (open a fresh HandleScope from iso_ptr); >0 = a host callback is on the V8
@@ -1495,10 +1527,13 @@ fn transfer_out(
1495
1527
  return;
1496
1528
  }
1497
1529
  let token = NEXT_TRANSFER_TOKEN.fetch_add(1, Ordering::Relaxed);
1498
- transfer_registry()
1499
- .lock()
1500
- .unwrap()
1501
- .insert(token, SendBackingStore { store, shared: false });
1530
+ transfer_registry().lock().unwrap().insert(
1531
+ token,
1532
+ SendBackingStore {
1533
+ store,
1534
+ shared: false,
1535
+ },
1536
+ );
1502
1537
  rv.set(v8::Number::new(scope, token as f64).into());
1503
1538
  }
1504
1539
 
@@ -1519,10 +1554,13 @@ fn share_out(
1519
1554
  };
1520
1555
  let store = sab.get_backing_store();
1521
1556
  let token = NEXT_TRANSFER_TOKEN.fetch_add(1, Ordering::Relaxed);
1522
- transfer_registry()
1523
- .lock()
1524
- .unwrap()
1525
- .insert(token, SendBackingStore { store, shared: true });
1557
+ transfer_registry().lock().unwrap().insert(
1558
+ token,
1559
+ SendBackingStore {
1560
+ store,
1561
+ shared: true,
1562
+ },
1563
+ );
1526
1564
  rv.set(v8::Number::new(scope, token as f64).into());
1527
1565
  }
1528
1566
 
@@ -1665,12 +1703,15 @@ fn new_realm(
1665
1703
  ) -> (v8::Global<v8::Context>, v8::UniqueRef<v8::MicrotaskQueue>) {
1666
1704
  // Explicit policy like the isolate's: rusty drives every drain by hand
1667
1705
  // (auto_drain / NS.drainMicrotasks), so V8 must never auto-run this queue.
1668
- let mut queue = v8::MicrotaskQueue::new(&mut **scope, v8::MicrotasksPolicy::Explicit);
1706
+ let mut queue = v8::MicrotaskQueue::new(scope, v8::MicrotasksPolicy::Explicit);
1669
1707
  let fresh = {
1670
- let context = v8::Context::new(scope, v8::ContextOptions {
1671
- microtask_queue: Some(&mut *queue as *mut _),
1672
- ..Default::default()
1673
- });
1708
+ let context = v8::Context::new(
1709
+ scope,
1710
+ v8::ContextOptions {
1711
+ microtask_queue: Some(&mut *queue as *mut _),
1712
+ ..Default::default()
1713
+ },
1714
+ );
1674
1715
  v8::Global::new(scope, context)
1675
1716
  };
1676
1717
  // DESIGN DECISION: every realm of an isolate shares ONE security token, so
@@ -1723,7 +1764,8 @@ fn flush_retiring(scope: &mut v8::PinScope<'_, '_, ()>) {
1723
1764
  if istate!(scope).realms.retiring.is_empty() {
1724
1765
  return;
1725
1766
  }
1726
- let graveyard: *const v8::MicrotaskQueue = match istate!(scope).realms.graveyard_queue.as_ref() {
1767
+ let graveyard: *const v8::MicrotaskQueue = match istate!(scope).realms.graveyard_queue.as_ref()
1768
+ {
1727
1769
  Some(q) => &**q as *const _,
1728
1770
  // The graveyard is created at boot and never cleared, so with entries
1729
1771
  // waiting this is unreachable; assert so a future refactor that defers its
@@ -1763,7 +1805,10 @@ fn install_host_namespace(
1763
1805
  let scope = &mut v8::ContextScope::new(scope, context);
1764
1806
  let ns = v8::Object::new(scope);
1765
1807
  let members: [(&str, Option<v8::Local<v8::Function>>); 9] = [
1766
- ("drainMicrotasks", v8::Function::new(scope, drain_microtasks)),
1808
+ (
1809
+ "drainMicrotasks",
1810
+ v8::Function::new(scope, drain_microtasks),
1811
+ ),
1767
1812
  ("contextGlobal", v8::Function::new(scope, context_global)),
1768
1813
  ("contextOf", v8::Function::new(scope, context_of)),
1769
1814
  (
@@ -1807,8 +1852,8 @@ fn attach_at_path(
1807
1852
  if part.is_empty() {
1808
1853
  return Err(VmError::Runtime(format!("invalid attach name `{name}`")));
1809
1854
  }
1810
- let key =
1811
- v8::String::new(scope, part).ok_or_else(|| VmError::Runtime("name too large".into()))?;
1855
+ let key = v8::String::new(scope, part)
1856
+ .ok_or_else(|| VmError::Runtime("name too large".into()))?;
1812
1857
  holder = match holder.get(scope, key.into()) {
1813
1858
  Some(v) if v.is_object() => v8::Local::<v8::Object>::try_from(v).unwrap(),
1814
1859
  // Don't clobber an existing non-object (e.g. a primitive global that
@@ -1821,7 +1866,9 @@ fn attach_at_path(
1821
1866
  _ => {
1822
1867
  let obj = v8::Object::new(scope);
1823
1868
  if holder.set(scope, key.into(), obj.into()) != Some(true) {
1824
- return Err(VmError::Runtime(format!("`{name}`: cannot create `{part}`")));
1869
+ return Err(VmError::Runtime(format!(
1870
+ "`{name}`: cannot create `{part}`"
1871
+ )));
1825
1872
  }
1826
1873
  obj
1827
1874
  }
@@ -1868,17 +1915,18 @@ fn build_snapshot(code: &str, base: Option<Vec<u8>>, warmup: bool) -> Result<Vec
1868
1915
  let context = v8::Context::new(scope, Default::default());
1869
1916
  {
1870
1917
  let cscope = &mut v8::ContextScope::new(scope, context);
1871
- if !code.is_empty() {
1872
- if let Err(e) = run_source(cscope, code, if warmup { "<warmup>" } else { "<snapshot>" }) {
1873
- err = Some(match e {
1874
- VmError::Parse(m) | VmError::Runtime(m) => m,
1875
- VmError::JsError { message, .. } => message,
1876
- VmError::Terminated => "snapshot code was terminated".to_string(),
1877
- // Unreachable: the snapshot-creator is a separate isolate
1878
- // that never registers near_heap_limit_cb.
1879
- VmError::OutOfMemory => "snapshot code ran out of memory".to_string(),
1880
- });
1881
- }
1918
+ if !code.is_empty()
1919
+ && let Err(e) =
1920
+ run_source(cscope, code, if warmup { "<warmup>" } else { "<snapshot>" })
1921
+ {
1922
+ err = Some(match e {
1923
+ VmError::Parse(m) | VmError::Runtime(m) => m,
1924
+ VmError::JsError { message, .. } => message,
1925
+ VmError::Terminated => "snapshot code was terminated".to_string(),
1926
+ // Unreachable: the snapshot-creator is a separate isolate
1927
+ // that never registers near_heap_limit_cb.
1928
+ VmError::OutOfMemory => "snapshot code ran out of memory".to_string(),
1929
+ });
1882
1930
  }
1883
1931
  }
1884
1932
  // Mark the context to deserialize on boot (after the ContextScope is
@@ -1964,7 +2012,7 @@ impl Isolate {
1964
2012
  istate!(scope).realms.main_context = Some(main_context);
1965
2013
  istate!(scope).realms.main_queue = Some(main_queue);
1966
2014
  // The shared graveyard for retired realms' contexts (see V8State).
1967
- let graveyard = v8::MicrotaskQueue::new(&mut **scope, v8::MicrotasksPolicy::Explicit);
2015
+ let graveyard = v8::MicrotaskQueue::new(scope, v8::MicrotasksPolicy::Explicit);
1968
2016
  istate!(scope).realms.graveyard_queue = Some(graveyard);
1969
2017
  }
1970
2018
  // Box the OwnedIsolate so it has a STABLE address, then capture a raw ptr
@@ -1992,12 +2040,16 @@ impl Isolate {
1992
2040
  let owner_thread = unsafe { Value::from_raw(rb_sys::rb_thread_current()) };
1993
2041
  let core = Arc::new_cyclic(|me| Core {
1994
2042
  me: me.clone(),
1995
- shared: Mutex::new(Shared { handle, disposed: false }),
2043
+ shared: Mutex::new(Shared {
2044
+ handle,
2045
+ disposed: false,
2046
+ }),
1996
2047
  iso_id,
1997
2048
  owner: owner_thread.as_raw() as usize,
1998
2049
  _owner_root: RootedThread(BoxValue::new(owner_thread)),
1999
2050
  iso_ptr,
2000
2051
  scan_start_field: std::sync::atomic::AtomicUsize::new(0),
2052
+ installed_stack_limit: std::sync::atomic::AtomicUsize::new(0),
2001
2053
  depth: std::sync::atomic::AtomicU32::new(0),
2002
2054
  procs: Mutex::new(ProcTable::default()),
2003
2055
  default_timeout_ms: timeout_ms,
@@ -2051,7 +2103,10 @@ impl Core {
2051
2103
  ));
2052
2104
  }
2053
2105
  if self.shared.lock().unwrap().disposed {
2054
- return Err(Error::new(ruby.exception_runtime_error(), "disposed context"));
2106
+ return Err(Error::new(
2107
+ ruby.exception_runtime_error(),
2108
+ "disposed context",
2109
+ ));
2055
2110
  }
2056
2111
  Ok(())
2057
2112
  }
@@ -2084,29 +2139,69 @@ impl Core {
2084
2139
  // poison the isolate and raise instead of crashing the process.
2085
2140
  use std::panic::AssertUnwindSafe;
2086
2141
  let reply: Option<VmReply> = without_gvl(|| {
2087
- if depth == 0 {
2142
+ // iso_ptr is *mut v8::Isolate = *mut NonNull<RealIsolate>; the raw
2143
+ // v8::Isolate* the C++ entry points want is that NonNull's value.
2144
+ let real_isolate = unsafe { *(iso as *const *mut c_void) };
2145
+ // Whether this op has to ENTER the isolate itself. At depth 0 always:
2146
+ // between ops the isolate is exited, so each op enters around its run.
2147
+ //
2148
+ // A re-entrant op (a host callback or module resolver, having
2149
+ // reacquired the GVL to run a proc that issued this op, is on the V8
2150
+ // stack) USUALLY does not: the JS on the stack belongs to THIS
2151
+ // isolate, which is therefore already entered on THIS native thread,
2152
+ // and we bootstrap onto the ambient HandleScope instead.
2153
+ //
2154
+ // But an embedder driving MANY isolates (e.g. capybara-simulated's
2155
+ // windows) can interleave them: a host callback on isolate A runs
2156
+ // Ruby that evals isolate B, whose own callback re-enters A. Now A is
2157
+ // on the V8 stack (depth > 0) yet B — not A — is the isolate
2158
+ // CURRENTLY entered on this thread, so bootstrapping a scope on A and
2159
+ // opening a ContextScope would trip V8's "scope and Context do not
2160
+ // belong to the same Isolate" panic (it checks the scope's isolate
2161
+ // against Isolate::GetCurrent()). Detect that case and properly enter
2162
+ // A on top of B, restoring B on exit.
2163
+ let entered = depth == 0 || real_isolate != current_real_isolate();
2164
+ // Snapshot the ENCLOSING op's conservative-GC-scan start BEFORE
2165
+ // entering: Isolate::Enter re-points it at the native top, so reading
2166
+ // it afterwards would hand StackScope the clobbered value to
2167
+ // "restore" — stranding an enclosing FIBER op with a start on the
2168
+ // native stack, whose next GC walks off the fiber's mapped top and
2169
+ // SEGVs. None at depth 0, where the value in the field belongs to the
2170
+ // previous, already-finished op and so is nothing to preserve.
2171
+ let scan_start_field = self.scan_start_field.load(Ordering::Relaxed);
2172
+ let enclosing_scan_start = if depth > 0 && scan_start_field != 0 {
2173
+ Some(unsafe { *(scan_start_field as *const usize) })
2174
+ } else {
2175
+ None
2176
+ };
2177
+ if entered {
2088
2178
  unsafe { (*iso).enter() };
2089
- // In-thread: V8 runs on THIS native thread's stack. Re-point its
2090
- // stack limit at this thread's bottom before running JSthe
2091
- // create-time limit is fixed at a shallow (or other-thread)
2092
- // frame, so a deeper entry would false-overflow (and the bad
2093
- // throw trips V8's IsOnCentralStack CHECK -> fatal). iso_ptr is
2094
- // *mut v8::Isolate = *mut NonNull<RealIsolate>; the raw
2095
- // v8::Isolate* the C++ method wants is that NonNull's value.
2096
- let real_isolate = unsafe { *(iso as *const *mut c_void) };
2097
- // A live address ABOVE every V8 frame of this op (the scope and
2098
- // service_request below run in deeper frames). On a fiber it
2099
- // becomes V8's conservative-GC-scan stack_start so the scan stays
2100
- // within the live, mapped stack see set_v8_stack_limit.
2101
- let stack_top_marker = 0u8;
2102
- // Word-align (down): V8 CHECKs the scan start is pointer-aligned.
2103
- // Down stays above all V8 frames (they're a full frame below).
2104
- let stack_top = (&stack_top_marker as *const u8 as usize) & !(size_of::<usize>() - 1);
2105
- set_v8_stack_limit(
2106
- real_isolate,
2107
- self.scan_start_field.load(Ordering::Relaxed),
2108
- stack_top,
2109
- );
2179
+ }
2180
+ // In-thread: V8 runs on whatever stack the Ruby caller is on this
2181
+ // native thread's, or a Ruby Fiber's if a host callback resumed one.
2182
+ // Describe that stack to the isolate for the duration of the op, and
2183
+ // put the enclosing op's description back afterwards. Both the
2184
+ // create-time limit (fixed at a shallow, possibly other-thread frame)
2185
+ // and an enclosing op's are stale here, and a stale limit doesn't
2186
+ // merely false-overflow it makes the resulting throw FATAL. See
2187
+ // StackScope.
2188
+ //
2189
+ // stack_top_marker is a live address ABOVE every V8 frame of this op
2190
+ // (the scope and service_request below run in deeper frames); on a
2191
+ // fiber it becomes V8's conservative-GC-scan start so the scan stays
2192
+ // within the live, mapped stack.
2193
+ let stack_top_marker = 0u8;
2194
+ // Word-align (down): V8 CHECKs the scan start is pointer-aligned.
2195
+ // Down stays above all V8 frames (they're a full frame below).
2196
+ let stack_top = (&stack_top_marker as *const u8 as usize) & !(size_of::<usize>() - 1);
2197
+ let stack = StackScope::enter(
2198
+ real_isolate,
2199
+ scan_start_field,
2200
+ enclosing_scan_start,
2201
+ &self.installed_stack_limit,
2202
+ stack_top,
2203
+ );
2204
+ if depth == 0 {
2110
2205
  let mut reply = std::panic::catch_unwind(AssertUnwindSafe(|| {
2111
2206
  v8::scope!(let scope, unsafe { &mut *iso });
2112
2207
  service_request(scope, request, true)
@@ -2145,45 +2240,20 @@ impl Core {
2145
2240
  istate!(iso_ref).oom_fired = false;
2146
2241
  reply = reply.map(relabel_oom);
2147
2242
  }
2243
+ // Drop the stack description BEFORE popping the isolate — exit()
2244
+ // can leave a different one current. At depth 0 the drop restores
2245
+ // nothing (no enclosing op), so this just releases the scope.
2246
+ drop(stack);
2148
2247
  unsafe { (*iso).exit() };
2149
2248
  reply
2150
2249
  } else {
2151
- // Re-entrant (a host callback or module resolver, having
2152
- // reacquired the GVL to run a proc that issued this op, is on the
2153
- // V8 stack). USUALLY the JS on the stack belongs to THIS isolate
2154
- // (same-isolate reentry) which is therefore already entered on
2155
- // THIS native thread — so we bootstrap onto the ambient
2156
- // HandleScope without re-entering.
2157
- //
2158
- // But an embedder driving MANY isolates (e.g. capybara-simulated's
2159
- // windows) can interleave them: a host callback on isolate A runs
2160
- // Ruby that evals isolate B, whose own callback re-enters A. Now A
2161
- // is on the V8 stack (depth > 0) yet B — not A — is the isolate
2162
- // CURRENTLY entered on this thread, so bootstrapping a scope on A
2163
- // and opening a ContextScope would trip V8's "scope and Context do
2164
- // not belong to the same Isolate" panic (it checks the scope's
2165
- // isolate against Isolate::GetCurrent()). Detect that case and
2166
- // properly enter A on top of B, restoring B on exit. Entering an
2167
- // already-current isolate is also harmless (V8's entered-isolate
2168
- // stack nests), so this is correct for same-isolate reentry too —
2169
- // we only pay the enter/exit when a FOREIGN isolate is on top.
2170
- //
2171
- // The stack limit + scan-start set at depth 0 are NOT re-pointed
2172
- // here: reentry runs in DEEPER frames of the SAME stack, so the
2173
- // depth-0 values still bound it correctly (whichever isolate is
2174
- // current — each tracks its own limit). The one exception is a
2175
- // host callback that SWITCHES stacks — e.g. resumes a Ruby Fiber
2176
- // that itself evals — where the depth-0 (native) settings are
2177
- // stale for the fiber; that nested-fiber-under-callback case is an
2178
- // unsupported edge (the realistic fiber path is a depth-0 eval).
2179
- let foreign = unsafe { *(iso as *const *mut c_void) } != current_real_isolate();
2180
- if foreign {
2181
- unsafe { (*iso).enter() };
2182
- }
2250
+ // Re-entrant. `entered` is true only when a FOREIGN isolate was
2251
+ // on top (see above): that one had no ambient scope of ours under
2252
+ // its entry, so it opens a fresh HandleScope exactly as the
2253
+ // depth-0 path does. Same-isolate reentry bootstraps onto the
2254
+ // ambient one instead.
2183
2255
  let reply = std::panic::catch_unwind(AssertUnwindSafe(|| {
2184
- if foreign {
2185
- // A had no ambient scope under B's entry — open a fresh
2186
- // HandleScope on A, exactly as the depth-0 path does.
2256
+ if entered {
2187
2257
  v8::scope!(let scope, unsafe { &mut *iso });
2188
2258
  service_request(scope, request, false)
2189
2259
  } else {
@@ -2195,7 +2265,8 @@ impl Core {
2195
2265
  // Pop A back off (restoring B as current). Safe even after a
2196
2266
  // panic unwind: the scope's Drop ran but left A entered, and
2197
2267
  // exit() asserts A == GetCurrent(), which holds here.
2198
- if foreign {
2268
+ drop(stack);
2269
+ if entered {
2199
2270
  unsafe { (*iso).exit() };
2200
2271
  }
2201
2272
  reply
@@ -2242,15 +2313,12 @@ impl Core {
2242
2313
  // access pattern as swap_instantiate. Does NOT clear it (run() clears at the
2243
2314
  // next outermost op) so a nested timeout's outer frame can read it too.
2244
2315
  fn peek_timeout_backtrace(&self) -> Option<Vec<String>> {
2245
- istate!(unsafe { &mut *self.iso_ptr.0 }).timeout_backtrace.clone()
2316
+ istate!(unsafe { &mut *self.iso_ptr.0 })
2317
+ .timeout_backtrace
2318
+ .clone()
2246
2319
  }
2247
2320
 
2248
- fn call_proc(
2249
- &self,
2250
- ruby: &Ruby,
2251
- host_fn_id: usize,
2252
- args: &[JsVal],
2253
- ) -> Result<JsVal, String> {
2321
+ fn call_proc(&self, ruby: &Ruby, host_fn_id: usize, args: &[JsVal]) -> Result<JsVal, String> {
2254
2322
  let proc = {
2255
2323
  let procs = self.procs.lock().unwrap();
2256
2324
  procs
@@ -2286,7 +2354,13 @@ impl Core {
2286
2354
  // Context#call (and call_void). Resolves a dotted function path
2287
2355
  // on globalThis and invokes it via v8::Function::call. |void| skips
2288
2356
  // marshalling the return for fire-and-forget calls.
2289
- fn call(&self, ruby: &Ruby, context_id: i32, args: &[Value], void: bool) -> Result<Value, Error> {
2357
+ fn call(
2358
+ &self,
2359
+ ruby: &Ruby,
2360
+ context_id: i32,
2361
+ args: &[Value],
2362
+ void: bool,
2363
+ ) -> Result<Value, Error> {
2290
2364
  let Some((name, call_args)) = args.split_first() else {
2291
2365
  return Err(Error::new(
2292
2366
  ruby.exception_arg_error(),
@@ -2299,20 +2373,26 @@ impl Core {
2299
2373
  .map(|v| ruby_to_jsval(*v))
2300
2374
  .collect::<Result<_, _>>()?;
2301
2375
 
2302
- let reply = self.run(ruby, Request::Call {
2303
- context_id,
2304
- name,
2305
- args: jsargs,
2306
- void,
2307
- timeout_ms: self.default_timeout_ms,
2308
- })?;
2376
+ let reply = self.run(
2377
+ ruby,
2378
+ Request::Call {
2379
+ context_id,
2380
+ name,
2381
+ args: jsargs,
2382
+ void,
2383
+ timeout_ms: self.default_timeout_ms,
2384
+ },
2385
+ )?;
2309
2386
  self.reply_value(ruby, reply)
2310
2387
  }
2311
2388
 
2312
2389
  fn drain_microtasks(&self, ruby: &Ruby) -> Result<Value, Error> {
2313
- let reply = self.run(ruby, Request::DrainMicrotasks {
2314
- timeout_ms: self.default_timeout_ms,
2315
- })?;
2390
+ let reply = self.run(
2391
+ ruby,
2392
+ Request::DrainMicrotasks {
2393
+ timeout_ms: self.default_timeout_ms,
2394
+ },
2395
+ )?;
2316
2396
  self.reply_value(ruby, reply)
2317
2397
  }
2318
2398
 
@@ -2331,10 +2411,19 @@ impl Core {
2331
2411
  h.aset(ruby.to_symbol("total_heap_size"), s.total_heap_size)?;
2332
2412
  h.aset(ruby.to_symbol("heap_size_limit"), s.heap_size_limit)?;
2333
2413
  h.aset(ruby.to_symbol("malloced_memory"), s.malloced_memory)?;
2334
- h.aset(ruby.to_symbol("peak_malloced_memory"), s.peak_malloced_memory)?;
2414
+ h.aset(
2415
+ ruby.to_symbol("peak_malloced_memory"),
2416
+ s.peak_malloced_memory,
2417
+ )?;
2335
2418
  h.aset(ruby.to_symbol("external_memory"), s.external_memory)?;
2336
- h.aset(ruby.to_symbol("number_of_native_contexts"), s.number_of_native_contexts)?;
2337
- h.aset(ruby.to_symbol("number_of_detached_contexts"), s.number_of_detached_contexts)?;
2419
+ h.aset(
2420
+ ruby.to_symbol("number_of_native_contexts"),
2421
+ s.number_of_native_contexts,
2422
+ )?;
2423
+ h.aset(
2424
+ ruby.to_symbol("number_of_detached_contexts"),
2425
+ s.number_of_detached_contexts,
2426
+ )?;
2338
2427
  Ok(h.as_value())
2339
2428
  }
2340
2429
 
@@ -2353,26 +2442,38 @@ impl Core {
2353
2442
  filename: String,
2354
2443
  timeout_ms: u64,
2355
2444
  ) -> Result<Value, Error> {
2356
- let reply = self.run(ruby, Request::Eval {
2357
- context_id,
2358
- source,
2359
- filename,
2360
- timeout_ms,
2361
- })?;
2445
+ let reply = self.run(
2446
+ ruby,
2447
+ Request::Eval {
2448
+ context_id,
2449
+ source,
2450
+ filename,
2451
+ timeout_ms,
2452
+ },
2453
+ )?;
2362
2454
  self.reply_value(ruby, reply)
2363
2455
  }
2364
2456
 
2365
- fn attach(&self, ruby: &Ruby, context_id: i32, name: String, proc: Proc) -> Result<Value, Error> {
2457
+ fn attach(
2458
+ &self,
2459
+ ruby: &Ruby,
2460
+ context_id: i32,
2461
+ name: String,
2462
+ proc: Proc,
2463
+ ) -> Result<Value, Error> {
2366
2464
  let host_fn_id = self.procs.lock().unwrap().alloc(ProcSlot {
2367
2465
  context_id,
2368
2466
  proc: Some(RootedProc(BoxValue::new(proc))),
2369
2467
  });
2370
- let reply = self.run(ruby, Request::Attach {
2371
- context_id,
2372
- name,
2373
- host_fn_id,
2374
- timeout_ms: self.default_timeout_ms,
2375
- })?;
2468
+ let reply = self.run(
2469
+ ruby,
2470
+ Request::Attach {
2471
+ context_id,
2472
+ name,
2473
+ host_fn_id,
2474
+ timeout_ms: self.default_timeout_ms,
2475
+ },
2476
+ )?;
2376
2477
  self.reply_value(ruby, reply)
2377
2478
  }
2378
2479
 
@@ -2383,7 +2484,12 @@ impl Core {
2383
2484
  // (name-tagged) error WITHOUT rolling back earlier entries (see the
2384
2485
  // AttachMany arm). On that error path the unused slots are reclaimed at the
2385
2486
  // next reset/dispose of the realm, like single attach.
2386
- fn attach_many(&self, ruby: &Ruby, context_id: i32, entries: Vec<(String, Proc)>) -> Result<Value, Error> {
2487
+ fn attach_many(
2488
+ &self,
2489
+ ruby: &Ruby,
2490
+ context_id: i32,
2491
+ entries: Vec<(String, Proc)>,
2492
+ ) -> Result<Value, Error> {
2387
2493
  if entries.is_empty() {
2388
2494
  return Ok(ruby.qnil().as_value()); // nothing to install, skip the round-trip
2389
2495
  }
@@ -2400,11 +2506,14 @@ impl Core {
2400
2506
  })
2401
2507
  .collect()
2402
2508
  };
2403
- let reply = self.run(ruby, Request::AttachMany {
2404
- context_id,
2405
- entries: named_ids,
2406
- timeout_ms: self.default_timeout_ms,
2407
- })?;
2509
+ let reply = self.run(
2510
+ ruby,
2511
+ Request::AttachMany {
2512
+ context_id,
2513
+ entries: named_ids,
2514
+ timeout_ms: self.default_timeout_ms,
2515
+ },
2516
+ )?;
2408
2517
  self.reply_value(ruby, reply)
2409
2518
  }
2410
2519
 
@@ -2452,14 +2561,17 @@ impl Core {
2452
2561
  produce_cache: bool,
2453
2562
  eager: bool,
2454
2563
  ) -> Result<Compiled, Error> {
2455
- let reply = self.run(ruby, Request::CompileModule {
2456
- context_id,
2457
- source,
2458
- filename,
2459
- cached_data,
2460
- produce_cache,
2461
- eager,
2462
- })?;
2564
+ let reply = self.run(
2565
+ ruby,
2566
+ Request::CompileModule {
2567
+ context_id,
2568
+ source,
2569
+ filename,
2570
+ cached_data,
2571
+ produce_cache,
2572
+ eager,
2573
+ },
2574
+ )?;
2463
2575
  match reply {
2464
2576
  VmReply::ModuleCompiled(Ok(cm)) => Ok(cm),
2465
2577
  VmReply::ModuleCompiled(Err(e)) => Err(vm_err(ruby, e)),
@@ -2494,7 +2606,12 @@ impl Core {
2494
2606
  // it per import edge (it may compile a dependency lazily — a re-entrant op
2495
2607
  // that just recurses into run). A resolver that RAISED is re-raised here with
2496
2608
  // its original class.
2497
- fn instantiate_module(&self, ruby: &Ruby, module_id: i32, resolve: Proc) -> Result<Value, Error> {
2609
+ fn instantiate_module(
2610
+ &self,
2611
+ ruby: &Ruby,
2612
+ module_id: i32,
2613
+ resolve: Proc,
2614
+ ) -> Result<Value, Error> {
2498
2615
  // Guard BEFORE touching the slot via iso_ptr: a foreign-thread or
2499
2616
  // post-dispose call must be refused, not deref a freed/foreign isolate.
2500
2617
  self.ensure_owner_and_live(ruby)?;
@@ -2512,10 +2629,13 @@ impl Core {
2512
2629
  }
2513
2630
 
2514
2631
  fn evaluate_module(&self, ruby: &Ruby, module_id: i32) -> Result<Value, Error> {
2515
- let reply = self.run(ruby, Request::EvaluateModule {
2516
- module_id,
2517
- timeout_ms: self.default_timeout_ms,
2518
- })?;
2632
+ let reply = self.run(
2633
+ ruby,
2634
+ Request::EvaluateModule {
2635
+ module_id,
2636
+ timeout_ms: self.default_timeout_ms,
2637
+ },
2638
+ )?;
2519
2639
  self.reply_value(ruby, reply)
2520
2640
  }
2521
2641
 
@@ -2546,14 +2666,17 @@ impl Core {
2546
2666
  produce_cache: bool,
2547
2667
  eager: bool,
2548
2668
  ) -> Result<Compiled, Error> {
2549
- let reply = self.run(ruby, Request::CompileScript {
2550
- context_id,
2551
- source,
2552
- filename,
2553
- cached_data,
2554
- produce_cache,
2555
- eager,
2556
- })?;
2669
+ let reply = self.run(
2670
+ ruby,
2671
+ Request::CompileScript {
2672
+ context_id,
2673
+ source,
2674
+ filename,
2675
+ cached_data,
2676
+ produce_cache,
2677
+ eager,
2678
+ },
2679
+ )?;
2557
2680
  match reply {
2558
2681
  VmReply::ScriptCompiled(Ok(cs)) => Ok(cs),
2559
2682
  VmReply::ScriptCompiled(Err(e)) => Err(vm_err(ruby, e)),
@@ -2565,10 +2688,13 @@ impl Core {
2565
2688
  }
2566
2689
 
2567
2690
  fn run_script(&self, ruby: &Ruby, script_id: i32) -> Result<Value, Error> {
2568
- let reply = self.run(ruby, Request::RunScript {
2569
- script_id,
2570
- timeout_ms: self.default_timeout_ms,
2571
- })?;
2691
+ let reply = self.run(
2692
+ ruby,
2693
+ Request::RunScript {
2694
+ script_id,
2695
+ timeout_ms: self.default_timeout_ms,
2696
+ },
2697
+ )?;
2572
2698
  self.reply_value(ruby, reply)
2573
2699
  }
2574
2700
 
@@ -2788,7 +2914,10 @@ impl Context {
2788
2914
  fn check_live(&self, ruby: &Ruby) -> Result<(), Error> {
2789
2915
  // id 0's lifetime is the isolate's; extras also track their own dispose.
2790
2916
  if self.disposed.load(Ordering::SeqCst) || self.core.is_disposed() {
2791
- return Err(Error::new(ruby.exception_runtime_error(), "disposed context"));
2917
+ return Err(Error::new(
2918
+ ruby.exception_runtime_error(),
2919
+ "disposed context",
2920
+ ));
2792
2921
  }
2793
2922
  Ok(())
2794
2923
  }
@@ -2806,7 +2935,9 @@ impl Context {
2806
2935
  } else {
2807
2936
  timeout_ms
2808
2937
  };
2809
- rb_self.core.eval_t(ruby, rb_self.id, source, filename, timeout)
2938
+ rb_self
2939
+ .core
2940
+ .eval_t(ruby, rb_self.id, source, filename, timeout)
2810
2941
  }
2811
2942
  fn call(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result<Value, Error> {
2812
2943
  rb_self.check_live(ruby)?;
@@ -2849,9 +2980,15 @@ impl Context {
2849
2980
  ) -> Result<JsModule, Error> {
2850
2981
  rb_self.check_live(ruby)?;
2851
2982
  let cache_in = binary_bytes(ruby, cached_data)?;
2852
- let cm = rb_self
2853
- .core
2854
- .compile_module(ruby, rb_self.id, source, filename, cache_in, produce_cache, eager)?;
2983
+ let cm = rb_self.core.compile_module(
2984
+ ruby,
2985
+ rb_self.id,
2986
+ source,
2987
+ filename,
2988
+ cache_in,
2989
+ produce_cache,
2990
+ eager,
2991
+ )?;
2855
2992
  Ok(JsModule {
2856
2993
  core: rb_self.core.clone(),
2857
2994
  module_id: cm.id,
@@ -2873,9 +3010,15 @@ impl Context {
2873
3010
  ) -> Result<Script, Error> {
2874
3011
  rb_self.check_live(ruby)?;
2875
3012
  let cache_in = binary_bytes(ruby, cached_data)?;
2876
- let cs = rb_self
2877
- .core
2878
- .compile_script(ruby, rb_self.id, source, filename, cache_in, produce_cache, eager)?;
3013
+ let cs = rb_self.core.compile_script(
3014
+ ruby,
3015
+ rb_self.id,
3016
+ source,
3017
+ filename,
3018
+ cache_in,
3019
+ produce_cache,
3020
+ eager,
3021
+ )?;
2879
3022
  Ok(Script {
2880
3023
  core: rb_self.core.clone(),
2881
3024
  script_id: cs.id,
@@ -2968,7 +3111,10 @@ impl JsModule {
2968
3111
  // before run's guard) is gone — without this, instantiate after
2969
3112
  // iso.dispose is a use-after-free.
2970
3113
  if self.disposed.load(Ordering::SeqCst) || self.core.is_disposed() {
2971
- return Err(Error::new(ruby.exception_runtime_error(), "disposed module"));
3114
+ return Err(Error::new(
3115
+ ruby.exception_runtime_error(),
3116
+ "disposed module",
3117
+ ));
2972
3118
  }
2973
3119
  Ok(())
2974
3120
  }
@@ -2976,7 +3122,9 @@ impl JsModule {
2976
3122
  // the lib wrapper). resolver.(specifier, referrer_url) must return a Module.
2977
3123
  fn instantiate(ruby: &Ruby, rb_self: &Self, resolver: Proc) -> Result<Value, Error> {
2978
3124
  rb_self.check_live(ruby)?;
2979
- rb_self.core.instantiate_module(ruby, rb_self.module_id, resolver)
3125
+ rb_self
3126
+ .core
3127
+ .instantiate_module(ruby, rb_self.module_id, resolver)
2980
3128
  }
2981
3129
  fn evaluate(ruby: &Ruby, rb_self: &Self) -> Result<Value, Error> {
2982
3130
  rb_self.check_live(ruby)?;
@@ -3027,7 +3175,10 @@ impl Script {
3027
3175
  fn check_live(&self, ruby: &Ruby) -> Result<(), Error> {
3028
3176
  // Also refuse once the isolate is disposed (see JsModule::check_live).
3029
3177
  if self.disposed.load(Ordering::SeqCst) || self.core.is_disposed() {
3030
- return Err(Error::new(ruby.exception_runtime_error(), "disposed script"));
3178
+ return Err(Error::new(
3179
+ ruby.exception_runtime_error(),
3180
+ "disposed script",
3181
+ ));
3031
3182
  }
3032
3183
  Ok(())
3033
3184
  }
@@ -3089,7 +3240,10 @@ fn code_cache_from_reply(ruby: &Ruby, reply: VmReply) -> Result<Option<Vec<u8>>,
3089
3240
  // Read a Ruby cached_data arg as raw bytes, refusing a non-binary string so a
3090
3241
  // cache file read without 'rb' (silently transcoded) fails loudly rather than
3091
3242
  // being consumed as garbage and rejected with no signal.
3092
- fn binary_bytes(ruby: &Ruby, cached_data: Option<magnus::RString>) -> Result<Option<Vec<u8>>, Error> {
3243
+ fn binary_bytes(
3244
+ ruby: &Ruby,
3245
+ cached_data: Option<magnus::RString>,
3246
+ ) -> Result<Option<Vec<u8>>, Error> {
3093
3247
  match cached_data {
3094
3248
  None => Ok(None),
3095
3249
  Some(s) => {
@@ -3301,7 +3455,10 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
3301
3455
  // JS Map instead of a plain object. It behaves exactly like a Hash (so
3302
3456
  // `assert_kind_of Hash` / `h[k]` still work); users may also construct one to
3303
3457
  // hand a JS Map to JS. See marshal.rs.
3304
- module.define_class("JSMap", ruby.class_object().const_get::<_, magnus::RClass>("Hash")?)?;
3458
+ module.define_class(
3459
+ "JSMap",
3460
+ ruby.class_object().const_get::<_, magnus::RClass>("Hash")?,
3461
+ )?;
3305
3462
 
3306
3463
  let platform = module.define_module("Platform")?;
3307
3464
  platform.define_singleton_method("set_flags!", function!(platform_set_flags, -1))?;
@@ -3315,6 +3472,9 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
3315
3472
  // Observability for the thread-confined lifecycle (see Drop for Core).
3316
3473
  module.define_singleton_method("live_isolate_count", function!(live_isolate_count, 0))?;
3317
3474
  module.define_singleton_method("leaked_isolate_count", function!(leaked_isolate_count, 0))?;
3318
- module.define_singleton_method("pending_transfer_count", function!(pending_transfer_count, 0))?;
3475
+ module.define_singleton_method(
3476
+ "pending_transfer_count",
3477
+ function!(pending_transfer_count, 0),
3478
+ )?;
3319
3479
  Ok(())
3320
3480
  }