rusty_racer 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: af5578c6ac8ba4eb6d367097be2faef786fdc0cb077ac5c76c14f1ec50106d41
4
- data.tar.gz: 5cc05af64db35a3202c3fd72d2b0809e621ee7fa59c3db4e913ea537d835c438
3
+ metadata.gz: 7df2aeec586e04d155d2635f6df9b51e219b31108fad5829e7a5ed5eb8a784a3
4
+ data.tar.gz: '099e1e39f64e041d082fb86269ae4265406e4f05279e3d24909a9e6f4fa89de0'
5
5
  SHA512:
6
- metadata.gz: ab88c79367db640e4144c0844a2ffcc0168f353de01ad7c225902c756d350f7a48188d9a3c0890ec6da2a61dc83e6a2484e519ccb082bb171d4fa795f89a4e7f
7
- data.tar.gz: da6ca243ceb13b6d353ce15c6b59e725d617c0712e146a13017f2bbb2d7715dad0705849c4ce36b1b941b0dcb1a2a1750390d557b80c3e7a196809a66a5d4efa
6
+ metadata.gz: 5d8f2b15aca012b8c3933b960009e1d248cf2284774eff23c4740688796dfff97d4fe647fc0792f68e70fe4a66aa4fb4cdb7869451a82fac625178f57b8f56d2
7
+ data.tar.gz: c66d30a7c1bfd8d00f62c7ee99e6356f1f9f6c4a8aeebab9e05552a945c51d54f4dd31f3787fa95bd60e425e1dae3240fdb90186c9c2bb87a7b945a0b55b06cd
data/README.md CHANGED
@@ -38,12 +38,12 @@ Embed [V8](https://v8.dev/) in Ruby, built on [rusty_v8](https://crates.io/crate
38
38
  incumbent — if you want a battle-tested binding or **Windows** support, reach for
39
39
  it. rusty_racer differs where it counts for some workloads: native **ES modules +
40
40
  dynamic import** (mini_racer is eval/classic-script oriented); **richer
41
- marshalling** (the types above round-trip natively instead of through a
42
- JSON-shaped projection); and **in-thread execution** with no per-op thread hop,
41
+ marshalling** (`BigInt`/`Date`/`Map`/`Set` and shared/cyclic graphs cross as
42
+ distinct Ruby types, where mini_racer does a narrower value conversion); and
43
+ **in-thread execution** with no per-op thread hop,
43
44
  which is faster for overhead-dominated workloads (lots of tiny `eval`/`call`) and
44
- at parity once the per-op JS work dominates. Both axes of resource limiting are
45
- covereda `timeout_ms` (time) and a `memory_limit` (space), each catchable. It
46
- is also younger and **experimental** — fewer miles, no Windows yet. Parity with
45
+ at parity once the per-op JS work dominates. It is also younger and
46
+ **experimental**fewer miles, no Windows yet. Parity with
47
47
  mini_racer is not a goal; the overlap is convergent evolution, not a port.
48
48
 
49
49
  ## What it can do
@@ -4,7 +4,7 @@
4
4
  # libv8-rusty needed under the cibuildgem native-per-platform model).
5
5
  [package]
6
6
  name = "rusty_racer"
7
- version = "0.1.8"
7
+ version = "0.1.10"
8
8
  edition = "2021"
9
9
  publish = false
10
10
 
@@ -54,7 +54,7 @@ use marshal::{js_to_jsval, jsval_to_js, jsval_to_ruby, ruby_to_jsval, JsVal};
54
54
  mod ops;
55
55
  use ops::{run_source, service_request, Compiled, Request, VmReply};
56
56
  mod stack;
57
- use stack::{discover_scan_start_field, set_v8_stack_limit, STACK_DEBUG};
57
+ use stack::{current_real_isolate, discover_scan_start_field, set_v8_stack_limit, STACK_DEBUG};
58
58
  mod watchdog;
59
59
  use watchdog::{
60
60
  arm_watchdog, disarm_watchdog, run_js_bracketed, watchdog_loop, WatchdogShared, WATCHDOG_DEBUG,
@@ -242,6 +242,8 @@ fn relabel_oom(reply: VmReply) -> VmReply {
242
242
  VmReply::ModuleCompiled(r) => VmReply::ModuleCompiled(fix(r)),
243
243
  VmReply::ScriptCompiled(r) => VmReply::ScriptCompiled(fix(r)),
244
244
  VmReply::CodeCache(r) => VmReply::CodeCache(fix(r)),
245
+ // Carries no Result and can't OOM (no JS allocation) — pass through.
246
+ VmReply::Heap(s) => VmReply::Heap(s),
245
247
  }
246
248
  }
247
249
 
@@ -519,6 +521,48 @@ struct ScriptReg {
519
521
  struct V8State {
520
522
  main_context: Option<v8::Global<v8::Context>>,
521
523
  contexts: HashMap<i32, v8::Global<v8::Context>>,
524
+ // Each realm gets its OWN v8::MicrotaskQueue (created in new_realm, owned
525
+ // here, keyed like the contexts: main_queue for id 0, queues for the rest).
526
+ // Why per-realm rather than the isolate-wide default: reset/dispose can then
527
+ // DISCARD a torn-down realm's pending microtasks by simply dropping its queue
528
+ // — without that, a queued promise reaction (it captures its creation realm)
529
+ // sits in the shared queue forever and pins the old v8::Context, so a warm
530
+ // isolate that Context#resets per visit leaks one whole realm per reset (V8
531
+ // counts it as a live native context; even a full GC can't reclaim it). The
532
+ // queue must outlive its context: dropping the UniqueRef DESTRUCTs the queue,
533
+ // which removes it from V8's per-isolate ring (so V8 won't scan it) and frees
534
+ // its microtasks. reset/dispose don't drop it directly — they move the old
535
+ // (context, queue) into `retiring` so flush_retiring can repoint then free it
536
+ // safely (see those fields).
537
+ main_queue: Option<v8::UniqueRef<v8::MicrotaskQueue>>,
538
+ queues: HashMap<i32, v8::UniqueRef<v8::MicrotaskQueue>>,
539
+ // A long-lived, never-drained queue a retired realm's context is repointed to
540
+ // before its own queue is freed. V8 enqueues a promise reaction into the
541
+ // HANDLER's context's queue, and rusty's realms are mutually accessible (one
542
+ // shared security token + NS.contextGlobal), so a LIVE realm can still hold —
543
+ // and later resolve — a promise whose handler lives in a realm we tore down;
544
+ // if that realm's queue were already freed the enqueue would be a
545
+ // use-after-free. Repointing to the graveyard makes any such late enqueue land
546
+ // in valid memory (the microtask simply never runs). Created once per isolate,
547
+ // dropped only at isolate teardown.
548
+ //
549
+ // TRADEOFF: the graveyard is never drained, so a microtask landed there lives
550
+ // until isolate teardown — a small, bounded-per-occurrence leak on the narrow
551
+ // "resolve a promise into an already-disposed realm" path. Vastly smaller than
552
+ // the whole-realm-per-reset leak this design fixes, and empty in normal use
553
+ // (you don't resolve a disposed realm's promises), so it is an accepted cost of
554
+ // keeping that late enqueue memory-safe.
555
+ graveyard_queue: Option<v8::UniqueRef<v8::MicrotaskQueue>>,
556
+ // Realms retired by reset/dispose, awaiting teardown. We can't free a realm's
557
+ // queue at reset/dispose time: (1) Context::SetMicrotaskQueue (the graveyard
558
+ // repoint) requires NO context entered, which fails for a NESTED reset (an
559
+ // outer eval's context is on the stack); (2) freeing before repointing risks
560
+ // the cross-realm use-after-free above. So we stash (old context, old queue)
561
+ // here — both stay alive, so no dangling pointer and no unbounded leak — and
562
+ // flush_retiring drains this list at the end of the outermost request, when
563
+ // no context is entered: it repoints each context to the graveyard, then drops
564
+ // the queues (discarding their pending microtasks — the actual leak fix).
565
+ retiring: Vec<(v8::Global<v8::Context>, v8::UniqueRef<v8::MicrotaskQueue>)>,
522
566
  next_context_id: i32,
523
567
  host_namespace: Option<String>,
524
568
  // One security token shared by every realm of this isolate: the
@@ -585,6 +629,12 @@ struct IsolateState {
585
629
  // platform-derived default, whose value we don't otherwise know — so we restore
586
630
  // to this captured initial instead. 0 until the callback has fired at least once.
587
631
  oom_initial_limit: usize,
632
+ // The JS stack captured at a watchdog timeout: when a deadline fires, the
633
+ // watchdog requests an interrupt that runs on THIS thread with JS still live
634
+ // (see timeout_interrupt) and snapshots the stack here BEFORE terminating, so
635
+ // the ScriptTerminatedError can name what was running. Taken (cleared) when the
636
+ // terminated op's error is built; None for any non-timeout outcome.
637
+ timeout_backtrace: Option<Vec<String>>,
588
638
  }
589
639
 
590
640
  impl IsolateState {
@@ -608,6 +658,7 @@ impl IsolateState {
608
658
  watchdog: WatchdogShared::new(),
609
659
  oom_fired: false,
610
660
  oom_initial_limit: 0,
661
+ timeout_backtrace: None,
611
662
  }
612
663
  }
613
664
  }
@@ -695,12 +746,44 @@ fn transfer_registry() -> &'static Mutex<HashMap<u64, SendBackingStore>> {
695
746
  // transfers, which a process will never reach.
696
747
  static NEXT_TRANSFER_TOKEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
697
748
 
749
+ // Drain EVERY realm's microtask queue once. Each realm has its own queue (see
750
+ // V8State::queues), so a single scope.perform_microtask_checkpoint() — which only
751
+ // touches the isolate's default queue — would run NOTHING (every realm's promises
752
+ // land in the realm's own queue). This restores the old isolate-wide "drain
753
+ // everything" semantics: a microtask queued in ANY realm runs, regardless of
754
+ // which realm the checkpoint was requested from. V8 drains each queue until empty
755
+ // and enters each microtask's own realm, so same-realm cascades fully resolve in
756
+ // one pass; a cross-realm cascade (a microtask in realm A enqueueing into realm B
757
+ // that was already drained this pass) resolves on the next checkpoint — callers
758
+ // that need full quiescence loop (csim does).
759
+ fn drain_all_realms(scope: &mut v8::PinScope<'_, '_>) {
760
+ // Snapshot the queue pointers, then drain without holding the IsolateState
761
+ // borrow (a microtask re-enters host fns that borrow it). The queue OBJECTS
762
+ // are address-stable (owned via UniqueRef, heap-allocated by V8), so the
763
+ // snapshot survives a queues-HashMap realloc (e.g. a microtask creating a
764
+ // realm); and reset/dispose — the only things that free a queue — are refused
765
+ // while draining (checkpoint_draining set the flag), so no pointer dangles.
766
+ let queues: Vec<*const v8::MicrotaskQueue> = {
767
+ let st = istate!(scope);
768
+ st.realms
769
+ .main_queue
770
+ .iter()
771
+ .chain(st.realms.queues.values())
772
+ .map(|q| &**q as *const v8::MicrotaskQueue)
773
+ .collect()
774
+ };
775
+ for q in queues {
776
+ unsafe { (*q).perform_checkpoint(&mut ***scope) };
777
+ }
778
+ }
779
+
698
780
  // Run a microtask checkpoint with DRAINING set (nesting-safe via save/restore),
699
- // so a nested Reset/DisposeContext issued by a drained microtask is refused.
781
+ // so a nested Reset/DisposeContext issued by a drained microtask is refused
782
+ // which also keeps drain_all_realms's queue snapshot from dangling.
700
783
  fn checkpoint_draining(scope: &mut v8::PinScope<'_, '_>) {
701
784
  let prev = istate!(scope).draining;
702
785
  istate!(scope).draining = true;
703
- scope.perform_microtask_checkpoint();
786
+ drain_all_realms(scope);
704
787
  istate!(scope).draining = prev;
705
788
  }
706
789
 
@@ -1297,7 +1380,9 @@ fn drain_microtasks(
1297
1380
  _args: v8::FunctionCallbackArguments<'_>,
1298
1381
  _rv: v8::ReturnValue<'_, v8::Value>,
1299
1382
  ) {
1300
- scope.perform_microtask_checkpoint();
1383
+ // Drain every realm's queue (isolate-wide semantics), under the draining
1384
+ // guard so a microtask can't reset/dispose a realm mid-drain.
1385
+ checkpoint_draining(scope);
1301
1386
  }
1302
1387
 
1303
1388
  // NS.contextGlobal(id) -> the globalThis of context |id|. Cross-context
@@ -1572,10 +1657,20 @@ unsafe extern "C" fn promise_reject_cb(message: v8::PromiseRejectMessage) {
1572
1657
 
1573
1658
  // Build a fresh v8::Context and install the host namespace (from STATE) into
1574
1659
  // it — the single definition of "a realm of this isolate", shared by boot,
1575
- // reset and create_context so realms can't drift apart.
1576
- fn new_realm(scope: &mut v8::PinScope<'_, '_, ()>) -> v8::Global<v8::Context> {
1660
+ // reset and create_context so realms can't drift apart. Returns the context
1661
+ // Global AND its dedicated microtask queue; the caller owns the queue in
1662
+ // V8State alongside the context (see V8State::queues for why per-realm).
1663
+ fn new_realm(
1664
+ scope: &mut v8::PinScope<'_, '_, ()>,
1665
+ ) -> (v8::Global<v8::Context>, v8::UniqueRef<v8::MicrotaskQueue>) {
1666
+ // Explicit policy like the isolate's: rusty drives every drain by hand
1667
+ // (auto_drain / NS.drainMicrotasks), so V8 must never auto-run this queue.
1668
+ let mut queue = v8::MicrotaskQueue::new(&mut **scope, v8::MicrotasksPolicy::Explicit);
1577
1669
  let fresh = {
1578
- let context = v8::Context::new(scope, Default::default());
1670
+ let context = v8::Context::new(scope, v8::ContextOptions {
1671
+ microtask_queue: Some(&mut *queue as *mut _),
1672
+ ..Default::default()
1673
+ });
1579
1674
  v8::Global::new(scope, context)
1580
1675
  };
1581
1676
  // DESIGN DECISION: every realm of an isolate shares ONE security token, so
@@ -1615,7 +1710,44 @@ fn new_realm(scope: &mut v8::PinScope<'_, '_, ()>) -> v8::Global<v8::Context> {
1615
1710
  if let Some(name) = host_namespace {
1616
1711
  install_host_namespace(scope, &fresh, &name);
1617
1712
  }
1618
- fresh
1713
+ (fresh, queue)
1714
+ }
1715
+
1716
+ // Tear down every realm parked in `retiring` (by reset/dispose): repoint each old
1717
+ // context at the graveyard queue, then free the old queues (discarding their
1718
+ // pending microtasks — the leak fix). MUST run with NO context entered, because
1719
+ // Context::SetMicrotaskQueue requires it — so the only caller is the OUTERMOST
1720
+ // service_request, after its op (and all nested ones) have unwound their
1721
+ // ContextScopes. A no-op when nothing is retired.
1722
+ fn flush_retiring(scope: &mut v8::PinScope<'_, '_, ()>) {
1723
+ if istate!(scope).realms.retiring.is_empty() {
1724
+ return;
1725
+ }
1726
+ let graveyard: *const v8::MicrotaskQueue = match istate!(scope).realms.graveyard_queue.as_ref() {
1727
+ Some(q) => &**q as *const _,
1728
+ // The graveyard is created at boot and never cleared, so with entries
1729
+ // waiting this is unreachable; assert so a future refactor that defers its
1730
+ // creation fails loudly instead of silently stranding `retiring` (which
1731
+ // would quietly resurrect the whole-realm leak). Bail without taking the
1732
+ // list, so nothing is lost if it somehow happens in release.
1733
+ None => {
1734
+ debug_assert!(false, "graveyard queue missing while realms are retiring");
1735
+ return;
1736
+ }
1737
+ };
1738
+ let retiring = std::mem::take(&mut istate!(scope).realms.retiring);
1739
+ for (ctx, _queue) in &retiring {
1740
+ let local = v8::Local::new(scope, ctx);
1741
+ // SAFETY: the graveyard queue is owned by V8State for the isolate's whole
1742
+ // life, so the pointer is valid for this set_microtask_queue call. Repoint
1743
+ // BEFORE the queues drop below, so a cross-realm reference that keeps `ctx`
1744
+ // alive can't be left holding a freed queue.
1745
+ local.set_microtask_queue(unsafe { &*graveyard });
1746
+ }
1747
+ // Dropping `retiring` here frees each old queue (and its now-discarded pending
1748
+ // microtasks) and each old context Global. Every context was just repointed to
1749
+ // the graveyard, so no live context holds a freed queue pointer.
1750
+ drop(retiring);
1619
1751
  }
1620
1752
 
1621
1753
  // Inject globalThis.<name> = { drainMicrotasks } into a context. Re-run on
@@ -1828,8 +1960,12 @@ impl Isolate {
1828
1960
  // namespace from the slot (seeded above).
1829
1961
  {
1830
1962
  v8::scope!(let scope, &mut isolate);
1831
- let main_context = new_realm(scope);
1963
+ let (main_context, main_queue) = new_realm(scope);
1832
1964
  istate!(scope).realms.main_context = Some(main_context);
1965
+ istate!(scope).realms.main_queue = Some(main_queue);
1966
+ // The shared graveyard for retired realms' contexts (see V8State).
1967
+ let graveyard = v8::MicrotaskQueue::new(&mut **scope, v8::MicrotasksPolicy::Explicit);
1968
+ istate!(scope).realms.graveyard_queue = Some(graveyard);
1833
1969
  }
1834
1970
  // Box the OwnedIsolate so it has a STABLE address, then capture a raw ptr
1835
1971
  // INTO the box (a `&mut Isolate` is `&mut NonNull<RealIsolate>`, pointing
@@ -1843,6 +1979,10 @@ impl Isolate {
1843
1979
  // Registered unconditionally — with memory_limit it guards that ceiling,
1844
1980
  // without one it guards V8's default ceiling (catchable, not a process abort).
1845
1981
  boxed.add_near_heap_limit_callback(near_heap_limit_cb, iso_ptr.0 as *mut c_void);
1982
+ // Wire the watchdog's interrupt target now that iso_ptr is stable: on a
1983
+ // timeout the loop requests an interrupt against this ptr to capture the JS
1984
+ // stack on the isolate thread before terminating (see timeout_interrupt).
1985
+ watchdog.set_iso_ptr(iso_ptr.0 as *mut c_void);
1846
1986
  let iso_id = NEXT_ISOLATE_ID.fetch_add(1, Ordering::SeqCst);
1847
1987
  isolates().lock().unwrap().insert(iso_id, SendIso(boxed));
1848
1988
  // Root the owner Thread VALUE so its address can't be reused while this
@@ -1920,6 +2060,16 @@ impl Core {
1920
2060
  self.ensure_owner_and_live(ruby)?;
1921
2061
  let iso = self.iso_ptr.0;
1922
2062
  let depth = self.depth.fetch_add(1, Ordering::SeqCst);
2063
+ // Drop any prior timeout's captured stack at the START of an OUTERMOST op,
2064
+ // so it can't leak onto this op's error. Cleared only at depth 0 (not for
2065
+ // nested ops) on purpose: a nested timeout's terminate is isolate-global
2066
+ // and tears down the whole op stack, so EVERY frame's error (nested and the
2067
+ // escalated outer one) should surface the same culprit — reply_value peeks
2068
+ // (clones) rather than takes, leaving it readable for the outer frame until
2069
+ // the next outermost op clears it here.
2070
+ if depth == 0 {
2071
+ istate!(unsafe { &mut *iso }).timeout_backtrace = None;
2072
+ }
1923
2073
  // EVERYTHING that touches V8 — enter, the scope, the JS run, the scope
1924
2074
  // drop, exit — happens inside ONE without_gvl, hence on ONE native thread
1925
2075
  // with no GVL boundary in between: M:N could otherwise migrate us to a
@@ -1998,22 +2148,57 @@ impl Core {
1998
2148
  unsafe { (*iso).exit() };
1999
2149
  reply
2000
2150
  } else {
2001
- // Re-entrant (a host callback, having reacquired the GVL to run a
2002
- // proc that issued this op, is on the V8 stack): the isolate is
2003
- // already entered by the depth-0 op on THIS native thread, so
2004
- // bootstrap onto the ambient HandleScope rather than re-enter.
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
+ //
2005
2171
  // The stack limit + scan-start set at depth 0 are NOT re-pointed
2006
2172
  // here: reentry runs in DEEPER frames of the SAME stack, so the
2007
- // depth-0 values still bound it correctly. The one exception is a
2173
+ // depth-0 values still bound it correctly (whichever isolate is
2174
+ // current — each tracks its own limit). The one exception is a
2008
2175
  // host callback that SWITCHES stacks — e.g. resumes a Ruby Fiber
2009
2176
  // that itself evals — where the depth-0 (native) settings are
2010
2177
  // stale for the fiber; that nested-fiber-under-callback case is an
2011
2178
  // unsupported edge (the realistic fiber path is a depth-0 eval).
2012
- std::panic::catch_unwind(AssertUnwindSafe(|| {
2013
- v8::callback_scope!(unsafe scope, unsafe { &mut *iso });
2014
- service_request(scope, request, false)
2179
+ let foreign = unsafe { *(iso as *const *mut c_void) } != current_real_isolate();
2180
+ if foreign {
2181
+ unsafe { (*iso).enter() };
2182
+ }
2183
+ 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.
2187
+ v8::scope!(let scope, unsafe { &mut *iso });
2188
+ service_request(scope, request, false)
2189
+ } else {
2190
+ v8::callback_scope!(unsafe scope, unsafe { &mut *iso });
2191
+ service_request(scope, request, false)
2192
+ }
2015
2193
  }))
2016
- .ok()
2194
+ .ok();
2195
+ // Pop A back off (restoring B as current). Safe even after a
2196
+ // panic unwind: the scope's Drop ran but left A entered, and
2197
+ // exit() asserts A == GetCurrent(), which holds here.
2198
+ if foreign {
2199
+ unsafe { (*iso).exit() };
2200
+ }
2201
+ reply
2017
2202
  }
2018
2203
  });
2019
2204
  self.depth.fetch_sub(1, Ordering::SeqCst);
@@ -2032,9 +2217,18 @@ impl Core {
2032
2217
  }
2033
2218
 
2034
2219
  // Map a terminal reply to a Ruby value (the common eval/call/run shape).
2035
- fn reply_value(ruby: &Ruby, reply: VmReply) -> Result<Value, Error> {
2220
+ // &self so a Terminated outcome can pick up the JS stack the watchdog snapshot
2221
+ // captured (see timeout_interrupt / take_timeout_backtrace).
2222
+ fn reply_value(&self, ruby: &Ruby, reply: VmReply) -> Result<Value, Error> {
2036
2223
  match reply {
2037
2224
  VmReply::Done(Ok(val)) => jsval_to_ruby(ruby, &val),
2225
+ // Clone (don't take): a nested timeout's terminate unwinds the whole op
2226
+ // stack, so the escalated outer frame's error should name the same
2227
+ // culprit too. The capture is dropped at the next outermost op's start
2228
+ // (run), which keeps it from leaking onto an unrelated later op.
2229
+ VmReply::Done(Err(VmError::Terminated)) => {
2230
+ Err(terminated_error(ruby, self.peek_timeout_backtrace()))
2231
+ }
2038
2232
  VmReply::Done(Err(e)) => Err(vm_err(ruby, e)),
2039
2233
  _ => Err(Error::new(
2040
2234
  ruby.exception_runtime_error(),
@@ -2043,6 +2237,14 @@ impl Core {
2043
2237
  }
2044
2238
  }
2045
2239
 
2240
+ // Clone the JS stack the watchdog's interrupt captured at the last timeout.
2241
+ // Owner thread, isolate live — called right after run() returns, the same
2242
+ // access pattern as swap_instantiate. Does NOT clear it (run() clears at the
2243
+ // next outermost op) so a nested timeout's outer frame can read it too.
2244
+ fn peek_timeout_backtrace(&self) -> Option<Vec<String>> {
2245
+ istate!(unsafe { &mut *self.iso_ptr.0 }).timeout_backtrace.clone()
2246
+ }
2247
+
2046
2248
  fn call_proc(
2047
2249
  &self,
2048
2250
  ruby: &Ruby,
@@ -2104,14 +2306,43 @@ impl Core {
2104
2306
  void,
2105
2307
  timeout_ms: self.default_timeout_ms,
2106
2308
  })?;
2107
- Self::reply_value(ruby, reply)
2309
+ self.reply_value(ruby, reply)
2108
2310
  }
2109
2311
 
2110
2312
  fn drain_microtasks(&self, ruby: &Ruby) -> Result<Value, Error> {
2111
2313
  let reply = self.run(ruby, Request::DrainMicrotasks {
2112
2314
  timeout_ms: self.default_timeout_ms,
2113
2315
  })?;
2114
- Self::reply_value(ruby, reply)
2316
+ self.reply_value(ruby, reply)
2317
+ }
2318
+
2319
+ // Isolate#heap_statistics -> a Symbol-keyed Hash of v8::HeapStatistics
2320
+ // (bytes; the two *_contexts entries are counts). See Request::HeapStatistics.
2321
+ fn heap_statistics(&self, ruby: &Ruby) -> Result<Value, Error> {
2322
+ let reply = self.run(ruby, Request::HeapStatistics)?;
2323
+ let VmReply::Heap(s) = reply else {
2324
+ return Err(Error::new(
2325
+ ruby.exception_runtime_error(),
2326
+ "internal: unexpected heap reply",
2327
+ ));
2328
+ };
2329
+ let h = ruby.hash_new();
2330
+ h.aset(ruby.to_symbol("used_heap_size"), s.used_heap_size)?;
2331
+ h.aset(ruby.to_symbol("total_heap_size"), s.total_heap_size)?;
2332
+ h.aset(ruby.to_symbol("heap_size_limit"), s.heap_size_limit)?;
2333
+ h.aset(ruby.to_symbol("malloced_memory"), s.malloced_memory)?;
2334
+ h.aset(ruby.to_symbol("peak_malloced_memory"), s.peak_malloced_memory)?;
2335
+ 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)?;
2338
+ Ok(h.as_value())
2339
+ }
2340
+
2341
+ // Isolate#low_memory_notification: ask V8 to run a full GC now.
2342
+ fn low_memory_notification(&self, ruby: &Ruby) -> Result<(), Error> {
2343
+ let reply = self.run(ruby, Request::LowMemoryNotification)?;
2344
+ self.reply_value(ruby, reply)?;
2345
+ Ok(())
2115
2346
  }
2116
2347
 
2117
2348
  fn eval_t(
@@ -2128,7 +2359,7 @@ impl Core {
2128
2359
  filename,
2129
2360
  timeout_ms,
2130
2361
  })?;
2131
- Self::reply_value(ruby, reply)
2362
+ self.reply_value(ruby, reply)
2132
2363
  }
2133
2364
 
2134
2365
  fn attach(&self, ruby: &Ruby, context_id: i32, name: String, proc: Proc) -> Result<Value, Error> {
@@ -2142,7 +2373,7 @@ impl Core {
2142
2373
  host_fn_id,
2143
2374
  timeout_ms: self.default_timeout_ms,
2144
2375
  })?;
2145
- Self::reply_value(ruby, reply)
2376
+ self.reply_value(ruby, reply)
2146
2377
  }
2147
2378
 
2148
2379
  // attach_many: install several host fns in ONE round-trip to the V8 thread
@@ -2174,7 +2405,7 @@ impl Core {
2174
2405
  entries: named_ids,
2175
2406
  timeout_ms: self.default_timeout_ms,
2176
2407
  })?;
2177
- Self::reply_value(ruby, reply)
2408
+ self.reply_value(ruby, reply)
2178
2409
  }
2179
2410
 
2180
2411
  // Release the GC roots of the procs attached into |context_id| — its
@@ -2188,7 +2419,7 @@ impl Core {
2188
2419
 
2189
2420
  fn reset(&self, ruby: &Ruby, context_id: i32) -> Result<Value, Error> {
2190
2421
  let reply = self.run(ruby, Request::Reset { context_id })?;
2191
- let out = Self::reply_value(ruby, reply)?;
2422
+ let out = self.reply_value(ruby, reply)?;
2192
2423
  // Only on success — a refused reset (unknown/suspended realm) keeps
2193
2424
  // its attached fns callable.
2194
2425
  self.release_context_procs(context_id);
@@ -2198,13 +2429,13 @@ impl Core {
2198
2429
  // Build a new context; returns its id (replied as an Int).
2199
2430
  fn create_context(&self, ruby: &Ruby) -> Result<i32, Error> {
2200
2431
  let reply = self.run(ruby, Request::CreateContext)?;
2201
- let id = Self::reply_value(ruby, reply)?;
2432
+ let id = self.reply_value(ruby, reply)?;
2202
2433
  i32::try_convert(id)
2203
2434
  }
2204
2435
 
2205
2436
  fn dispose_context(&self, ruby: &Ruby, context_id: i32) -> Result<(), Error> {
2206
2437
  let reply = self.run(ruby, Request::DisposeContext { context_id })?;
2207
- Self::reply_value(ruby, reply)?;
2438
+ self.reply_value(ruby, reply)?;
2208
2439
  self.release_context_procs(context_id);
2209
2440
  Ok(())
2210
2441
  }
@@ -2277,7 +2508,7 @@ impl Core {
2277
2508
  if let Some(exc) = resolver_err {
2278
2509
  return Err(Error::from(*exc));
2279
2510
  }
2280
- Self::reply_value(ruby, reply?)
2511
+ self.reply_value(ruby, reply?)
2281
2512
  }
2282
2513
 
2283
2514
  fn evaluate_module(&self, ruby: &Ruby, module_id: i32) -> Result<Value, Error> {
@@ -2285,22 +2516,22 @@ impl Core {
2285
2516
  module_id,
2286
2517
  timeout_ms: self.default_timeout_ms,
2287
2518
  })?;
2288
- Self::reply_value(ruby, reply)
2519
+ self.reply_value(ruby, reply)
2289
2520
  }
2290
2521
 
2291
2522
  fn module_namespace(&self, ruby: &Ruby, module_id: i32) -> Result<Value, Error> {
2292
2523
  let reply = self.run(ruby, Request::ModuleNamespace { module_id })?;
2293
- Self::reply_value(ruby, reply)
2524
+ self.reply_value(ruby, reply)
2294
2525
  }
2295
2526
 
2296
2527
  fn module_status(&self, ruby: &Ruby, module_id: i32) -> Result<Value, Error> {
2297
2528
  let reply = self.run(ruby, Request::ModuleStatus { module_id })?;
2298
- Self::reply_value(ruby, reply)
2529
+ self.reply_value(ruby, reply)
2299
2530
  }
2300
2531
 
2301
2532
  fn dispose_module(&self, ruby: &Ruby, module_id: i32) -> Result<(), Error> {
2302
2533
  let reply = self.run(ruby, Request::DisposeModule { module_id })?;
2303
- Self::reply_value(ruby, reply).map(|_| ())
2534
+ self.reply_value(ruby, reply).map(|_| ())
2304
2535
  }
2305
2536
 
2306
2537
  // Classic script: compile, run, dispose.
@@ -2338,12 +2569,12 @@ impl Core {
2338
2569
  script_id,
2339
2570
  timeout_ms: self.default_timeout_ms,
2340
2571
  })?;
2341
- Self::reply_value(ruby, reply)
2572
+ self.reply_value(ruby, reply)
2342
2573
  }
2343
2574
 
2344
2575
  fn dispose_script(&self, ruby: &Ruby, script_id: i32) -> Result<(), Error> {
2345
2576
  let reply = self.run(ruby, Request::DisposeScript { script_id })?;
2346
- Self::reply_value(ruby, reply).map(|_| ())
2577
+ self.reply_value(ruby, reply).map(|_| ())
2347
2578
  }
2348
2579
 
2349
2580
  // Serialize a fresh bytecode cache from a compiled handle's current state
@@ -2524,6 +2755,17 @@ impl Isolate {
2524
2755
  fn perform_microtask_checkpoint(ruby: &Ruby, rb_self: &Self) -> Result<Value, Error> {
2525
2756
  rb_self.core.drain_microtasks(ruby)
2526
2757
  }
2758
+ // Isolate#heap_statistics -> Hash. A diagnostic window into the V8 heap;
2759
+ // watch number_of_native_contexts / number_of_detached_contexts to spot a
2760
+ // realm leak across Context#reset.
2761
+ fn heap_statistics(ruby: &Ruby, rb_self: &Self) -> Result<Value, Error> {
2762
+ rb_self.core.heap_statistics(ruby)
2763
+ }
2764
+ // Isolate#low_memory_notification: full GC now. Reclaims dead realms between
2765
+ // visits, and distinguishes reclaimable garbage from a genuine leak.
2766
+ fn low_memory_notification(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
2767
+ rb_self.core.low_memory_notification(ruby)
2768
+ }
2527
2769
  // dynamic_import_resolver = ->(specifier, referrer_url) { module } for import().
2528
2770
  fn set_dynamic_import_resolver(rb_self: &Self, proc: Proc) {
2529
2771
  rb_self.core.set_dynamic_import_resolver(proc);
@@ -2884,6 +3126,36 @@ fn vm_err(ruby: &Ruby, e: VmError) -> Error {
2884
3126
  }
2885
3127
  }
2886
3128
 
3129
+ // Build a RustyRacer::ScriptTerminatedError, attaching the JS stack the watchdog
3130
+ // snapshotted at the timeout (top frame first) as both #js_backtrace and — when
3131
+ // non-empty — the exception's Ruby backtrace, plus the top frame in the message
3132
+ // so even plain logging names what was running. js_backtrace is None for a stop
3133
+ // that isn't a watchdog timeout (e.g. Isolate#terminate), where no stack was
3134
+ // captured; then #js_backtrace is [] and the Ruby backtrace is left intact.
3135
+ fn terminated_error(ruby: &Ruby, js_backtrace: Option<Vec<String>>) -> Error {
3136
+ let class = err_class(ruby, "ScriptTerminatedError");
3137
+ let frames = js_backtrace.unwrap_or_default();
3138
+ let message = match frames.first() {
3139
+ Some(top) => format!("JavaScript was terminated (timeout or stop); running: {top}"),
3140
+ None => "JavaScript was terminated (timeout or stop)".to_string(),
3141
+ };
3142
+ let exc: Value = match class.funcall("new", (message.as_str(),)) {
3143
+ Ok(v) => v,
3144
+ Err(e) => return e,
3145
+ };
3146
+ // Always expose #js_backtrace (even []) so the accessor is never nil.
3147
+ let _ = exc.funcall::<_, _, Value>("instance_variable_set", ("@js_backtrace", frames.clone()));
3148
+ // Only override the Ruby backtrace when we actually have JS frames — for a
3149
+ // bare stop, keep Ruby's own backtrace (the eval call site) instead of [].
3150
+ if !frames.is_empty() {
3151
+ let _ = exc.funcall::<_, _, Value>("set_backtrace", (frames,));
3152
+ }
3153
+ match magnus::Exception::from_value(exc) {
3154
+ Some(e) => Error::from(e),
3155
+ None => Error::new(class, message),
3156
+ }
3157
+ }
3158
+
2887
3159
  // Build a RustyRacer::RuntimeError carrying the JS stack as its Ruby backtrace.
2888
3160
  // Constructs the exception instance so we can set_backtrace before raising;
2889
3161
  // falls back to a plain Error if any of that fails.
@@ -2971,6 +3243,11 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
2971
3243
  "_set_dynamic_import_resolver",
2972
3244
  method!(Isolate::set_dynamic_import_resolver, 1),
2973
3245
  )?;
3246
+ isolate.define_method("heap_statistics", method!(Isolate::heap_statistics, 0))?;
3247
+ isolate.define_method(
3248
+ "low_memory_notification",
3249
+ method!(Isolate::low_memory_notification, 0),
3250
+ )?;
2974
3251
  isolate.define_method("dispose", method!(Isolate::dispose, 0))?;
2975
3252
  isolate.define_method("disposed?", method!(Isolate::disposed, 0))?;
2976
3253
 
@@ -137,6 +137,18 @@ pub(crate) enum Request {
137
137
  ModuleCodeCache {
138
138
  module_id: i32,
139
139
  },
140
+ // Isolate-level memory introspection / hint (realm-independent). Read
141
+ // v8::HeapStatistics — used/total/limit, malloced + external bytes, and the
142
+ // live (number_of_native_contexts) and detached (number_of_detached_contexts)
143
+ // native-context counts. The two context counts are the lever for diagnosing
144
+ // a realm leak across Context#reset: a healthy warm isolate's live+detached
145
+ // count plateaus, a leak makes it climb.
146
+ HeapStatistics,
147
+ // Ask V8 to run a full GC now (Isolate::LowMemoryNotification) — lets the
148
+ // embedder reclaim dead realms between visits without waiting for the
149
+ // near-heap-limit callback, and tells reclaimable garbage apart from a real
150
+ // leak (memory that survives this is genuinely retained).
151
+ LowMemoryNotification,
140
152
  }
141
153
 
142
154
  // compile_module result: the module's id plus any produced bytecode cache and
@@ -147,6 +159,20 @@ pub(crate) struct Compiled {
147
159
  pub(crate) cache_rejected: bool,
148
160
  }
149
161
 
162
+ // A snapshot of v8::HeapStatistics, in bytes (counts for the context fields).
163
+ // Plain copyable numbers so it crosses out of the V8 op into a Ruby Hash without
164
+ // any handle. See Request::HeapStatistics for what the context counts tell you.
165
+ pub(crate) struct HeapStats {
166
+ pub(crate) used_heap_size: u64,
167
+ pub(crate) total_heap_size: u64,
168
+ pub(crate) heap_size_limit: u64,
169
+ pub(crate) malloced_memory: u64,
170
+ pub(crate) peak_malloced_memory: u64,
171
+ pub(crate) external_memory: u64,
172
+ pub(crate) number_of_native_contexts: u64,
173
+ pub(crate) number_of_detached_contexts: u64,
174
+ }
175
+
150
176
  // The terminal reply of an op: service_request returns it straight up to
151
177
  // Core::run (no channel). Host callbacks and module resolvers don't round-trip
152
178
  // through here — they run inline (with_gvl).
@@ -158,6 +184,8 @@ pub(crate) enum VmReply {
158
184
  // Script#/Module#create_code_cache: the serialized bytes, or None when V8
159
185
  // can't produce a cache (or the handle's realm is gone).
160
186
  CodeCache(Result<Option<Vec<u8>>, VmError>),
187
+ // Isolate#heap_statistics: a snapshot of v8::HeapStatistics.
188
+ Heap(HeapStats),
161
189
  }
162
190
 
163
191
  pub(crate) fn run_source(scope: &mut v8::PinScope<'_, '_>, source: &str, filename: &str) -> Result<JsVal, VmError> {
@@ -315,6 +343,12 @@ pub(crate) fn service_request(scope: &mut v8::PinScope<'_, '_, ()>, request: Req
315
343
  istate!(scope).watchdog_fired = false;
316
344
  scope.cancel_terminate_execution();
317
345
  }
346
+ // Free realms retired by this request (or a nested reset/dispose) now that the
347
+ // stack has fully unwound and NO context is entered — Context::SetMicrotaskQueue
348
+ // (the graveyard repoint inside) requires that.
349
+ if outermost {
350
+ flush_retiring(scope);
351
+ }
318
352
  reply
319
353
  }
320
354
 
@@ -342,7 +376,9 @@ fn request_realm(state: &IsolateState, request: &Request) -> Option<i32> {
342
376
  | Request::DisposeModule { .. }
343
377
  | Request::DisposeScript { .. }
344
378
  | Request::ScriptCodeCache { .. }
345
- | Request::ModuleCodeCache { .. } => None,
379
+ | Request::ModuleCodeCache { .. }
380
+ | Request::HeapStatistics
381
+ | Request::LowMemoryNotification => None,
346
382
  }
347
383
  }
348
384
 
@@ -417,9 +453,35 @@ fn dispatch_one(scope: &mut v8::PinScope<'_, '_, ()>, request: Request, outermos
417
453
  // It needs the module's context entered (unlike UnboundScript), so
418
454
  // a gone realm yields nil.
419
455
  Request::ModuleCodeCache { module_id } => op_module_code_cache(scope, module_id),
456
+ Request::HeapStatistics => op_heap_statistics(scope),
457
+ Request::LowMemoryNotification => op_low_memory_notification(scope),
420
458
  }
421
459
  }
422
460
 
461
+ // Snapshot v8::HeapStatistics. No handles, no JS — a PinScope<()> derefs to the
462
+ // Isolate, so the stats read straight off it.
463
+ fn op_heap_statistics(scope: &mut v8::PinScope<'_, '_, ()>) -> VmReply {
464
+ let s = scope.get_heap_statistics();
465
+ VmReply::Heap(HeapStats {
466
+ used_heap_size: s.used_heap_size() as u64,
467
+ total_heap_size: s.total_heap_size() as u64,
468
+ heap_size_limit: s.heap_size_limit() as u64,
469
+ malloced_memory: s.malloced_memory() as u64,
470
+ peak_malloced_memory: s.peak_malloced_memory() as u64,
471
+ external_memory: s.external_memory() as u64,
472
+ number_of_native_contexts: s.number_of_native_contexts() as u64,
473
+ number_of_detached_contexts: s.number_of_detached_contexts() as u64,
474
+ })
475
+ }
476
+
477
+ // Hint V8 to free as much as it can right now (a full GC). Runs entered, under
478
+ // the GVL-released op, on the owner thread — the same place the OOM recovery
479
+ // already calls it.
480
+ fn op_low_memory_notification(scope: &mut v8::PinScope<'_, '_, ()>) -> VmReply {
481
+ scope.low_memory_notification();
482
+ VmReply::Done(Ok(JsVal::Undefined))
483
+ }
484
+
423
485
  fn op_eval(scope: &mut v8::PinScope<'_, '_, ()>, context_id: i32, source: String, filename: String, timeout_ms: u64, outermost: bool) -> VmReply {
424
486
  let outcome = run_js_bracketed(scope, outermost, timeout_ms, "eval", |scope, outermost| {
425
487
  let realm = context_for(istate!(scope), context_id);
@@ -574,13 +636,22 @@ fn op_reset(scope: &mut v8::PinScope<'_, '_, ()>, context_id: i32) -> VmReply {
574
636
  .into(),
575
637
  )))
576
638
  } else {
577
- let fresh = new_realm(scope);
639
+ let (fresh, fresh_queue) = new_realm(scope);
578
640
  {
579
641
  let realms = &mut istate!(scope).realms;
580
- if context_id == 0 {
581
- realms.main_context = Some(fresh);
642
+ // Swap in the fresh realm and PARK the old context + queue in
643
+ // `retiring` (don't drop the queue here): freeing it now could strand a
644
+ // freed pointer in the old context if a cross-realm reference outlives
645
+ // this reset, and the graveyard repoint can't run while a context is
646
+ // entered (nested reset). flush_retiring frees them safely at the next
647
+ // outermost request boundary.
648
+ let (old_ctx, old_queue) = if context_id == 0 {
649
+ (realms.main_context.replace(fresh), realms.main_queue.replace(fresh_queue))
582
650
  } else {
583
- realms.contexts.insert(context_id, fresh);
651
+ (realms.contexts.insert(context_id, fresh), realms.queues.insert(context_id, fresh_queue))
652
+ };
653
+ if let (Some(c), Some(q)) = (old_ctx, old_queue) {
654
+ realms.retiring.push((c, q));
584
655
  }
585
656
  }
586
657
  // Drop modules bound to this context — their realm just changed.
@@ -596,8 +667,9 @@ fn op_create_context(scope: &mut v8::PinScope<'_, '_, ()>) -> VmReply {
596
667
  realms.next_context_id += 1;
597
668
  id
598
669
  };
599
- let fresh = new_realm(scope);
670
+ let (fresh, fresh_queue) = new_realm(scope);
600
671
  istate!(scope).realms.contexts.insert(id, fresh);
672
+ istate!(scope).realms.queues.insert(id, fresh_queue);
601
673
  VmReply::Done(Ok(JsVal::Int(id as i64)))
602
674
  }
603
675
 
@@ -614,9 +686,17 @@ fn op_dispose_context(scope: &mut v8::PinScope<'_, '_, ()>, context_id: i32) ->
614
686
  .into(),
615
687
  )))
616
688
  } else {
617
- // Dropping the Global lets V8 collect the context. id 0 is the
618
- // default context and never disposed independently.
619
- istate!(scope).realms.contexts.remove(&context_id);
689
+ // Park the context + queue in `retiring` rather than dropping them here:
690
+ // freeing the queue could strand a freed pointer in a cross-realm-reachable
691
+ // context, and the graveyard repoint needs no context entered.
692
+ // flush_retiring frees them at the next outermost request boundary, which
693
+ // is what finally lets V8 collect the context. id 0 is the default context
694
+ // and never disposed independently.
695
+ let old_ctx = istate!(scope).realms.contexts.remove(&context_id);
696
+ let old_queue = istate!(scope).realms.queues.remove(&context_id);
697
+ if let (Some(c), Some(q)) = (old_ctx, old_queue) {
698
+ istate!(scope).realms.retiring.push((c, q));
699
+ }
620
700
  // Reclaim the modules compiled in it (else they leak until
621
701
  // isolate teardown).
622
702
  drop_context_artifacts(istate!(scope), context_id);
@@ -1,9 +1,13 @@
1
1
  // V8 stack limit + conservative-GC-scan retargeting (in-thread: V8 runs on the
2
2
  // calling Ruby thread's stack — a native pthread stack, or a Ruby Fiber's
3
- // separate mmap'd stack). Self-contained: only raw pointers, std, libc, and the
4
- // exported V8 symbols below no IsolateState/JsVal/marshalling. The crate uses
5
- // discover_scan_start_field (once per isolate), set_v8_stack_limit (per op), and
6
- // STACK_DEBUG (set at init); everything else is private to this module.
3
+ // separate mmap'd stack), plus the current-isolate query the reentry path needs.
4
+ // Self-contained: only raw pointers, std, libc, and the exported V8 symbols below
5
+ // no IsolateState/JsVal/marshalling. It also hosts current_real_isolate() (the
6
+ // entered-isolate query) since that too is just one of the exported V8 symbols
7
+ // below, kept here so the FFI block stays in one place. The crate uses
8
+ // discover_scan_start_field (once per isolate), set_v8_stack_limit (per op),
9
+ // current_real_isolate (per reentrant op), and STACK_DEBUG (set at init);
10
+ // everything else is private to this module.
7
11
 
8
12
  use std::ffi::c_void;
9
13
  // Only native_stack_bounds (linux) needs it; gated so non-linux builds (macOS)
@@ -32,6 +36,19 @@ unsafe extern "C" {
32
36
  fn v8__internal__Heap__SetStackStart(heap: *mut c_void);
33
37
  #[link_name = "_ZN2v84base5Stack13GetStackStartEv"]
34
38
  fn v8__base__Stack__GetStackStart() -> usize;
39
+ // rusty_v8's C binding for v8::Isolate::GetCurrent() — the thread-local
40
+ // "currently entered" isolate. No #[link_name]: the exported symbol is
41
+ // literally this name (rusty_v8's binding glue), same as the crate's own
42
+ // private declaration. Lets a re-entrant op tell whether ITS isolate is the
43
+ // one currently entered, or a foreign isolate was entered on top of it (the
44
+ // cross-isolate reentry case — see Core::run).
45
+ fn v8__Isolate__GetCurrent() -> *mut c_void;
46
+ }
47
+
48
+ // The raw v8::Isolate* currently entered on this native thread (null if none).
49
+ // Compared by identity against an isolate's own raw pointer.
50
+ pub(crate) fn current_real_isolate() -> *mut c_void {
51
+ unsafe { v8__Isolate__GetCurrent() }
35
52
  }
36
53
 
37
54
  // Locate V8's conservative-GC-scan stack_start field
@@ -12,13 +12,22 @@
12
12
  // op handlers and isolate setup (still in lib.rs) call them;
13
13
  // report_watchdog_anomaly is private to this module.
14
14
 
15
- use std::sync::atomic::{AtomicBool, Ordering};
15
+ use std::ffi::c_void;
16
+ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
16
17
  use std::sync::{Arc, Condvar, Mutex};
17
18
  use std::time::{Duration, Instant};
18
19
 
19
20
  use crate::istate;
20
21
  use crate::{IsolateState, JsVal, VmError};
21
22
 
23
+ // Top N JS frames captured at a timeout — enough to name the culprit without
24
+ // walking a pathological deep stack.
25
+ const MAX_TIMEOUT_FRAMES: usize = 32;
26
+ // Per-frame string cap. Function names and (especially) script URLs are
27
+ // attacker-controlled JS and can be arbitrarily long (a multi-KB data: URL);
28
+ // cap each so a runaway can't bloat the error message / backtrace.
29
+ const MAX_FRAME_NAME: usize = 256;
30
+
22
31
  // The watchdog runs on ONE persistent thread per isolate rather than a fresh
23
32
  // std::thread per request: spawning + joining a thread on every op cost ~16µs
24
33
  // (5.5x) when a timeout was set, dwarfing the actual work. The thread sleeps on
@@ -27,6 +36,11 @@ use crate::{IsolateState, JsVal, VmError};
27
36
  pub(crate) struct WatchdogShared {
28
37
  inner: Mutex<WatchdogInner>,
29
38
  cv: Condvar,
39
+ // The owning isolate's raw `*mut v8::Isolate` as a usize (0 until wired up by
40
+ // set_iso_ptr, right after the isolate is boxed). The loop needs it to address
41
+ // the RequestInterrupt callback's `data` at fire time; kept as an atomic
42
+ // (outside the Mutex) so the loop reads it without serialising on arm/disarm.
43
+ iso_ptr: AtomicUsize,
30
44
  }
31
45
 
32
46
  impl WatchdogShared {
@@ -40,9 +54,17 @@ impl WatchdogShared {
40
54
  shutdown: false,
41
55
  }),
42
56
  cv: Condvar::new(),
57
+ iso_ptr: AtomicUsize::new(0),
43
58
  })
44
59
  }
45
60
 
61
+ // Wire up the isolate pointer once it is stable (after the OwnedIsolate is
62
+ // boxed). Called before any op can arm a deadline, so the loop always sees it
63
+ // set by the time it could fire.
64
+ pub(crate) fn set_iso_ptr(&self, iso_ptr: *mut std::ffi::c_void) {
65
+ self.iso_ptr.store(iso_ptr as usize, Ordering::Relaxed);
66
+ }
67
+
46
68
  // Signal the loop to stop and wake it. Called once at isolate teardown,
47
69
  // before the isolate is touched, so the loop can't fire a terminate into an
48
70
  // isolate we're mid-disposing.
@@ -94,8 +116,21 @@ pub(crate) fn watchdog_loop(shared: Arc<WatchdogShared>, handle: v8::IsolateHand
94
116
  Some(frame) => {
95
117
  let now = Instant::now();
96
118
  if now >= frame.deadline {
97
- handle.terminate_execution();
98
119
  inner.fired_generation = Some(frame.generation);
120
+ // Don't terminate directly: request an interrupt so the stack
121
+ // is captured on the isolate thread (with JS live) before the
122
+ // terminate — see timeout_interrupt. fired_generation is set
123
+ // FIRST and we still hold `inner`, so the callback (which locks
124
+ // `inner` to read it) can't run until we release, and will see
125
+ // it set. Fall back to a direct terminate if the isolate ptr
126
+ // isn't wired yet (can't happen once an op has armed) — never
127
+ // leave a runaway un-terminated.
128
+ let iso = shared.iso_ptr.load(Ordering::Relaxed);
129
+ if iso != 0 {
130
+ handle.request_interrupt(timeout_interrupt, iso as *mut c_void);
131
+ } else {
132
+ handle.terminate_execution();
133
+ }
99
134
  // Drop the fired frame so the loop moves on to the next
100
135
  // deadline instead of re-firing this one every wakeup.
101
136
  inner.frames.retain(|f| f.generation != frame.generation);
@@ -110,6 +145,92 @@ pub(crate) fn watchdog_loop(shared: Arc<WatchdogShared>, handle: v8::IsolateHand
110
145
 
111
146
  // (The watchdog Arc now lives in IsolateState; arm/disarm reach it via istate!.)
112
147
 
148
+ // The RequestInterrupt callback the watchdog fires when a deadline passes. It
149
+ // runs on the ISOLATE thread with the runaway JS still on the stack, so it can
150
+ // snapshot the stack BEFORE TerminateExecution unwinds it. `data` is the
151
+ // `*mut v8::Isolate` (the same pointer near_heap_limit_cb uses); the
152
+ // UnsafeRawIsolatePtr arg is ignored.
153
+ //
154
+ // GUARDED by fired_generation: a RequestInterrupt callback can't be cancelled, so
155
+ // one still pending after its op already disarmed must NOT capture+terminate the
156
+ // NEXT op. It acts only while some fired deadline is still awaiting its terminate
157
+ // (fired_generation set) — which is exactly when a terminate is warranted, for
158
+ // whatever op is currently on the stack.
159
+ unsafe extern "C" fn timeout_interrupt(_isolate: v8::UnsafeRawIsolatePtr, data: *mut c_void) {
160
+ let isolate = unsafe { &mut *(data as *mut v8::Isolate) };
161
+ let pending = isolate
162
+ .get_slot::<IsolateState>()
163
+ .map(|s| s.watchdog.inner.lock().unwrap().fired_generation.is_some())
164
+ .unwrap_or(false);
165
+ if !pending {
166
+ return;
167
+ }
168
+ // Enter SOME realm (the main one — always present) just to get a
169
+ // context-typed scope, which StackTrace::current_stack_trace requires.
170
+ // CurrentStackTrace reads the ISOLATE's running stack regardless of which
171
+ // realm is entered, so this captures the real runaway frames even if they're
172
+ // in another realm. Capture, stash for the unwinding op's error, THEN
173
+ // terminate (so the stack is still live when we snapshot it).
174
+ v8::scope!(let scope, isolate);
175
+ let Some(main) = istate!(scope).realms.main_context.clone() else {
176
+ scope.terminate_execution();
177
+ return;
178
+ };
179
+ let context = v8::Local::new(scope, &main);
180
+ let scope = &mut v8::ContextScope::new(scope, context);
181
+ let frames = capture_js_backtrace(scope, MAX_TIMEOUT_FRAMES);
182
+ istate!(scope).timeout_backtrace = Some(frames);
183
+ scope.terminate_execution();
184
+ }
185
+
186
+ // Snapshot up to |max_frames| of the current JS stack as "func (script:line:col)"
187
+ // strings, top frame first. Empty when there's no JS stack (e.g. already
188
+ // terminating) — best-effort, never fatal.
189
+ fn capture_js_backtrace(scope: &mut v8::PinScope<'_, '_>, max_frames: usize) -> Vec<String> {
190
+ let Some(trace) = v8::StackTrace::current_stack_trace(scope, max_frames) else {
191
+ return Vec::new();
192
+ };
193
+ let count = trace.get_frame_count();
194
+ let mut out = Vec::with_capacity(count);
195
+ for i in 0..count {
196
+ let Some(frame) = trace.get_frame(scope, i) else {
197
+ continue;
198
+ };
199
+ let func = clamp_name(
200
+ frame
201
+ .get_function_name(scope)
202
+ .map(|s| s.to_rust_string_lossy(scope))
203
+ .filter(|s| !s.is_empty())
204
+ .unwrap_or_else(|| "<anonymous>".to_string()),
205
+ );
206
+ let script = clamp_name(
207
+ frame
208
+ .get_script_name_or_source_url(scope)
209
+ .map(|s| s.to_rust_string_lossy(scope))
210
+ .filter(|s| !s.is_empty())
211
+ .unwrap_or_else(|| "<unknown>".to_string()),
212
+ );
213
+ out.push(format!(
214
+ "{func} ({script}:{}:{})",
215
+ frame.get_line_number(),
216
+ frame.get_column()
217
+ ));
218
+ }
219
+ out
220
+ }
221
+
222
+ // Cap a frame name/script string at MAX_FRAME_NAME chars (on a char boundary),
223
+ // appending an ellipsis when truncated, so an attacker-controlled long name can't
224
+ // bloat the error.
225
+ fn clamp_name(mut s: String) -> String {
226
+ if s.len() > MAX_FRAME_NAME {
227
+ let end = (0..=MAX_FRAME_NAME).rev().find(|&i| s.is_char_boundary(i)).unwrap_or(0);
228
+ s.truncate(end);
229
+ s.push('…');
230
+ }
231
+ s
232
+ }
233
+
113
234
  // Arm the watchdog for this request: push a frame with its own deadline and
114
235
  // wake the loop. Returns the generation token to hand to `disarm_watchdog`
115
236
  // (None when timeout_ms is 0 — no watchdog for this request).
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RustyRacer
4
- VERSION = "0.1.8"
4
+ VERSION = "0.1.10"
5
5
  end
data/lib/rusty_racer.rb CHANGED
@@ -26,7 +26,14 @@ module RustyRacer
26
26
  class EvalError < Error; end
27
27
  class ParseError < EvalError; end
28
28
  class RuntimeError < EvalError; end
29
- class ScriptTerminatedError < EvalError; end
29
+ class ScriptTerminatedError < EvalError
30
+ # The JS stack at the moment the call timed out (watchdog), as
31
+ # ["func (script:line:col)", ...], top frame first — captured on the isolate
32
+ # thread just before TerminateExecution. [] when no stack was captured (e.g. a
33
+ # bare Isolate#terminate rather than a timeout). The same frames are also set
34
+ # as the exception's #backtrace when present.
35
+ def js_backtrace = @js_backtrace || []
36
+ end
30
37
  # Raised when JS allocation exceeds the isolate's heap ceiling (the configured
31
38
  # memory_limit, or V8's default ceiling when none was set). Catchable like any
32
39
  # eval error — a runaway script fails its own eval instead of aborting the
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rusty_racer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.8
4
+ version: 0.1.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keita Urashima
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-15 00:00:00.000000000 Z
11
+ date: 2026-06-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys
@@ -54,7 +54,7 @@ metadata:
54
54
  source_code_uri: https://github.com/ursm/rusty_racer
55
55
  bug_tracker_uri: https://github.com/ursm/rusty_racer/issues
56
56
  rubygems_mfa_required: 'true'
57
- post_install_message:
57
+ post_install_message:
58
58
  rdoc_options: []
59
59
  require_paths:
60
60
  - lib
@@ -70,7 +70,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
70
70
  version: '0'
71
71
  requirements: []
72
72
  rubygems_version: 3.5.22
73
- signing_key:
73
+ signing_key:
74
74
  specification_version: 4
75
75
  summary: Embed V8 in Ruby via rusty_v8 + Magnus (rb-sys)
76
76
  test_files: []