rusty_racer 0.1.8 → 0.1.9

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: 8bae20d8fe811c8b1e9b171fe272d6481934d7fa2b1fbe978846605dd51e76d4
4
+ data.tar.gz: fa35ae7940c34c6c6ff54af93178b4f94200033c2a3fa4ca37f8e878bed829c3
5
5
  SHA512:
6
- metadata.gz: ab88c79367db640e4144c0844a2ffcc0168f353de01ad7c225902c756d350f7a48188d9a3c0890ec6da2a61dc83e6a2484e519ccb082bb171d4fa795f89a4e7f
7
- data.tar.gz: da6ca243ceb13b6d353ce15c6b59e725d617c0712e146a13017f2bbb2d7715dad0705849c4ce36b1b941b0dcb1a2a1750390d557b80c3e7a196809a66a5d4efa
6
+ metadata.gz: 8b98d2be3c82bf39d69dc5d3389927a1fd5872f8ace99028da3f9ffd5116464b17d8a2c05f932039f2da0a769599d22b5f870a01ac35b8ade7b8d263ae5f3c80
7
+ data.tar.gz: 3391fb308a736f925d56d34cfb649a262a2d9c58f8227bd28706f4da8f366aaebad779510203a57f2e7eb1507888100eb578400318addafbbbb9c4a004d69ca9
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.9"
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
@@ -695,12 +739,44 @@ fn transfer_registry() -> &'static Mutex<HashMap<u64, SendBackingStore>> {
695
739
  // transfers, which a process will never reach.
696
740
  static NEXT_TRANSFER_TOKEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
697
741
 
742
+ // Drain EVERY realm's microtask queue once. Each realm has its own queue (see
743
+ // V8State::queues), so a single scope.perform_microtask_checkpoint() — which only
744
+ // touches the isolate's default queue — would run NOTHING (every realm's promises
745
+ // land in the realm's own queue). This restores the old isolate-wide "drain
746
+ // everything" semantics: a microtask queued in ANY realm runs, regardless of
747
+ // which realm the checkpoint was requested from. V8 drains each queue until empty
748
+ // and enters each microtask's own realm, so same-realm cascades fully resolve in
749
+ // one pass; a cross-realm cascade (a microtask in realm A enqueueing into realm B
750
+ // that was already drained this pass) resolves on the next checkpoint — callers
751
+ // that need full quiescence loop (csim does).
752
+ fn drain_all_realms(scope: &mut v8::PinScope<'_, '_>) {
753
+ // Snapshot the queue pointers, then drain without holding the IsolateState
754
+ // borrow (a microtask re-enters host fns that borrow it). The queue OBJECTS
755
+ // are address-stable (owned via UniqueRef, heap-allocated by V8), so the
756
+ // snapshot survives a queues-HashMap realloc (e.g. a microtask creating a
757
+ // realm); and reset/dispose — the only things that free a queue — are refused
758
+ // while draining (checkpoint_draining set the flag), so no pointer dangles.
759
+ let queues: Vec<*const v8::MicrotaskQueue> = {
760
+ let st = istate!(scope);
761
+ st.realms
762
+ .main_queue
763
+ .iter()
764
+ .chain(st.realms.queues.values())
765
+ .map(|q| &**q as *const v8::MicrotaskQueue)
766
+ .collect()
767
+ };
768
+ for q in queues {
769
+ unsafe { (*q).perform_checkpoint(&mut ***scope) };
770
+ }
771
+ }
772
+
698
773
  // 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.
774
+ // so a nested Reset/DisposeContext issued by a drained microtask is refused
775
+ // which also keeps drain_all_realms's queue snapshot from dangling.
700
776
  fn checkpoint_draining(scope: &mut v8::PinScope<'_, '_>) {
701
777
  let prev = istate!(scope).draining;
702
778
  istate!(scope).draining = true;
703
- scope.perform_microtask_checkpoint();
779
+ drain_all_realms(scope);
704
780
  istate!(scope).draining = prev;
705
781
  }
706
782
 
@@ -1297,7 +1373,9 @@ fn drain_microtasks(
1297
1373
  _args: v8::FunctionCallbackArguments<'_>,
1298
1374
  _rv: v8::ReturnValue<'_, v8::Value>,
1299
1375
  ) {
1300
- scope.perform_microtask_checkpoint();
1376
+ // Drain every realm's queue (isolate-wide semantics), under the draining
1377
+ // guard so a microtask can't reset/dispose a realm mid-drain.
1378
+ checkpoint_draining(scope);
1301
1379
  }
1302
1380
 
1303
1381
  // NS.contextGlobal(id) -> the globalThis of context |id|. Cross-context
@@ -1572,10 +1650,20 @@ unsafe extern "C" fn promise_reject_cb(message: v8::PromiseRejectMessage) {
1572
1650
 
1573
1651
  // Build a fresh v8::Context and install the host namespace (from STATE) into
1574
1652
  // 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> {
1653
+ // reset and create_context so realms can't drift apart. Returns the context
1654
+ // Global AND its dedicated microtask queue; the caller owns the queue in
1655
+ // V8State alongside the context (see V8State::queues for why per-realm).
1656
+ fn new_realm(
1657
+ scope: &mut v8::PinScope<'_, '_, ()>,
1658
+ ) -> (v8::Global<v8::Context>, v8::UniqueRef<v8::MicrotaskQueue>) {
1659
+ // Explicit policy like the isolate's: rusty drives every drain by hand
1660
+ // (auto_drain / NS.drainMicrotasks), so V8 must never auto-run this queue.
1661
+ let mut queue = v8::MicrotaskQueue::new(&mut **scope, v8::MicrotasksPolicy::Explicit);
1577
1662
  let fresh = {
1578
- let context = v8::Context::new(scope, Default::default());
1663
+ let context = v8::Context::new(scope, v8::ContextOptions {
1664
+ microtask_queue: Some(&mut *queue as *mut _),
1665
+ ..Default::default()
1666
+ });
1579
1667
  v8::Global::new(scope, context)
1580
1668
  };
1581
1669
  // DESIGN DECISION: every realm of an isolate shares ONE security token, so
@@ -1615,7 +1703,44 @@ fn new_realm(scope: &mut v8::PinScope<'_, '_, ()>) -> v8::Global<v8::Context> {
1615
1703
  if let Some(name) = host_namespace {
1616
1704
  install_host_namespace(scope, &fresh, &name);
1617
1705
  }
1618
- fresh
1706
+ (fresh, queue)
1707
+ }
1708
+
1709
+ // Tear down every realm parked in `retiring` (by reset/dispose): repoint each old
1710
+ // context at the graveyard queue, then free the old queues (discarding their
1711
+ // pending microtasks — the leak fix). MUST run with NO context entered, because
1712
+ // Context::SetMicrotaskQueue requires it — so the only caller is the OUTERMOST
1713
+ // service_request, after its op (and all nested ones) have unwound their
1714
+ // ContextScopes. A no-op when nothing is retired.
1715
+ fn flush_retiring(scope: &mut v8::PinScope<'_, '_, ()>) {
1716
+ if istate!(scope).realms.retiring.is_empty() {
1717
+ return;
1718
+ }
1719
+ let graveyard: *const v8::MicrotaskQueue = match istate!(scope).realms.graveyard_queue.as_ref() {
1720
+ Some(q) => &**q as *const _,
1721
+ // The graveyard is created at boot and never cleared, so with entries
1722
+ // waiting this is unreachable; assert so a future refactor that defers its
1723
+ // creation fails loudly instead of silently stranding `retiring` (which
1724
+ // would quietly resurrect the whole-realm leak). Bail without taking the
1725
+ // list, so nothing is lost if it somehow happens in release.
1726
+ None => {
1727
+ debug_assert!(false, "graveyard queue missing while realms are retiring");
1728
+ return;
1729
+ }
1730
+ };
1731
+ let retiring = std::mem::take(&mut istate!(scope).realms.retiring);
1732
+ for (ctx, _queue) in &retiring {
1733
+ let local = v8::Local::new(scope, ctx);
1734
+ // SAFETY: the graveyard queue is owned by V8State for the isolate's whole
1735
+ // life, so the pointer is valid for this set_microtask_queue call. Repoint
1736
+ // BEFORE the queues drop below, so a cross-realm reference that keeps `ctx`
1737
+ // alive can't be left holding a freed queue.
1738
+ local.set_microtask_queue(unsafe { &*graveyard });
1739
+ }
1740
+ // Dropping `retiring` here frees each old queue (and its now-discarded pending
1741
+ // microtasks) and each old context Global. Every context was just repointed to
1742
+ // the graveyard, so no live context holds a freed queue pointer.
1743
+ drop(retiring);
1619
1744
  }
1620
1745
 
1621
1746
  // Inject globalThis.<name> = { drainMicrotasks } into a context. Re-run on
@@ -1828,8 +1953,12 @@ impl Isolate {
1828
1953
  // namespace from the slot (seeded above).
1829
1954
  {
1830
1955
  v8::scope!(let scope, &mut isolate);
1831
- let main_context = new_realm(scope);
1956
+ let (main_context, main_queue) = new_realm(scope);
1832
1957
  istate!(scope).realms.main_context = Some(main_context);
1958
+ istate!(scope).realms.main_queue = Some(main_queue);
1959
+ // The shared graveyard for retired realms' contexts (see V8State).
1960
+ let graveyard = v8::MicrotaskQueue::new(&mut **scope, v8::MicrotasksPolicy::Explicit);
1961
+ istate!(scope).realms.graveyard_queue = Some(graveyard);
1833
1962
  }
1834
1963
  // Box the OwnedIsolate so it has a STABLE address, then capture a raw ptr
1835
1964
  // INTO the box (a `&mut Isolate` is `&mut NonNull<RealIsolate>`, pointing
@@ -1998,22 +2127,57 @@ impl Core {
1998
2127
  unsafe { (*iso).exit() };
1999
2128
  reply
2000
2129
  } 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.
2130
+ // Re-entrant (a host callback or module resolver, having
2131
+ // reacquired the GVL to run a proc that issued this op, is on the
2132
+ // V8 stack). USUALLY the JS on the stack belongs to THIS isolate
2133
+ // (same-isolate reentry) which is therefore already entered on
2134
+ // THIS native thread — so we bootstrap onto the ambient
2135
+ // HandleScope without re-entering.
2136
+ //
2137
+ // But an embedder driving MANY isolates (e.g. capybara-simulated's
2138
+ // windows) can interleave them: a host callback on isolate A runs
2139
+ // Ruby that evals isolate B, whose own callback re-enters A. Now A
2140
+ // is on the V8 stack (depth > 0) yet B — not A — is the isolate
2141
+ // CURRENTLY entered on this thread, so bootstrapping a scope on A
2142
+ // and opening a ContextScope would trip V8's "scope and Context do
2143
+ // not belong to the same Isolate" panic (it checks the scope's
2144
+ // isolate against Isolate::GetCurrent()). Detect that case and
2145
+ // properly enter A on top of B, restoring B on exit. Entering an
2146
+ // already-current isolate is also harmless (V8's entered-isolate
2147
+ // stack nests), so this is correct for same-isolate reentry too —
2148
+ // we only pay the enter/exit when a FOREIGN isolate is on top.
2149
+ //
2005
2150
  // The stack limit + scan-start set at depth 0 are NOT re-pointed
2006
2151
  // 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
2152
+ // depth-0 values still bound it correctly (whichever isolate is
2153
+ // current — each tracks its own limit). The one exception is a
2008
2154
  // host callback that SWITCHES stacks — e.g. resumes a Ruby Fiber
2009
2155
  // that itself evals — where the depth-0 (native) settings are
2010
2156
  // stale for the fiber; that nested-fiber-under-callback case is an
2011
2157
  // 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)
2158
+ let foreign = unsafe { *(iso as *const *mut c_void) } != current_real_isolate();
2159
+ if foreign {
2160
+ unsafe { (*iso).enter() };
2161
+ }
2162
+ let reply = std::panic::catch_unwind(AssertUnwindSafe(|| {
2163
+ if foreign {
2164
+ // A had no ambient scope under B's entry — open a fresh
2165
+ // HandleScope on A, exactly as the depth-0 path does.
2166
+ v8::scope!(let scope, unsafe { &mut *iso });
2167
+ service_request(scope, request, false)
2168
+ } else {
2169
+ v8::callback_scope!(unsafe scope, unsafe { &mut *iso });
2170
+ service_request(scope, request, false)
2171
+ }
2015
2172
  }))
2016
- .ok()
2173
+ .ok();
2174
+ // Pop A back off (restoring B as current). Safe even after a
2175
+ // panic unwind: the scope's Drop ran but left A entered, and
2176
+ // exit() asserts A == GetCurrent(), which holds here.
2177
+ if foreign {
2178
+ unsafe { (*iso).exit() };
2179
+ }
2180
+ reply
2017
2181
  }
2018
2182
  });
2019
2183
  self.depth.fetch_sub(1, Ordering::SeqCst);
@@ -2114,6 +2278,35 @@ impl Core {
2114
2278
  Self::reply_value(ruby, reply)
2115
2279
  }
2116
2280
 
2281
+ // Isolate#heap_statistics -> a Symbol-keyed Hash of v8::HeapStatistics
2282
+ // (bytes; the two *_contexts entries are counts). See Request::HeapStatistics.
2283
+ fn heap_statistics(&self, ruby: &Ruby) -> Result<Value, Error> {
2284
+ let reply = self.run(ruby, Request::HeapStatistics)?;
2285
+ let VmReply::Heap(s) = reply else {
2286
+ return Err(Error::new(
2287
+ ruby.exception_runtime_error(),
2288
+ "internal: unexpected heap reply",
2289
+ ));
2290
+ };
2291
+ let h = ruby.hash_new();
2292
+ h.aset(ruby.to_symbol("used_heap_size"), s.used_heap_size)?;
2293
+ h.aset(ruby.to_symbol("total_heap_size"), s.total_heap_size)?;
2294
+ h.aset(ruby.to_symbol("heap_size_limit"), s.heap_size_limit)?;
2295
+ h.aset(ruby.to_symbol("malloced_memory"), s.malloced_memory)?;
2296
+ h.aset(ruby.to_symbol("peak_malloced_memory"), s.peak_malloced_memory)?;
2297
+ h.aset(ruby.to_symbol("external_memory"), s.external_memory)?;
2298
+ h.aset(ruby.to_symbol("number_of_native_contexts"), s.number_of_native_contexts)?;
2299
+ h.aset(ruby.to_symbol("number_of_detached_contexts"), s.number_of_detached_contexts)?;
2300
+ Ok(h.as_value())
2301
+ }
2302
+
2303
+ // Isolate#low_memory_notification: ask V8 to run a full GC now.
2304
+ fn low_memory_notification(&self, ruby: &Ruby) -> Result<(), Error> {
2305
+ let reply = self.run(ruby, Request::LowMemoryNotification)?;
2306
+ Self::reply_value(ruby, reply)?;
2307
+ Ok(())
2308
+ }
2309
+
2117
2310
  fn eval_t(
2118
2311
  &self,
2119
2312
  ruby: &Ruby,
@@ -2524,6 +2717,17 @@ impl Isolate {
2524
2717
  fn perform_microtask_checkpoint(ruby: &Ruby, rb_self: &Self) -> Result<Value, Error> {
2525
2718
  rb_self.core.drain_microtasks(ruby)
2526
2719
  }
2720
+ // Isolate#heap_statistics -> Hash. A diagnostic window into the V8 heap;
2721
+ // watch number_of_native_contexts / number_of_detached_contexts to spot a
2722
+ // realm leak across Context#reset.
2723
+ fn heap_statistics(ruby: &Ruby, rb_self: &Self) -> Result<Value, Error> {
2724
+ rb_self.core.heap_statistics(ruby)
2725
+ }
2726
+ // Isolate#low_memory_notification: full GC now. Reclaims dead realms between
2727
+ // visits, and distinguishes reclaimable garbage from a genuine leak.
2728
+ fn low_memory_notification(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
2729
+ rb_self.core.low_memory_notification(ruby)
2730
+ }
2527
2731
  // dynamic_import_resolver = ->(specifier, referrer_url) { module } for import().
2528
2732
  fn set_dynamic_import_resolver(rb_self: &Self, proc: Proc) {
2529
2733
  rb_self.core.set_dynamic_import_resolver(proc);
@@ -2971,6 +3175,11 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
2971
3175
  "_set_dynamic_import_resolver",
2972
3176
  method!(Isolate::set_dynamic_import_resolver, 1),
2973
3177
  )?;
3178
+ isolate.define_method("heap_statistics", method!(Isolate::heap_statistics, 0))?;
3179
+ isolate.define_method(
3180
+ "low_memory_notification",
3181
+ method!(Isolate::low_memory_notification, 0),
3182
+ )?;
2974
3183
  isolate.define_method("dispose", method!(Isolate::dispose, 0))?;
2975
3184
  isolate.define_method("disposed?", method!(Isolate::disposed, 0))?;
2976
3185
 
@@ -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
@@ -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.9"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rusty_racer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.8
4
+ version: 0.1.9
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-18 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: []