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 +4 -4
- data/README.md +5 -5
- data/ext/rusty_racer/Cargo.toml +1 -1
- data/ext/rusty_racer/src/lib.rs +311 -34
- data/ext/rusty_racer/src/ops.rs +89 -9
- data/ext/rusty_racer/src/stack.rs +21 -4
- data/ext/rusty_racer/src/watchdog.rs +123 -2
- data/lib/rusty_racer/version.rb +1 -1
- data/lib/rusty_racer.rb +8 -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: 7df2aeec586e04d155d2635f6df9b51e219b31108fad5829e7a5ed5eb8a784a3
|
|
4
|
+
data.tar.gz: '099e1e39f64e041d082fb86269ae4265406e4f05279e3d24909a9e6f4fa89de0'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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** (
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
2002
|
-
// proc that issued this op, is on the
|
|
2003
|
-
//
|
|
2004
|
-
//
|
|
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
|
|
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
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
@@ -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::
|
|
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).
|
data/lib/rusty_racer/version.rb
CHANGED
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
|
|
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.
|
|
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-
|
|
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: []
|