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 +4 -4
- data/README.md +5 -5
- data/ext/rusty_racer/Cargo.toml +1 -1
- data/ext/rusty_racer/src/lib.rs +227 -18
- data/ext/rusty_racer/src/ops.rs +89 -9
- data/ext/rusty_racer/src/stack.rs +21 -4
- data/lib/rusty_racer/version.rb +1 -1
- metadata +5 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8bae20d8fe811c8b1e9b171fe272d6481934d7fa2b1fbe978846605dd51e76d4
|
|
4
|
+
data.tar.gz: fa35ae7940c34c6c6ff54af93178b4f94200033c2a3fa4ca37f8e878bed829c3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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** (
|
|
42
|
-
|
|
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.
|
|
45
|
-
|
|
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
|
data/ext/rusty_racer/Cargo.toml
CHANGED
data/ext/rusty_racer/src/lib.rs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
2002
|
-
// proc that issued this op, is on the
|
|
2003
|
-
//
|
|
2004
|
-
//
|
|
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
|
|
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
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
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
|
|
data/ext/rusty_racer/src/ops.rs
CHANGED
|
@@ -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 { .. }
|
|
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
|
-
|
|
581
|
-
|
|
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
|
-
//
|
|
618
|
-
//
|
|
619
|
-
|
|
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)
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
data/lib/rusty_racer/version.rb
CHANGED
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.
|
|
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-
|
|
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: []
|