rusty_racer 0.1.1 → 0.1.3

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.
@@ -41,14 +41,23 @@ use std::collections::HashMap;
41
41
  use std::ffi::c_void;
42
42
  use std::ptr::null_mut;
43
43
  use std::sync::atomic::{AtomicBool, Ordering};
44
- use std::sync::{Arc, Condvar, Mutex, Once, Weak};
45
- use std::time::{Duration, Instant};
44
+ use std::sync::{Arc, Mutex, Once, Weak};
46
45
 
47
46
  use magnus::block::Proc;
48
47
  use magnus::value::{BoxValue, ReprValue};
49
48
  use magnus::{
50
- function, method, prelude::*, Error, Exception, ExceptionClass, IntoValue, RArray, RHash,
51
- RString, Ruby, TryConvert, Value,
49
+ function, method, prelude::*, Error, Exception, ExceptionClass, RHash, Ruby, TryConvert, Value,
50
+ };
51
+
52
+ mod marshal;
53
+ use marshal::{js_to_jsval, jsval_to_js, jsval_to_ruby, ruby_to_jsval, JsVal};
54
+ mod ops;
55
+ use ops::{run_source, service_request, Compiled, Request, VmReply};
56
+ mod stack;
57
+ use stack::{discover_scan_start_field, set_v8_stack_limit, STACK_DEBUG};
58
+ mod watchdog;
59
+ use watchdog::{
60
+ arm_watchdog, disarm_watchdog, run_js_bracketed, watchdog_loop, WatchdogShared, WATCHDOG_DEBUG,
52
61
  };
53
62
 
54
63
  // A Ruby Proc rooted for as long as the Core holds it. BoxValue registers a
@@ -110,6 +119,7 @@ macro_rules! istate {
110
119
  .expect("IsolateState missing from isolate slot")
111
120
  };
112
121
  }
122
+ pub(crate) use istate;
113
123
 
114
124
  // One attach()'d host fn: the realm it was attached into — so resetting or
115
125
  // disposing that realm can release the GC root — and the rooted proc itself
@@ -166,56 +176,6 @@ fn err_class(ruby: &Ruby, name: &str) -> ExceptionClass {
166
176
  .unwrap_or_else(|_| ruby.exception_runtime_error())
167
177
  }
168
178
 
169
- // ---------------------------------------------------------------------------
170
- // Values crossing threads: plain Rust data. No Ruby allocation off the Ruby
171
- // thread, no V8 handles off the V8 thread, no wire format. Replaces serde.c.
172
- // ---------------------------------------------------------------------------
173
- #[derive(Debug, Clone)]
174
- enum JsVal {
175
- Undefined,
176
- Null,
177
- Bool(bool),
178
- Int(i64),
179
- Num(f64),
180
- Str(String),
181
- // Binary bytes: a JS Uint8Array / ArrayBuffer (view) <-> a Ruby ASCII-8BIT
182
- // (binary-tagged) String. The encoding tag IS the type declaration, so the
183
- // round-trip is symmetric and faithful (Uint8Array -> binary String ->
184
- // Uint8Array), like BigInt/Date/Map/Set — no lossy text coercion. |id| (when
185
- // Some) registers it in the Ref table so a binary blob aliased in a graph
186
- // keeps ONE identity instead of being duplicated; None = not identity-tracked
187
- // (e.g. a to_str result).
188
- Bytes { id: Option<u32>, bytes: Vec<u8> },
189
- // Arbitrary-precision integer (JS BigInt <-> Ruby Integer). Carried as V8's
190
- // word representation: sign + little-endian u64 limbs. Both ends speak this
191
- // natively (V8 BigInt words; Ruby Integer via a hex string), so no value is
192
- // truncated — unlike routing a big int through f64.
193
- BigInt { negative: bool, words: Vec<u64> },
194
- // JS Date <-> Ruby Time, carried as milliseconds since the Unix epoch
195
- // (v8::Date::value_of's unit). mini_racer marshals Date to Time.
196
- Date(f64),
197
- // Containers carry a serialization id so shared/cyclic graphs survive the
198
- // round-trip: the first time an object is seen it is emitted with its id,
199
- // and any later occurrence (a sibling sharing it, or a cycle back to an
200
- // ancestor) is emitted as Ref(id) instead of being re-expanded.
201
- Array { id: u32, items: Vec<JsVal> },
202
- // JS object / Ruby Hash with string keys. Insertion order preserved.
203
- Obj { id: u32, entries: Vec<(String, JsVal)> },
204
- // JS Map <-> Ruby Hash. Keys are arbitrary values (not just strings), so
205
- // this is distinct from Obj. Insertion order preserved.
206
- Map { id: u32, pairs: Vec<(JsVal, JsVal)> },
207
- // JS Set <-> Ruby Set (stdlib).
208
- Set { id: u32, items: Vec<JsVal> },
209
- // Back-reference to an already-emitted container (preserves identity; makes
210
- // cycles representable instead of truncating at a depth cap).
211
- Ref(u32),
212
- }
213
-
214
- // Cycles and sharing are handled by the Ref table (see JsVal::Ref), so this is
215
- // purely a native-stack backstop against a pathologically deep (but acyclic)
216
- // graph — set well above any realistic nesting.
217
- const MAX_MARSHAL_DEPTH: u32 = 256;
218
-
219
179
  #[derive(Debug)]
220
180
  enum VmError {
221
181
  Parse(String), // compile-time failure -> RustyRacer::ParseError
@@ -226,149 +186,52 @@ enum VmError {
226
186
  message: String,
227
187
  backtrace: Vec<String>,
228
188
  },
229
- Terminated, // watchdog/stop -> RustyRacer::ScriptTerminatedError
230
- }
231
-
232
- // One VM operation, built by a magnus method and run inline by Core::run ->
233
- // service_request -> dispatch_one. |context_id| selects which realm the op runs
234
- // in: 0 = the main realm (Context's own globalThis, swappable by reset_realm),
235
- // N >= 1 = an extra realm made by create_context.
236
- enum Request {
237
- Eval {
238
- context_id: i32,
239
- source: String,
240
- filename: String,
241
- timeout_ms: u64,
242
- },
243
- // Resolve a dotted function path on globalThis and invoke it with marshalled
244
- // args (v8::Function::call), preserving the holder as `this`. Distinct from
245
- // Eval so args keep full type/identity fidelity instead of a JSON literal.
246
- Call {
247
- context_id: i32,
248
- name: String,
249
- args: Vec<JsVal>,
250
- // void = don't marshal the return (fire-and-forget): the called fn may
251
- // return a huge/cyclic JS object the caller never reads.
252
- void: bool,
253
- timeout_ms: u64,
254
- },
255
- // Drain the isolate's microtask queue once (no auto event loop).
256
- DrainMicrotasks {
257
- timeout_ms: u64,
258
- },
259
- Attach {
260
- context_id: i32,
261
- name: String,
262
- host_fn_id: usize,
263
- timeout_ms: u64,
264
- },
265
- // Batch attach: install many (name, host_fn_id) host fns in one round-trip
266
- // (a fresh realm needs ~dozens). Same semantics as Attach, applied in order.
267
- AttachMany {
268
- context_id: i32,
269
- entries: Vec<(String, usize)>,
270
- timeout_ms: u64,
271
- },
272
- // reset: swap globalThis for a fresh v8::Context, reusing the same warm
273
- // isolate — csim's per-visit reset. Applies to the named context.
274
- Reset {
275
- context_id: i32,
276
- },
277
- // create_context: build a fresh, persistent v8::Context in the isolate and
278
- // return its id (the multi-realm model). DisposeContext frees one.
279
- CreateContext,
280
- DisposeContext {
281
- context_id: i32,
282
- },
283
- // Thin ES-module primitives (V8's raw compile/instantiate/evaluate). The
284
- // embedder owns the url->Module registry and the resolve policy; the binding
285
- // just exposes the steps. A compiled module is addressed by an id (like a
286
- // realm) since a v8::Local handle can't outlive the op's scope.
287
- CompileModule {
288
- // The context to compile the module in (modules are realm-bound).
289
- context_id: i32,
290
- source: String,
291
- filename: String,
292
- // Bytecode cache to consume (skip reparse); None compiles fresh.
293
- cached_data: Option<Vec<u8>>,
294
- // Produce a fresh bytecode cache to hand back (Module#cached_data).
295
- produce_cache: bool,
296
- // Eager-compile every function up front (CompileOptions::EagerCompile)
297
- // instead of V8's default lazy top-level-only compile. Ignored when
298
- // cached_data is set (V8 forbids ConsumeCodeCache + EagerCompile).
299
- eager: bool,
300
- },
301
- // instantiate: V8 walks imports, calling back to the Ruby resolve block
302
- // (parked in the slot for the op) per edge via resolve_imported.
303
- InstantiateModule {
304
- module_id: i32,
305
- },
306
- EvaluateModule {
307
- module_id: i32,
308
- timeout_ms: u64,
309
- },
310
- ModuleNamespace {
311
- module_id: i32,
312
- },
313
- // The module's v8::Module::Status, as a lowercase name ("uninstantiated",
314
- // "instantiated", ...) the Ruby wrapper symbolizes.
315
- ModuleStatus {
316
- module_id: i32,
317
- },
318
- DisposeModule {
319
- module_id: i32,
320
- },
321
- // Classic <script> primitives (V8 ScriptCompiler::CompileUnboundScript): an
322
- // unbound script, compiled in a context, runnable repeatedly, with the same
323
- // bytecode-cache options as modules. Addressed by id like a module.
324
- CompileScript {
325
- context_id: i32,
326
- source: String,
327
- filename: String,
328
- cached_data: Option<Vec<u8>>,
329
- produce_cache: bool,
330
- eager: bool,
331
- },
332
- // Bind the script to its context and run it; returns the completion value.
333
- RunScript {
334
- script_id: i32,
335
- timeout_ms: u64,
336
- },
337
- DisposeScript {
338
- script_id: i32,
339
- },
340
- // Serialize a bytecode cache from a compiled handle's CURRENT compile state
341
- // (Script#create_code_cache / Module#create_code_cache). Called after run/
342
- // evaluate, it captures the inner functions V8 lazily compiled while running
343
- // — the only way (as of V8-150) to get inner-function bytecode into a cache,
344
- // since create_code_cache at compile time only sees the top level.
345
- ScriptCodeCache {
346
- script_id: i32,
347
- },
348
- ModuleCodeCache {
349
- module_id: i32,
350
- },
351
- }
352
-
353
- // compile_module result: the module's id plus any produced bytecode cache and
354
- // whether a supplied cache was rejected.
355
- struct Compiled {
356
- id: i32,
357
- cached_data: Option<Vec<u8>>,
358
- cache_rejected: bool,
359
- }
360
-
361
- // The terminal reply of an op: service_request returns it straight up to
362
- // Core::run (no channel). Host callbacks and module resolvers don't round-trip
363
- // through here — they run inline (with_gvl).
364
- enum VmReply {
365
- Done(Result<JsVal, VmError>),
366
- // compile_module / compile's richer reply (id + produced cache + rejected).
367
- ModuleCompiled(Result<Compiled, VmError>),
368
- ScriptCompiled(Result<Compiled, VmError>),
369
- // Script#/Module#create_code_cache: the serialized bytes, or None when V8
370
- // can't produce a cache (or the handle's realm is gone).
371
- CodeCache(Result<Option<Vec<u8>>, VmError>),
189
+ Terminated, // watchdog/stop -> RustyRacer::ScriptTerminatedError
190
+ OutOfMemory, // memory_limit hit -> RustyRacer::V8OutOfMemoryError
191
+ }
192
+
193
+ // V8's near-heap-limit callback (registered per isolate when memory_limit > 0).
194
+ // V8 calls this, synchronously on the owner thread, when a GC still leaves the
195
+ // heap about to exceed the configured ceiling i.e. the script is running away
196
+ // on memory. `data` is the isolate ptr we registered (Core.iso_ptr). We flag the
197
+ // isolate and terminate the running JS so it unwinds with a catchable error
198
+ // rather than V8 aborting the process. The return value becomes V8's new ceiling:
199
+ // hand it a DOUBLED limit so the unwind itself (and any pending finalizers) has
200
+ // room to allocate without tripping a hard OOM abort mid-unwind. Core::run, once
201
+ // the op has unwound, forces a GC to reclaim and resets the ceiling — see the OOM
202
+ // recovery there. The bump is a no-op-after-the-fact: doubling here, GC + reset
203
+ // after, so the limit keeps protecting later ops.
204
+ unsafe extern "C" fn near_heap_limit_cb(data: *mut c_void, current_heap_limit: usize, _initial: usize) -> usize {
205
+ let isolate = unsafe { &mut *(data as *mut v8::Isolate) };
206
+ // get_slot_mut (not istate!): this runs as an extern "C" callback from V8's
207
+ // C++ allocator, where a panic would unwind across the FFI boundary. The slot
208
+ // is always present once an op can run (set in Isolate::new before any JS), but
209
+ // skip flagging rather than .expect()-panic in the impossible absent case.
210
+ // Setting the flag releases the &mut borrow before terminate_execution (&self).
211
+ if isolate.get_slot_mut::<IsolateState>().map(|s| s.oom_fired = true).is_some() {
212
+ isolate.terminate_execution();
213
+ }
214
+ current_heap_limit.saturating_mul(2)
215
+ }
216
+
217
+ // After an OOM the running op's outcome is a bare Terminated (the terminate the
218
+ // callback fired). Swap it for OutOfMemory so it surfaces as V8OutOfMemoryError,
219
+ // preserving the reply's variant (the caller dispatches on it). Only the error
220
+ // arm changes; a Terminated from a real timeout/stop never reaches here because
221
+ // this runs only when oom_fired was set.
222
+ fn relabel_oom(reply: VmReply) -> VmReply {
223
+ fn fix<T>(r: Result<T, VmError>) -> Result<T, VmError> {
224
+ match r {
225
+ Err(VmError::Terminated) => Err(VmError::OutOfMemory),
226
+ other => other,
227
+ }
228
+ }
229
+ match reply {
230
+ VmReply::Done(r) => VmReply::Done(fix(r)),
231
+ VmReply::ModuleCompiled(r) => VmReply::ModuleCompiled(fix(r)),
232
+ VmReply::ScriptCompiled(r) => VmReply::ScriptCompiled(fix(r)),
233
+ VmReply::CodeCache(r) => VmReply::CodeCache(fix(r)),
234
+ }
372
235
  }
373
236
 
374
237
  // ---------------------------------------------------------------------------
@@ -449,644 +312,6 @@ fn error_to_exception(e: &Error) -> Option<Exception> {
449
312
  None
450
313
  }
451
314
 
452
- // ---------------------------------------------------------------------------
453
- // V8 stack limit (in-thread: V8 runs on the calling Ruby thread's stack)
454
- // ---------------------------------------------------------------------------
455
- // rusty_v8 doesn't wrap the runtime `v8::Isolate::SetStackLimit(uintptr_t)`, so
456
- // link the public V8 symbol directly (stable across V8 versions). It sets the
457
- // lowest address V8's stack may reach before it throws RangeError.
458
- unsafe extern "C" {
459
- #[link_name = "_ZN2v87Isolate13SetStackLimitEm"]
460
- fn v8__Isolate__SetStackLimit(isolate: *mut c_void, stack_limit: usize);
461
- // V8's own (exported) accessors down to the conservative-GC-scan Stack
462
- // object, so we can re-point its stack_start per op when V8 runs on a Ruby
463
- // Fiber (see set_fiber_scan_start / discover_scan_start_field). Member fns:
464
- // the first arg is `this`. The public v8::Isolate* IS i::Isolate*.
465
- #[link_name = "_ZN2v88internal7Isolate4heapEv"]
466
- fn v8__internal__Isolate__heap(isolate: *mut c_void) -> *mut c_void;
467
- #[link_name = "_ZN2v88internal4Heap5stackEv"]
468
- fn v8__internal__Heap__stack(heap: *mut c_void) -> *mut c_void;
469
- // Sets the scan stack_start to v8::base::Stack::GetStackStart() (the native
470
- // pthread top) — used only to positively identify the field during discovery.
471
- #[link_name = "_ZN2v88internal4Heap13SetStackStartEv"]
472
- fn v8__internal__Heap__SetStackStart(heap: *mut c_void);
473
- #[link_name = "_ZN2v84base5Stack13GetStackStartEv"]
474
- fn v8__base__Stack__GetStackStart() -> usize;
475
- }
476
-
477
- // Locate V8's conservative-GC-scan stack_start field
478
- // (heap::base::Stack::current_segment_.start) so set_fiber_scan_start can
479
- // re-point it per op. The scanner walks [SP, stack_start); on a Ruby Fiber V8's
480
- // stack_start is still the NATIVE thread top, a different region, so the walk
481
- // runs off the fiber's mapped top into the guard page and SEGVs (the residual
482
- // after the limit fix). We reach the Stack via V8's exported Isolate::heap()/
483
- // Heap::stack(); the field is the first word of Stack (current_segment_ is its
484
- // first member, .start the first field), but we VERIFY rather than trust the
485
- // layout: Heap::SetStackStart() writes that field to base::Stack::GetStackStart(),
486
- // so if poking a sentinel and re-calling SetStackStart restores the value at
487
- // offset 0, that word IS the field. Any mismatch returns 0 (override disabled —
488
- // V8 keeps its native start, i.e. the rare pre-fix crash, NEVER corruption).
489
- // Must run with the isolate ENTERED. `real_isolate` is the raw v8::Isolate*.
490
- fn discover_scan_start_field(real_isolate: *mut c_void) -> usize {
491
- const SENTINEL: usize = 0xA5A5_A5A5_A5A5_A5A5;
492
- unsafe {
493
- let heap = v8__internal__Isolate__heap(real_isolate);
494
- if heap.is_null() {
495
- return 0;
496
- }
497
- let stack = v8__internal__Heap__stack(heap);
498
- if stack.is_null() {
499
- return 0;
500
- }
501
- let nt = v8__base__Stack__GetStackStart();
502
- if nt == 0 {
503
- return 0;
504
- }
505
- v8__internal__Heap__SetStackStart(heap); // start := nt
506
- let field = stack as *mut usize; // expected &current_segment_.start
507
- if field.read() != nt {
508
- return 0; // offset 0 isn't the field (layout changed) — disable
509
- }
510
- field.write(SENTINEL);
511
- v8__internal__Heap__SetStackStart(heap); // must rewrite the same word
512
- if field.read() != nt {
513
- return 0; // SetStackStart doesn't own offset 0 — disable
514
- }
515
- stack as usize
516
- }
517
- }
518
-
519
- // The native thread's stack bounds are stable per NATIVE thread, but querying
520
- // them (pthread, which reads /proc/self/maps for the main thread on Linux) is
521
- // far too slow per op. Cache (bottom, top) in a native-thread-local — correct
522
- // under M:N (each native thread caches its own stack) and ~free after the first
523
- // op on a thread. (0, 0) if it can't be queried.
524
- thread_local! {
525
- static STACK_BOUNDS: std::cell::Cell<(usize, usize)> =
526
- const { std::cell::Cell::new((0, 0)) };
527
- }
528
-
529
- fn native_stack_bounds_cached() -> (usize, usize) {
530
- STACK_BOUNDS.with(|c| {
531
- let cached = c.get();
532
- if cached.0 != 0 {
533
- return cached;
534
- }
535
- let bounds = native_stack_bounds();
536
- c.set(bounds);
537
- bounds
538
- })
539
- }
540
-
541
- // (bottom, top) of the CURRENT native thread's stack via pthread (uncached —
542
- // callers go through native_stack_bounds_cached). The stack grows DOWN from top
543
- // toward bottom. (0, 0) if it can't be queried. NB: this is the NATIVE thread's
544
- // pthread stack; a Ruby Fiber runs on a separate mmap'd stack invisible here.
545
- #[cfg(target_os = "linux")]
546
- fn native_stack_bounds() -> (usize, usize) {
547
- unsafe {
548
- let mut attr: libc::pthread_attr_t = std::mem::zeroed();
549
- if libc::pthread_getattr_np(libc::pthread_self(), &mut attr) != 0 {
550
- return (0, 0);
551
- }
552
- let mut addr: *mut c_void = null_mut();
553
- let mut size: libc::size_t = 0;
554
- let rc = libc::pthread_attr_getstack(&attr, &mut addr, &mut size);
555
- libc::pthread_attr_destroy(&mut attr);
556
- if rc != 0 {
557
- return (0, 0);
558
- }
559
- (addr as usize, addr as usize + size)
560
- }
561
- }
562
-
563
- #[cfg(target_os = "macos")]
564
- fn native_stack_bounds() -> (usize, usize) {
565
- unsafe {
566
- let top = libc::pthread_get_stackaddr_np(libc::pthread_self()) as usize;
567
- let size = libc::pthread_get_stacksize_np(libc::pthread_self());
568
- (top.saturating_sub(size), top)
569
- }
570
- }
571
-
572
- #[cfg(not(any(target_os = "linux", target_os = "macos")))]
573
- fn native_stack_bounds() -> (usize, usize) {
574
- (0, 0)
575
- }
576
-
577
- // Lower bound (and upper, for caching) of the memory region containing `addr`
578
- // — i.e. the BOTTOM of the stack `addr` is on. Used for a Ruby Fiber, whose
579
- // mmap'd stack pthread can't see: V8's limit must sit ABOVE this bottom or a
580
- // deep fiber recursion overflows the real stack and SEGVs the unmapped guard.
581
- // Cached per native thread keyed by the region (parsing /proc/self/maps is
582
- // slow): reused while successive ops stay on the same fiber. (0, 0) if unknown.
583
- thread_local! {
584
- static FIBER_REGION: std::cell::Cell<(usize, usize)> = const { std::cell::Cell::new((0, 0)) };
585
- }
586
-
587
- fn current_region_bounds_cached(addr: usize) -> (usize, usize) {
588
- FIBER_REGION.with(|c| {
589
- let (lo, hi) = c.get();
590
- if lo != 0 && addr >= lo && addr < hi {
591
- return (lo, hi);
592
- }
593
- let bounds = query_region_bounds(addr);
594
- if bounds.0 != 0 {
595
- c.set(bounds);
596
- }
597
- bounds
598
- })
599
- }
600
-
601
- // The [start, end) of the /proc/self/maps mapping containing `addr`. Linux only;
602
- // (0, 0) elsewhere (and the caller falls back). Reads the file fresh — slow, so
603
- // only called on a cache miss (a new fiber).
604
- #[cfg(target_os = "linux")]
605
- fn query_region_bounds(addr: usize) -> (usize, usize) {
606
- use std::io::Read;
607
- let mut buf = String::new();
608
- if std::fs::File::open("/proc/self/maps")
609
- .and_then(|mut f| f.read_to_string(&mut buf))
610
- .is_err()
611
- {
612
- return (0, 0);
613
- }
614
- for line in buf.lines() {
615
- // e.g. "7f6a...000-7f6a...000 rw-p 00000000 00:00 0 ..."
616
- let Some((range, _)) = line.split_once(' ') else {
617
- continue;
618
- };
619
- let Some((lo, hi)) = range.split_once('-') else {
620
- continue;
621
- };
622
- if let (Ok(lo), Ok(hi)) = (
623
- usize::from_str_radix(lo, 16),
624
- usize::from_str_radix(hi, 16),
625
- ) {
626
- if addr >= lo && addr < hi {
627
- return (lo, hi);
628
- }
629
- }
630
- }
631
- (0, 0)
632
- }
633
-
634
- #[cfg(not(target_os = "linux"))]
635
- fn query_region_bounds(_addr: usize) -> (usize, usize) {
636
- (0, 0)
637
- }
638
-
639
- // Set from RUSTY_RACER_STACK_DEBUG at init; gates the per-op stack diagnostics.
640
- static STACK_DEBUG: AtomicBool = AtomicBool::new(false);
641
-
642
- // Re-point V8's stack limit at the CURRENT stack each op. In-thread V8 runs
643
- // wherever the Ruby code is: usually the native thread's pthread stack, but also
644
- // a Ruby Fiber's separate mmap'd stack (Capybara::Result is an Enumerator) that
645
- // pthread can't see. The limit MUST sit between the current SP and the real
646
- // bottom of whatever stack we're on:
647
- // * Too high (above SP) and V8 declares a FALSE overflow on entry.
648
- // * Too low (below the real bottom) and a deep recursion grows past the
649
- // mapped stack and SEGVs the unmapped guard page below it.
650
- // So detect the stack by comparing the SP to the cached native bounds: on the
651
- // native stack, anchor to its pthread bottom; on a fiber, find the bottom of the
652
- // /proc/self/maps region holding the SP (the fiber's real bottom — anchoring to
653
- // SP minus a fixed guard punched through the bottom of Avo's small/deep Capybara
654
- // fibers and SEGV'd). Must be called with the isolate ENTERED. `real_isolate` is
655
- // the raw v8::Isolate* read out of iso_ptr.
656
- //
657
- // On a fiber it ALSO re-points V8's conservative-GC-scan stack_start (via
658
- // scan_start_field, discovered once per isolate) to `stack_top`: Enter just set
659
- // it to the native top, but the scanner walks [marker, stack_start), so a native
660
- // start runs the scan off the fiber's mapped stack into unmapped memory and
661
- // SEGVs (Avo's Capybara filter chain). scan_start_field is 0 when discovery
662
- // failed (override disabled).
663
- //
664
- // LIMITATION (worker-thread fibers): the GC and a thrown exception ALSO
665
- // `CHECK(IsOnCentralStack(SP))`, which tests the SP against
666
- // `base::Stack::GetStackStart()` — the pthread top, cached per native thread,
667
- // with no API to retarget — NOT the scan start we re-point above. A fiber mmap'd
668
- // ABOVE that top (the common case on a NON-main native thread, whose stack sits
669
- // below later fiber mmaps) fails the CHECK, so V8 aborts on the next GC or throw.
670
- // We can fix the scan (the SEGV) but not that CHECK. On the main thread the
671
- // process stack is the highest address, so every fiber is below it and both the
672
- // scan and the CHECK are safe — the Capybara/Avo case. See README.
673
- fn set_v8_stack_limit(real_isolate: *mut c_void, scan_start_field: usize, stack_top: usize) {
674
- let sp_marker = 0u8;
675
- let sp = &sp_marker as *const u8 as usize;
676
- let (nbottom, ntop) = native_stack_bounds_cached();
677
- let on_native = nbottom != 0 && sp > nbottom && sp <= ntop;
678
- // Reserve below the limit for V8's own RangeError-throw frames.
679
- const NATIVE_GUARD: usize = 128 * 1024;
680
- // V8 throws when SP descends to the limit, then needs some real stack BELOW
681
- // it to build the RangeError (and V8 itself allows growing a little past the
682
- // limit — its overflow slack). On a fiber that reserve must NOT cross the
683
- // fiber's real bottom (the mapping below it is an unmapped guard -> SEGV), so
684
- // keep it comfortably above V8's slack.
685
- const FIBER_RESERVE: usize = 64 * 1024;
686
- let mut region = (0usize, 0usize);
687
- let limit = if on_native {
688
- nbottom + NATIVE_GUARD
689
- } else {
690
- // Anchor to the FIBER's real bottom (the /proc/self/maps region holding
691
- // the SP), not the SP: SP - fixed_guard can punch through the bottom of a
692
- // small/deep fiber stack and SEGV (Avo's deep Capybara filter chain).
693
- // Reserve FIBER_RESERVE above the bottom for the throw, but keep the
694
- // limit below the SP so we don't false-overflow; on a nearly-full fiber
695
- // that clamps the headroom down (an early but CLEAN RangeError).
696
- region = current_region_bounds_cached(sp);
697
- if region.0 != 0 {
698
- (region.0 + FIBER_RESERVE).min(sp.saturating_sub(8 * 1024))
699
- } else {
700
- sp.saturating_sub(64 * 1024) // region unknown (non-linux) — best effort
701
- }
702
- };
703
- if limit == 0 {
704
- return; // couldn't determine a sane limit — leave V8's default
705
- }
706
- unsafe { v8__Isolate__SetStackLimit(real_isolate, limit) };
707
- // On a fiber, re-point V8's conservative-GC-scan stack_start to `stack_top`
708
- // — a live address captured by the caller ABOVE every V8 frame of this op.
709
- // Enter() set the start to the NATIVE top (a different region); the scanner
710
- // walks [marker, start), so a native start runs it off the fiber's mapped
711
- // top into unmapped memory and SEGVs. Anchoring to stack_top keeps the whole
712
- // scan range between two real stack pointers (marker..stack_top), so it's
713
- // guaranteed mapped, and every V8 root (all below stack_top) is still found.
714
- // (We can't use the /proc/maps region top here: that mapping isn't reliably
715
- // contiguous, so the scan could still hit a hole below it.)
716
- if !on_native && stack_top != 0 && scan_start_field != 0 {
717
- unsafe { (scan_start_field as *mut usize).write(stack_top) };
718
- }
719
- // Opt-in diagnostics (RUSTY_RACER_STACK_DEBUG): the SP vs the native stack
720
- // [nbottom, ntop], the fiber region (if any), the per-op limit, and whether
721
- // the SP is above the limit. A crash with sp_above_limit=false means the
722
- // limit is wrong for the current stack.
723
- if STACK_DEBUG.load(Ordering::Relaxed) {
724
- eprintln!(
725
- "[rusty stack] sp={sp:#x} nbottom={nbottom:#x} ntop={ntop:#x} \
726
- region=[{:#x},{:#x}) limit={limit:#x} fiber={} sp_above_limit={} \
727
- fiber_above_native={}",
728
- region.0,
729
- region.1,
730
- !on_native,
731
- sp > limit,
732
- !on_native && nbottom != 0 && sp > ntop,
733
- );
734
- }
735
- }
736
-
737
- // Little-endian u64 limbs -> big-endian hex magnitude (no sign, no "0x"). The
738
- // shared currency between V8 BigInt words and Ruby Integer(str, 16).
739
- fn words_to_hex(words: &[u64]) -> String {
740
- let mut hex = String::new();
741
- for w in words.iter().rev() {
742
- if hex.is_empty() {
743
- hex.push_str(&format!("{w:x}")); // top limb: no leading zeros
744
- } else {
745
- hex.push_str(&format!("{w:016x}")); // lower limbs: full width
746
- }
747
- }
748
- if hex.is_empty() {
749
- hex.push('0');
750
- }
751
- hex
752
- }
753
-
754
- // Big-endian hex magnitude -> little-endian u64 limbs (inverse of words_to_hex).
755
- fn hex_to_words(hex: &str) -> Vec<u64> {
756
- let mut words = Vec::new();
757
- let mut end = hex.len();
758
- while end > 0 {
759
- let start = end.saturating_sub(16);
760
- words.push(u64::from_str_radix(&hex[start..end], 16).unwrap_or(0));
761
- end = start;
762
- }
763
- if words.is_empty() {
764
- words.push(0);
765
- }
766
- words
767
- }
768
-
769
- // Tracks objects already emitted this marshal so a re-encounter becomes a
770
- // Ref instead of re-expansion. Buckets by V8 identity hash (which can collide),
771
- // disambiguated by Local equality — the same trick the module registry uses.
772
- #[derive(Default)]
773
- struct JsSeen {
774
- next_id: u32,
775
- map: HashMap<i32, Vec<(v8::Global<v8::Object>, u32)>>,
776
- }
777
-
778
- // Decide how to emit a container object: Ok(id) = first sighting, register it
779
- // and recurse; Err(jsval) = emit this directly and stop (a Ref to an already-
780
- // seen object, or a truncated Str at the depth backstop). Centralising this in
781
- // one place keeps the four container arms (array/object/map/set) in lockstep —
782
- // and crucially orders the checks so a depth-truncated object is NEVER assigned
783
- // an id (which would leave a sibling Ref dangling).
784
- fn js_container_id(
785
- scope: &mut v8::PinScope<'_, '_>,
786
- seen: &mut JsSeen,
787
- value: v8::Local<v8::Value>,
788
- obj: v8::Local<v8::Object>,
789
- depth: u32,
790
- ) -> Result<u32, JsVal> {
791
- let hash = obj.get_identity_hash().get();
792
- if let Some(bucket) = seen.map.get(&hash) {
793
- for (g, id) in bucket {
794
- if v8::Local::new(scope, g) == obj {
795
- return Err(JsVal::Ref(*id));
796
- }
797
- }
798
- }
799
- // First sighting but too deep: truncate WITHOUT registering, so no later
800
- // Ref can target a container that was never emitted.
801
- if depth >= MAX_MARSHAL_DEPTH {
802
- return Err(JsVal::Str(value.to_rust_string_lossy(scope)));
803
- }
804
- let id = seen.next_id;
805
- seen.next_id += 1;
806
- let g = v8::Global::new(scope, obj);
807
- seen.map.entry(hash).or_default().push((g, id));
808
- Ok(id)
809
- }
810
-
811
- // Copy |len| bytes from a V8 (Shared)ArrayBuffer backing pointer into an owned
812
- // Vec, with one allocation and no zero-fill (data is fully overwritten). |data|
813
- // is None only for a zero-length buffer, where the empty Vec is already right.
814
- fn copy_buffer_bytes(data: Option<std::ptr::NonNull<c_void>>, len: usize) -> Vec<u8> {
815
- let mut buf = Vec::with_capacity(len);
816
- if let Some(p) = data {
817
- unsafe {
818
- std::ptr::copy_nonoverlapping(p.as_ptr() as *const u8, buf.as_mut_ptr(), len);
819
- buf.set_len(len);
820
- }
821
- }
822
- buf
823
- }
824
-
825
- fn js_to_jsval(scope: &mut v8::PinScope<'_, '_>, value: v8::Local<v8::Value>) -> JsVal {
826
- let mut seen = JsSeen::default();
827
- js_to_jsval_d(scope, value, &mut seen, 0)
828
- }
829
-
830
- fn js_to_jsval_d(
831
- scope: &mut v8::PinScope<'_, '_>,
832
- value: v8::Local<v8::Value>,
833
- seen: &mut JsSeen,
834
- depth: u32,
835
- ) -> JsVal {
836
- if value.is_undefined() {
837
- return JsVal::Undefined;
838
- }
839
- if value.is_null() {
840
- return JsVal::Null;
841
- }
842
- if value.is_boolean() {
843
- return JsVal::Bool(value.boolean_value(scope));
844
- }
845
- if value.is_int32() {
846
- return JsVal::Int(value.integer_value(scope).unwrap_or(0));
847
- }
848
- if value.is_number() {
849
- return JsVal::Num(value.number_value(scope).unwrap_or(f64::NAN));
850
- }
851
- if value.is_big_int() {
852
- if let Ok(bi) = v8::Local::<v8::BigInt>::try_from(value) {
853
- let mut words = vec![0u64; bi.word_count()];
854
- let (negative, _) = bi.to_words_array(&mut words);
855
- return JsVal::BigInt { negative, words };
856
- }
857
- }
858
- // Date before the generic object branch (a Date *is* an object).
859
- if value.is_date() {
860
- if let Ok(date) = v8::Local::<v8::Date>::try_from(value) {
861
- return JsVal::Date(date.value_of());
862
- }
863
- }
864
- // Binary buffers before the generic object branch (they are objects too).
865
- // A TypedArray/DataView copies its VIEWED window; a bare ArrayBuffer or
866
- // SharedArrayBuffer copies the whole buffer. All become a Ruby binary
867
- // String. (Without the SharedArrayBuffer arm a bare SAB would fall through
868
- // to the plain-object branch and marshal as an empty Hash — silent loss.)
869
- if value.is_array_buffer_view() {
870
- if let Ok(view) = v8::Local::<v8::ArrayBufferView>::try_from(value) {
871
- let obj = v8::Local::<v8::Object>::try_from(value).unwrap();
872
- // depth 0: a buffer is a leaf (no recursion into children), so it
873
- // never risks native-stack overflow and must stay faithful bytes
874
- // even when deeply nested — only the identity (Ref) check applies,
875
- // never the depth-truncation-to-lossy-string the generic path uses.
876
- let id = match js_container_id(scope, seen, value, obj, 0) {
877
- Ok(id) => id,
878
- Err(jsval) => return jsval, // a Ref to the same buffer
879
- };
880
- let len = view.byte_length();
881
- let mut buf: Vec<u8> = Vec::with_capacity(len);
882
- // copy_contents_uninit writes into the UNINITIALIZED spare capacity
883
- // (a &mut [MaybeUninit<u8>]) — never forming a &mut [u8] over uninit
884
- // memory the way copy_contents would (that's UB). set_len to exactly
885
- // what it wrote so a detached/short view never exposes uninit bytes.
886
- let n = view.copy_contents_uninit(&mut buf.spare_capacity_mut()[..len]);
887
- unsafe { buf.set_len(n) };
888
- return JsVal::Bytes { id: Some(id), bytes: buf };
889
- }
890
- }
891
- if value.is_array_buffer() {
892
- if let Ok(ab) = v8::Local::<v8::ArrayBuffer>::try_from(value) {
893
- let obj = v8::Local::<v8::Object>::try_from(value).unwrap();
894
- // depth 0 — a buffer is a leaf; see the view arm above.
895
- let id = match js_container_id(scope, seen, value, obj, 0) {
896
- Ok(id) => id,
897
- Err(jsval) => return jsval,
898
- };
899
- return JsVal::Bytes {
900
- id: Some(id),
901
- bytes: copy_buffer_bytes(ab.data(), ab.byte_length()),
902
- };
903
- }
904
- }
905
- if value.is_shared_array_buffer() {
906
- if let Ok(sab) = v8::Local::<v8::SharedArrayBuffer>::try_from(value) {
907
- let obj = v8::Local::<v8::Object>::try_from(value).unwrap();
908
- // depth 0 — a buffer is a leaf; see the view arm above.
909
- let id = match js_container_id(scope, seen, value, obj, 0) {
910
- Ok(id) => id,
911
- Err(jsval) => return jsval,
912
- };
913
- let store = sab.get_backing_store();
914
- return JsVal::Bytes {
915
- id: Some(id),
916
- bytes: copy_buffer_bytes(store.data(), sab.byte_length()),
917
- };
918
- }
919
- }
920
- // Map/Set before the generic object branch (both are objects).
921
- if value.is_map() {
922
- let obj = v8::Local::<v8::Object>::try_from(value).unwrap();
923
- let id = match js_container_id(scope, seen, value, obj, depth) {
924
- Ok(id) => id,
925
- Err(jsval) => return jsval,
926
- };
927
- let map = v8::Local::<v8::Map>::try_from(value).unwrap();
928
- let arr = map.as_array(scope); // [k0, v0, k1, v1, ...]
929
- let mut pairs = Vec::with_capacity((arr.length() / 2) as usize);
930
- let mut i = 0;
931
- while i + 1 < arr.length() {
932
- let k = arr.get_index(scope, i).unwrap_or_else(|| v8::undefined(scope).into());
933
- let v = arr.get_index(scope, i + 1).unwrap_or_else(|| v8::undefined(scope).into());
934
- let kj = js_to_jsval_d(scope, k, seen, depth + 1);
935
- let vj = js_to_jsval_d(scope, v, seen, depth + 1);
936
- pairs.push((kj, vj));
937
- i += 2;
938
- }
939
- return JsVal::Map { id, pairs };
940
- }
941
- if value.is_set() {
942
- let obj = v8::Local::<v8::Object>::try_from(value).unwrap();
943
- let id = match js_container_id(scope, seen, value, obj, depth) {
944
- Ok(id) => id,
945
- Err(jsval) => return jsval,
946
- };
947
- let set = v8::Local::<v8::Set>::try_from(value).unwrap();
948
- let arr = set.as_array(scope);
949
- let mut items = Vec::with_capacity(arr.length() as usize);
950
- for i in 0..arr.length() {
951
- let el = arr.get_index(scope, i).unwrap_or_else(|| v8::undefined(scope).into());
952
- items.push(js_to_jsval_d(scope, el, seen, depth + 1));
953
- }
954
- return JsVal::Set { id, items };
955
- }
956
- if value.is_array() {
957
- let obj = v8::Local::<v8::Object>::try_from(value).unwrap();
958
- let id = match js_container_id(scope, seen, value, obj, depth) {
959
- Ok(id) => id,
960
- Err(jsval) => return jsval,
961
- };
962
- let arr = v8::Local::<v8::Array>::try_from(value).unwrap();
963
- let mut items = Vec::with_capacity(arr.length() as usize);
964
- for i in 0..arr.length() {
965
- let el = arr
966
- .get_index(scope, i)
967
- .unwrap_or_else(|| v8::undefined(scope).into());
968
- items.push(js_to_jsval_d(scope, el, seen, depth + 1));
969
- }
970
- return JsVal::Array { id, items };
971
- }
972
- // Plain object -> string-keyed Obj. Functions/Date/etc. fall through to
973
- // their toString (the spike's primitive escape hatch).
974
- if value.is_object() && !value.is_function() {
975
- let obj = v8::Local::<v8::Object>::try_from(value).unwrap();
976
- let id = match js_container_id(scope, seen, value, obj, depth) {
977
- Ok(id) => id,
978
- Err(jsval) => return jsval,
979
- };
980
- if let Some(names) = obj.get_own_property_names(scope, Default::default()) {
981
- let mut entries = Vec::with_capacity(names.length() as usize);
982
- for i in 0..names.length() {
983
- let Some(key) = names.get_index(scope, i) else {
984
- continue;
985
- };
986
- let key_str = key.to_rust_string_lossy(scope);
987
- let val = obj
988
- .get(scope, key)
989
- .unwrap_or_else(|| v8::undefined(scope).into());
990
- entries.push((key_str, js_to_jsval_d(scope, val, seen, depth + 1)));
991
- }
992
- return JsVal::Obj { id, entries };
993
- }
994
- }
995
- JsVal::Str(value.to_rust_string_lossy(scope))
996
- }
997
-
998
- // Owned-by-value (not &JsVal): a JsVal::Bytes hands its Vec straight to V8's
999
- // backing store with no copy of the payload, so a large binary blob crosses
1000
- // Ruby->JS with zero extra allocation.
1001
- fn jsval_to_js<'s>(scope: &mut v8::PinScope<'s, '_>, val: JsVal) -> v8::Local<'s, v8::Value> {
1002
- let mut built: HashMap<u32, v8::Local<'s, v8::Value>> = HashMap::new();
1003
- jsval_to_js_d(scope, val, &mut built)
1004
- }
1005
-
1006
- fn jsval_to_js_d<'s>(
1007
- scope: &mut v8::PinScope<'s, '_>,
1008
- val: JsVal,
1009
- built: &mut HashMap<u32, v8::Local<'s, v8::Value>>,
1010
- ) -> v8::Local<'s, v8::Value> {
1011
- match val {
1012
- JsVal::Undefined => v8::undefined(scope).into(),
1013
- JsVal::Null => v8::null(scope).into(),
1014
- JsVal::Bool(b) => v8::Boolean::new(scope, b).into(),
1015
- JsVal::Int(i) => v8::Number::new(scope, i as f64).into(),
1016
- JsVal::Num(n) => v8::Number::new(scope, n).into(),
1017
- JsVal::Str(s) => v8::String::new(scope, &s)
1018
- .map(|s| s.into())
1019
- .unwrap_or_else(|| v8::undefined(scope).into()),
1020
- // Bytes -> Uint8Array, moving the Vec into V8's backing store (no copy
1021
- // of the payload). Registered under |id| so an aliased blob resolves to
1022
- // the same Uint8Array via Ref.
1023
- JsVal::Bytes { id, bytes } => {
1024
- let len = bytes.len();
1025
- let store = v8::ArrayBuffer::new_backing_store_from_vec(bytes).make_shared();
1026
- let ab = v8::ArrayBuffer::with_backing_store(scope, &store);
1027
- let arr: v8::Local<v8::Value> = v8::Uint8Array::new(scope, ab, 0, len)
1028
- .map(|a| a.into())
1029
- .unwrap_or_else(|| v8::undefined(scope).into());
1030
- if let Some(id) = id {
1031
- built.insert(id, arr);
1032
- }
1033
- arr
1034
- }
1035
- JsVal::BigInt { negative, words } => v8::BigInt::new_from_words(scope, negative, &words)
1036
- .map(|b| b.into())
1037
- .unwrap_or_else(|| v8::undefined(scope).into()),
1038
- JsVal::Date(ms) => v8::Date::new(scope, ms)
1039
- .map(|d| d.into())
1040
- .unwrap_or_else(|| v8::undefined(scope).into()),
1041
- // Register the container under its id BEFORE filling it, so a Ref from
1042
- // a descendant (a cycle back to here) resolves to this same object.
1043
- JsVal::Array { id, items } => {
1044
- let arr = v8::Array::new(scope, items.len() as i32);
1045
- built.insert(id, arr.into());
1046
- for (i, it) in items.into_iter().enumerate() {
1047
- let v = jsval_to_js_d(scope, it, built);
1048
- arr.set_index(scope, i as u32, v);
1049
- }
1050
- arr.into()
1051
- }
1052
- JsVal::Obj { id, entries } => {
1053
- let obj = v8::Object::new(scope);
1054
- built.insert(id, obj.into());
1055
- for (k, it) in entries {
1056
- let Some(key) = v8::String::new(scope, &k) else {
1057
- continue;
1058
- };
1059
- let v = jsval_to_js_d(scope, it, built);
1060
- obj.set(scope, key.into(), v);
1061
- }
1062
- obj.into()
1063
- }
1064
- JsVal::Map { id, pairs } => {
1065
- let map = v8::Map::new(scope);
1066
- built.insert(id, map.into());
1067
- for (k, v) in pairs {
1068
- let kk = jsval_to_js_d(scope, k, built);
1069
- let vv = jsval_to_js_d(scope, v, built);
1070
- map.set(scope, kk, vv);
1071
- }
1072
- map.into()
1073
- }
1074
- JsVal::Set { id, items } => {
1075
- let set = v8::Set::new(scope);
1076
- built.insert(id, set.into());
1077
- for it in items {
1078
- let v = jsval_to_js_d(scope, it, built);
1079
- set.add(scope, v);
1080
- }
1081
- set.into()
1082
- }
1083
- JsVal::Ref(id) => built
1084
- .get(&id)
1085
- .copied()
1086
- .unwrap_or_else(|| v8::undefined(scope).into()),
1087
- }
1088
- }
1089
-
1090
315
  // JS called a host function. We are on the owner thread with the GVL RELEASED
1091
316
  // (the runner's without_gvl). Reacquire the GVL via with_gvl, run the Ruby proc
1092
317
  // inline, and set the JS return — no channel, no other thread. A VM op the proc
@@ -1242,103 +467,6 @@ fn capture_js_error(
1242
467
  VmError::JsError { message, backtrace }
1243
468
  }
1244
469
 
1245
- fn run_source(scope: &mut v8::PinScope<'_, '_>, source: &str, filename: &str) -> Result<JsVal, VmError> {
1246
- v8::tc_scope!(let tc, scope);
1247
- // Compile and run as distinct phases so a compile failure maps to
1248
- // ParseError and a thrown exception to RuntimeError (csim rescues both).
1249
- let Some(code) = v8::String::new(tc, source) else {
1250
- return Err(VmError::Parse("source too large".into()));
1251
- };
1252
- let origin = script_origin(tc, filename);
1253
- let script = match v8::Script::compile(tc, code, Some(&origin)) {
1254
- Some(script) => script,
1255
- None if tc.has_terminated() => return Err(VmError::Terminated),
1256
- None => {
1257
- let msg = tc
1258
- .exception()
1259
- .map(|e| e.to_rust_string_lossy(tc))
1260
- .unwrap_or_else(|| "parse error".to_string());
1261
- // Append the location V8 recorded; always name the file, add the
1262
- // line when V8 reports one.
1263
- let message = tc.message();
1264
- let res = message
1265
- .and_then(|m| m.get_script_resource_name(tc))
1266
- .filter(|v| v.is_string())
1267
- .map(|v| v.to_rust_string_lossy(tc))
1268
- .unwrap_or_else(|| filename.to_string());
1269
- let loc = match message.and_then(|m| m.get_line_number(tc)) {
1270
- Some(line) => format!(" at {res}:{line}"),
1271
- None => format!(" at {res}"),
1272
- };
1273
- return Err(VmError::Parse(format!("{msg}{loc}")));
1274
- }
1275
- };
1276
- match script.run(tc) {
1277
- Some(value) => Ok(js_to_jsval(tc, value)),
1278
- None if tc.has_terminated() => Err(VmError::Terminated),
1279
- None => {
1280
- let exc = tc.exception();
1281
- let stack = tc.stack_trace();
1282
- Err(capture_js_error(tc, exc, stack))
1283
- }
1284
- }
1285
- }
1286
-
1287
- // Resolve a dotted property path on globalThis to a function and invoke it via
1288
- // v8::Function::call, with the property's holder as `this` (so `a.b.f` gets the
1289
- // right receiver). Args/result marshal through the ref-preserving paths.
1290
- fn call_function(
1291
- scope: &mut v8::PinScope<'_, '_>,
1292
- name: &str,
1293
- args: Vec<JsVal>,
1294
- void: bool,
1295
- ) -> Result<JsVal, VmError> {
1296
- v8::tc_scope!(let tc, scope);
1297
- let context = tc.get_current_context();
1298
- let global = context.global(tc);
1299
- let mut recv: v8::Local<v8::Value> = global.into();
1300
- let mut target: v8::Local<v8::Value> = global.into();
1301
- for part in name.split('.') {
1302
- let Some(obj) = target.to_object(tc) else {
1303
- // The holder of `part` (a preceding segment) was null/undefined, so
1304
- // there's nothing to read `part` from — name the holder, not `part`.
1305
- return Err(VmError::Runtime(format!(
1306
- "`{name}`: cannot read `{part}` (a preceding path segment is not an object)"
1307
- )));
1308
- };
1309
- let Some(key) = v8::String::new(tc, part) else {
1310
- return Err(VmError::Runtime("property name too large".into()));
1311
- };
1312
- let Some(next) = obj.get(tc, key.into()) else {
1313
- if tc.has_terminated() {
1314
- return Err(VmError::Terminated);
1315
- }
1316
- let msg = tc
1317
- .exception()
1318
- .map(|e| e.to_rust_string_lossy(tc))
1319
- .unwrap_or_else(|| format!("cannot read `{part}` of `{name}`"));
1320
- return Err(VmError::Runtime(msg));
1321
- };
1322
- recv = target;
1323
- target = next;
1324
- }
1325
- let Ok(func) = v8::Local::<v8::Function>::try_from(target) else {
1326
- return Err(VmError::Runtime(format!("`{name}` is not a function")));
1327
- };
1328
- let argv: Vec<v8::Local<v8::Value>> = args.into_iter().map(|a| jsval_to_js(tc, a)).collect();
1329
- match func.call(tc, recv, &argv) {
1330
- // void: skip marshalling the return so a huge/cyclic result is never walked.
1331
- Some(_) if void => Ok(JsVal::Undefined),
1332
- Some(value) => Ok(js_to_jsval(tc, value)),
1333
- None if tc.has_terminated() => Err(VmError::Terminated),
1334
- None => {
1335
- let exc = tc.exception();
1336
- let stack = tc.stack_trace();
1337
- Err(capture_js_error(tc, exc, stack))
1338
- }
1339
- }
1340
- }
1341
-
1342
470
  // ---------------------------------------------------------------------------
1343
471
  // ES modules: V8's raw compile/instantiate/evaluate steps, with the embedder
1344
472
  // owning the url->Module registry (MODULES) and the resolve policy.
@@ -1350,30 +478,6 @@ fn module_origin<'s>(scope: &v8::PinScope<'s, '_>, url: &str) -> v8::ScriptOrigi
1350
478
  )
1351
479
  }
1352
480
 
1353
- // The (Source, CompileOptions) pair shared by the module and script compile
1354
- // handlers: consume a supplied bytecode cache (skip reparse), else eager-compile
1355
- // every function up front, else compile lazily (V8's default — only the top
1356
- // level). A supplied cache wins over `eager`: V8's CompileOptionsIsValid forbids
1357
- // ConsumeCodeCache + EagerCompile together, so `eager` is ignored on the consume
1358
- // path. (Source is an owned struct — V8 copies the origin in — so returning it
1359
- // across this fn boundary keeps the same handle-lifetime contract as inlining.)
1360
- fn compile_source<'s>(
1361
- code: v8::Local<'s, v8::String>,
1362
- origin: &v8::ScriptOrigin<'s>,
1363
- cached_data: &Option<Vec<u8>>,
1364
- eager: bool,
1365
- ) -> (v8::script_compiler::Source, v8::script_compiler::CompileOptions) {
1366
- use v8::script_compiler::{CompileOptions, Source};
1367
- match cached_data {
1368
- Some(bytes) => (
1369
- Source::new_with_cached_data(code, Some(origin), v8::script_compiler::CachedData::new(bytes)),
1370
- CompileOptions::ConsumeCodeCache,
1371
- ),
1372
- None if eager => (Source::new(code, Some(origin)), CompileOptions::EagerCompile),
1373
- None => (Source::new(code, Some(origin)), CompileOptions::NoCompileOptions),
1374
- }
1375
- }
1376
-
1377
481
  // Registry for the thin compile_module/instantiate API: each compiled module is
1378
482
  // addressed by an id, with its url kept for the resolve round-trip and a
1379
483
  // hash bucket to map a referrer Local<Module> back to its id.
@@ -1463,6 +567,12 @@ struct IsolateState {
1463
567
  instantiate_resolve: Option<RootedProc>,
1464
568
  instantiate_resolve_err: Option<BoxValue<Exception>>,
1465
569
  watchdog: Arc<WatchdogShared>,
570
+ // Set by near_heap_limit_cb when the configured memory_limit is hit: it
571
+ // terminates the running JS, and Core::run reads this after the op to relabel
572
+ // the terminate as OutOfMemory and recover the heap (GC + reset the ceiling).
573
+ // Plain bool (no atomic): the callback fires synchronously on the owner thread,
574
+ // never concurrently with the bracket that reads it.
575
+ oom_fired: bool,
1466
576
  }
1467
577
 
1468
578
  impl IsolateState {
@@ -1483,15 +593,8 @@ impl IsolateState {
1483
593
  core_ptr: std::ptr::null(),
1484
594
  instantiate_resolve: None,
1485
595
  instantiate_resolve_err: None,
1486
- watchdog: Arc::new(WatchdogShared {
1487
- inner: Mutex::new(WatchdogInner {
1488
- frames: Vec::new(),
1489
- next_generation: 0,
1490
- fired_generation: None,
1491
- shutdown: false,
1492
- }),
1493
- cv: Condvar::new(),
1494
- }),
596
+ watchdog: WatchdogShared::new(),
597
+ oom_fired: false,
1495
598
  }
1496
599
  }
1497
600
  }
@@ -1557,32 +660,6 @@ fn auto_drain(scope: &mut v8::PinScope<'_, '_>, outermost: bool) {
1557
660
  }
1558
661
  }
1559
662
 
1560
- // The shared bracket every JS-running request (Eval/Call/Attach/RunScript/
1561
- // EvaluateModule) needs: arm the watchdog, run |body|, then on a watchdog
1562
- // timeout flag the leftover terminate for the outermost sweep and — only if
1563
- // |body| actually ran JS (the bool it returns) — override its outcome to
1564
- // Terminated. |body| owns its ContextScope, JS call, and auto_drain, and
1565
- // returns (ran_js, outcome); the realm-disposed/unknown paths return
1566
- // (false, Err(..)) so a raced watchdog can't poison an error for work that ran
1567
- // no JS. Collapsing the five arms onto this keeps the terminate discipline in
1568
- // ONE place.
1569
- fn run_js_bracketed(
1570
- scope: &mut v8::PinScope<'_, '_, ()>,
1571
- outermost: bool,
1572
- timeout_ms: u64,
1573
- body: impl FnOnce(&mut v8::PinScope<'_, '_, ()>, bool) -> (bool, Result<JsVal, VmError>),
1574
- ) -> Result<JsVal, VmError> {
1575
- let watchdog = arm_watchdog(scope, timeout_ms);
1576
- let (ran_js, mut outcome) = body(scope, outermost);
1577
- if disarm_watchdog(scope, watchdog) {
1578
- istate!(scope).watchdog_fired = true;
1579
- if ran_js {
1580
- outcome = Err(VmError::Terminated);
1581
- }
1582
- }
1583
- outcome
1584
- }
1585
-
1586
663
  // Drop every module AND script compiled in `context_id` (its v8::Context is
1587
664
  // going away — on reset or dispose — so those handles are now dead).
1588
665
  fn drop_context_artifacts(state: &mut IsolateState, context_id: i32) {
@@ -1920,853 +997,6 @@ fn finish_dynamic_import(
1920
997
  }
1921
998
  }
1922
999
 
1923
- // The watchdog runs on ONE persistent thread per isolate rather than a fresh
1924
- // std::thread per request: spawning + joining a thread on every op cost ~16µs
1925
- // (5.5x) when a timeout was set, dwarfing the actual work. The thread sleeps on
1926
- // a condvar until a deadline is armed, terminates execution once the deadline
1927
- // passes, then goes back to sleep.
1928
- struct WatchdogShared {
1929
- inner: Mutex<WatchdogInner>,
1930
- cv: Condvar,
1931
- }
1932
-
1933
- // One armed request's deadline. `run_js_bracketed` is RE-ENTRANT — a host fn
1934
- // called from JS can issue a nested op that arms again while the outer op is
1935
- // still running — so the armed deadlines form a LIFO stack, not a single slot.
1936
- // (The old per-op design gave each op its own watchdog thread; collapsing onto
1937
- // one thread must not let a nested arm/disarm clobber the outer op's deadline,
1938
- // or the outer op would run unbounded after the nested call returns.)
1939
- #[derive(Clone, Copy)]
1940
- struct WatchdogFrame {
1941
- generation: u64,
1942
- deadline: Instant,
1943
- }
1944
-
1945
- struct WatchdogInner {
1946
- // Every currently-armed op (with timeout_ms > 0), pushed on arm and removed
1947
- // on disarm. The loop honours the EARLIEST deadline across all frames: the
1948
- // most urgent timeout fires first, and since TerminateExecution is
1949
- // isolate-global it tears down whatever is running (escalating outward).
1950
- frames: Vec<WatchdogFrame>,
1951
- // Monotonic; each arm takes the next value as its frame's id.
1952
- next_generation: u64,
1953
- // The generation whose deadline the loop terminated on — consumed (and
1954
- // cleared) by that op's disarm so it can map its outcome to Terminated.
1955
- fired_generation: Option<u64>,
1956
- // Set at isolate teardown to break the loop.
1957
- shutdown: bool,
1958
- }
1959
-
1960
- // The persistent watchdog loop. Runs off a Send IsolateHandle so it never
1961
- // borrows the isolate the V8 thread owns.
1962
- fn watchdog_loop(shared: Arc<WatchdogShared>, handle: v8::IsolateHandle) {
1963
- let mut inner = shared.inner.lock().unwrap();
1964
- loop {
1965
- if inner.shutdown {
1966
- return;
1967
- }
1968
- // The earliest deadline among all armed frames is the one to enforce.
1969
- match inner.frames.iter().min_by_key(|f| f.deadline).copied() {
1970
- // Idle: sleep until a frame is armed (or shutdown).
1971
- None => inner = shared.cv.wait(inner).unwrap(),
1972
- Some(frame) => {
1973
- let now = Instant::now();
1974
- if now >= frame.deadline {
1975
- handle.terminate_execution();
1976
- inner.fired_generation = Some(frame.generation);
1977
- // Drop the fired frame so the loop moves on to the next
1978
- // deadline instead of re-firing this one every wakeup.
1979
- inner.frames.retain(|f| f.generation != frame.generation);
1980
- } else {
1981
- let (next, _) = shared.cv.wait_timeout(inner, frame.deadline - now).unwrap();
1982
- inner = next;
1983
- }
1984
- }
1985
- }
1986
- }
1987
- }
1988
-
1989
- // (The watchdog Arc now lives in IsolateState; arm/disarm reach it via istate!.)
1990
-
1991
- // Arm the watchdog for this request: push a frame with its own deadline and
1992
- // wake the loop. Returns the generation token to hand to `disarm_watchdog`
1993
- // (None when timeout_ms is 0 — no watchdog for this request).
1994
- fn arm_watchdog(scope: &mut v8::PinScope<'_, '_, ()>, timeout_ms: u64) -> Option<u64> {
1995
- if timeout_ms == 0 {
1996
- return None;
1997
- }
1998
- let shared = &istate!(scope).watchdog;
1999
- let mut inner = shared.inner.lock().unwrap();
2000
- inner.next_generation += 1;
2001
- let generation = inner.next_generation;
2002
- inner.frames.push(WatchdogFrame {
2003
- generation,
2004
- deadline: Instant::now() + Duration::from_millis(timeout_ms),
2005
- });
2006
- shared.cv.notify_one();
2007
- Some(generation)
2008
- }
2009
-
2010
- // Disarm: drop THIS request's frame (leaving any outer frame still armed) and
2011
- // report whether its deadline fired. On fire the caller maps the outcome to
2012
- // Terminated and the outermost frame sweeps the leftover terminate via
2013
- // WATCHDOG_FIRED; removing only this frame keeps a late terminate from
2014
- // poisoning the next request without clobbering a still-running outer op.
2015
- fn disarm_watchdog(scope: &mut v8::PinScope<'_, '_, ()>, generation: Option<u64>) -> bool {
2016
- let Some(generation) = generation else {
2017
- return false;
2018
- };
2019
- let shared = &istate!(scope).watchdog;
2020
- let mut inner = shared.inner.lock().unwrap();
2021
- inner.frames.retain(|f| f.generation != generation);
2022
- let fired = inner.fired_generation == Some(generation);
2023
- if fired {
2024
- inner.fired_generation = None;
2025
- }
2026
- shared.cv.notify_one();
2027
- fired
2028
- }
2029
-
2030
- // Service ONE request inline on the owner thread and RETURN its terminal reply.
2031
- // This is the single dispatcher for BOTH a top-level op and a re-entrant one (a
2032
- // host proc / module resolver that issues another op), so EVERY op — not just
2033
- // eval/call — works re-entrantly. `outermost` (depth == 0, computed by Core::run
2034
- // before it bumped the depth) owns the terminate-flag cleanup; a nested op
2035
- // passes false.
2036
- fn service_request(scope: &mut v8::PinScope<'_, '_, ()>, request: Request, outermost: bool) -> VmReply {
2037
- // Clear any terminate left over from BEFORE this request. An
2038
- // Isolate#terminate fired while no JS was running arms the isolate-global
2039
- // flag but no watchdog_fired, so the end-of-request sweep would miss it and
2040
- // the next eval would abort spuriously — and an idle terminate isn't even
2041
- // observable via is_execution_terminating() yet, so cancel unconditionally.
2042
- // Only at the outermost frame: a terminate aimed at a SUSPENDED outer frame
2043
- // must survive a nested request.
2044
- if outermost {
2045
- scope.cancel_terminate_execution();
2046
- }
2047
- // Mark the realm this request runs in active while it is on the stack, so
2048
- // Reset/DisposeContext can refuse to pull a live realm out from under a
2049
- // suspended frame.
2050
- let realm = request_realm(istate!(scope), &request);
2051
- if let Some(id) = realm {
2052
- istate!(scope).active_realms.push(id);
2053
- }
2054
- let reply = dispatch_one(scope, request, outermost);
2055
- if realm.is_some() {
2056
- istate!(scope).active_realms.pop();
2057
- }
2058
- // Sweep a leftover terminate flag once the whole request stack has
2059
- // unwound (see watchdog_fired for why nested frames must not cancel).
2060
- if outermost && istate!(scope).watchdog_fired {
2061
- istate!(scope).watchdog_fired = false;
2062
- scope.cancel_terminate_execution();
2063
- }
2064
- reply
2065
- }
2066
-
2067
- // The realm a request will run in (None for realm-independent ops); feeds
2068
- // ACTIVE_REALMS above.
2069
- fn request_realm(state: &IsolateState, request: &Request) -> Option<i32> {
2070
- match request {
2071
- Request::Eval { context_id, .. }
2072
- | Request::Call { context_id, .. }
2073
- | Request::Attach { context_id, .. }
2074
- | Request::AttachMany { context_id, .. }
2075
- | Request::CompileModule { context_id, .. }
2076
- | Request::CompileScript { context_id, .. } => Some(*context_id),
2077
- Request::DrainMicrotasks { .. } => Some(0),
2078
- Request::InstantiateModule { module_id, .. }
2079
- | Request::EvaluateModule { module_id, .. }
2080
- | Request::ModuleNamespace { module_id, .. } => {
2081
- module_handle(state, *module_id).map(|(_, cid)| cid)
2082
- }
2083
- Request::RunScript { script_id, .. } => script_handle(state, *script_id).map(|(_, cid)| cid),
2084
- Request::Reset { .. }
2085
- | Request::CreateContext
2086
- | Request::DisposeContext { .. }
2087
- | Request::ModuleStatus { .. }
2088
- | Request::DisposeModule { .. }
2089
- | Request::DisposeScript { .. }
2090
- | Request::ScriptCodeCache { .. }
2091
- | Request::ModuleCodeCache { .. } => None,
2092
- }
2093
- }
2094
-
2095
- fn dispatch_one(scope: &mut v8::PinScope<'_, '_, ()>, request: Request, outermost: bool) -> VmReply {
2096
- // A request-scoped handle scope, so handles created while servicing a
2097
- // nested request don't pile up in the suspended callback's scope.
2098
- v8::scope!(let scope, &mut *scope);
2099
- {
2100
- match request {
2101
- Request::Eval {
2102
- context_id,
2103
- source,
2104
- filename,
2105
- timeout_ms,
2106
- } => {
2107
- let outcome = run_js_bracketed(scope, outermost, timeout_ms, |scope, outermost| {
2108
- let realm = context_for(istate!(scope), context_id);
2109
- match realm {
2110
- Some(ctx) => {
2111
- let context = v8::Local::new(scope, &ctx);
2112
- let scope = &mut v8::ContextScope::new(scope, context);
2113
- let out = run_source(scope, &source, &filename);
2114
- auto_drain(scope, outermost);
2115
- (true, out)
2116
- }
2117
- None => (false, Err(VmError::Runtime("realm disposed or unknown".into()))),
2118
- }
2119
- });
2120
- VmReply::Done(outcome)
2121
- }
2122
- Request::Call {
2123
- context_id,
2124
- name,
2125
- args,
2126
- void,
2127
- timeout_ms,
2128
- } => {
2129
- // A host fn invoked by the called function runs inline
2130
- // (host_fn_callback, with_gvl) — no routing setup needed.
2131
- let outcome = run_js_bracketed(scope, outermost, timeout_ms, |scope, outermost| {
2132
- let realm = context_for(istate!(scope), context_id);
2133
- match realm {
2134
- Some(ctx) => {
2135
- let context = v8::Local::new(scope, &ctx);
2136
- let scope = &mut v8::ContextScope::new(scope, context);
2137
- let out = call_function(scope, &name, args, void);
2138
- auto_drain(scope, outermost);
2139
- (true, out)
2140
- }
2141
- None => (false, Err(VmError::Runtime("realm disposed or unknown".into()))),
2142
- }
2143
- });
2144
- VmReply::Done(outcome)
2145
- }
2146
- Request::DrainMicrotasks { timeout_ms } => {
2147
- // A microtask may call an attached host fn (a Promise .then ->
2148
- // ruby), which runs inline via host_fn_callback — no routing
2149
- // setup needed any more.
2150
- let watchdog = arm_watchdog(scope, timeout_ms);
2151
- let main = context_for(istate!(scope), 0);
2152
- if let Some(ctx) = main {
2153
- let context = v8::Local::new(scope, &ctx);
2154
- let scope = &mut v8::ContextScope::new(scope, context);
2155
- checkpoint_draining(scope);
2156
- }
2157
- let fired = disarm_watchdog(scope, watchdog);
2158
- if fired {
2159
- istate!(scope).watchdog_fired = true;
2160
- }
2161
- let outcome = if fired {
2162
- Err(VmError::Terminated)
2163
- } else {
2164
- Ok(JsVal::Undefined)
2165
- };
2166
- VmReply::Done(outcome)
2167
- }
2168
- Request::Attach {
2169
- context_id,
2170
- name,
2171
- host_fn_id,
2172
- timeout_ms,
2173
- } => {
2174
- // attach_at_path writes onto globalThis (and walks a dotted
2175
- // path), which can fire a user-defined accessor or Proxy trap —
2176
- // arbitrary JS. So it goes through the same bracket as Eval: a
2177
- // host fn the trap calls routes back, and a looping trap is
2178
- // time-capped.
2179
- let outcome = run_js_bracketed(scope, outermost, timeout_ms, |scope, outermost| {
2180
- let realm = context_for(istate!(scope), context_id);
2181
- match realm {
2182
- Some(ctx) => {
2183
- let context = v8::Local::new(scope, &ctx);
2184
- let scope = &mut v8::ContextScope::new(scope, context);
2185
- let external = v8::External::new(scope, host_fn_id as *mut c_void);
2186
- let out = match v8::Function::builder(host_fn_callback)
2187
- .data(external.into())
2188
- .build(scope)
2189
- {
2190
- // A dotted name (e.g. "MiniRacer.foo") attaches
2191
- // under a namespace object, creating missing
2192
- // intermediates, so host fns needn't pollute the
2193
- // bare global.
2194
- Some(function) => attach_at_path(scope, context, &name, function),
2195
- None => Err(VmError::Runtime("failed to build function".into())),
2196
- };
2197
- auto_drain(scope, outermost);
2198
- (true, out)
2199
- }
2200
- None => (false, Err(VmError::Runtime("realm disposed or unknown".into()))),
2201
- }
2202
- });
2203
- VmReply::Done(outcome)
2204
- }
2205
- Request::AttachMany {
2206
- context_id,
2207
- entries,
2208
- timeout_ms,
2209
- } => {
2210
- // Same as Attach (arbitrary JS via accessors/Proxy traps), but
2211
- // installs every entry under one bracket/drain. Applied in order;
2212
- // stops at the first failure and reports its (name-tagged) error.
2213
- // NOT transactional: entries before the failure stay attached —
2214
- // the realm is not rolled back (matches single Attach, which also
2215
- // commits its one write or fails it).
2216
- let outcome = run_js_bracketed(scope, outermost, timeout_ms, |scope, outermost| {
2217
- let realm = context_for(istate!(scope), context_id);
2218
- match realm {
2219
- Some(ctx) => {
2220
- let context = v8::Local::new(scope, &ctx);
2221
- let scope = &mut v8::ContextScope::new(scope, context);
2222
- let mut out = Ok(JsVal::Undefined);
2223
- for (name, host_fn_id) in &entries {
2224
- let external = v8::External::new(scope, *host_fn_id as *mut c_void);
2225
- out = match v8::Function::builder(host_fn_callback)
2226
- .data(external.into())
2227
- .build(scope)
2228
- {
2229
- Some(function) => attach_at_path(scope, context, name, function),
2230
- None => Err(VmError::Runtime(format!(
2231
- "failed to build function for `{name}`"
2232
- ))),
2233
- };
2234
- if out.is_err() {
2235
- break;
2236
- }
2237
- }
2238
- auto_drain(scope, outermost);
2239
- (true, out)
2240
- }
2241
- None => (false, Err(VmError::Runtime("realm disposed or unknown".into()))),
2242
- }
2243
- });
2244
- VmReply::Done(outcome)
2245
- }
2246
- Request::Reset { context_id } => {
2247
- let known =
2248
- context_id == 0 || istate!(scope).realms.contexts.contains_key(&context_id);
2249
- if istate!(scope).draining {
2250
- // A microtask from ANY realm may be mid-flight on the stack;
2251
- // swapping a v8::Context out from under it corrupts state.
2252
- VmReply::Done(Err(VmError::Runtime(
2253
- "cannot reset a realm during a microtask checkpoint".into(),
2254
- )))
2255
- } else if !known {
2256
- VmReply::Done(Err(VmError::Runtime(
2257
- "context disposed or unknown".into(),
2258
- )))
2259
- } else if istate!(scope).active_realms.contains(&context_id) {
2260
- // Swapping the v8::Context behind a suspended frame would
2261
- // drop its in-flight modules/scripts and let the realm id
2262
- // refer to a different context than the one on the stack
2263
- // (defeating the cross-context import guards).
2264
- VmReply::Done(Err(VmError::Runtime(
2265
- "cannot reset a realm while a request for it is suspended on the V8 stack"
2266
- .into(),
2267
- )))
2268
- } else {
2269
- let fresh = new_realm(scope, context_id);
2270
- {
2271
- let realms = &mut istate!(scope).realms;
2272
- if context_id == 0 {
2273
- realms.main_context = Some(fresh);
2274
- } else {
2275
- realms.contexts.insert(context_id, fresh);
2276
- }
2277
- }
2278
- // Drop modules bound to this context — their realm just changed.
2279
- drop_context_artifacts(istate!(scope), context_id);
2280
- VmReply::Done(Ok(JsVal::Undefined))
2281
- }
2282
- }
2283
- Request::CreateContext => {
2284
- let id = {
2285
- let realms = &mut istate!(scope).realms;
2286
- let id = realms.next_context_id;
2287
- realms.next_context_id += 1;
2288
- id
2289
- };
2290
- let fresh = new_realm(scope, id);
2291
- istate!(scope).realms.contexts.insert(id, fresh);
2292
- VmReply::Done(Ok(JsVal::Int(id as i64)))
2293
- }
2294
- Request::DisposeContext { context_id } => {
2295
- if istate!(scope).draining {
2296
- // Same hazard as Reset: a microtask from any realm may be live.
2297
- VmReply::Done(Err(VmError::Runtime(
2298
- "cannot dispose a realm during a microtask checkpoint".into(),
2299
- )))
2300
- } else if istate!(scope).active_realms.contains(&context_id) {
2301
- // Same hazard as Reset: a suspended frame still runs in it.
2302
- VmReply::Done(Err(VmError::Runtime(
2303
- "cannot dispose a realm while a request for it is suspended on the V8 stack"
2304
- .into(),
2305
- )))
2306
- } else {
2307
- // Dropping the Global lets V8 collect the context. id 0 is the
2308
- // default context and never disposed independently.
2309
- istate!(scope).realms.contexts.remove(&context_id);
2310
- // Reclaim the modules compiled in it (else they leak until
2311
- // isolate teardown).
2312
- drop_context_artifacts(istate!(scope), context_id);
2313
- VmReply::Done(Ok(JsVal::Undefined))
2314
- }
2315
- }
2316
- Request::CompileModule {
2317
- context_id,
2318
- source,
2319
- filename,
2320
- cached_data,
2321
- produce_cache,
2322
- eager,
2323
- } => {
2324
- let ctx = context_for(istate!(scope), context_id);
2325
- let outcome = match ctx {
2326
- None => Err(VmError::Runtime("context disposed or unknown".into())),
2327
- Some(cx) => {
2328
- let context = v8::Local::new(scope, &cx);
2329
- let scope = &mut v8::ContextScope::new(scope, context);
2330
- v8::tc_scope!(let tc, scope);
2331
- match v8::String::new(tc, &source) {
2332
- None => Err(VmError::Runtime("module source too large".into())),
2333
- Some(code) => {
2334
- let origin = module_origin(tc, &filename);
2335
- // Consume a supplied bytecode cache (skip reparse),
2336
- // eager-compile every function, or compile fresh
2337
- // (lazy). cached_data wins: V8 forbids combining
2338
- // ConsumeCodeCache with EagerCompile.
2339
- let (mut src, opts) = compile_source(code, &origin, &cached_data, eager);
2340
- let compiled = v8::script_compiler::compile_module2(
2341
- tc,
2342
- &mut src,
2343
- opts,
2344
- v8::script_compiler::NoCacheReason::NoReason,
2345
- );
2346
- match compiled {
2347
- Some(module) => {
2348
- // V8 marks a stale/incompatible supplied cache
2349
- // rejected; the embedder recompiles & re-caches.
2350
- let cache_rejected = cached_data.is_some()
2351
- && src.get_cached_data().is_some_and(|c| c.rejected());
2352
- // Produce a fresh cache from the unbound script.
2353
- let produced = if produce_cache {
2354
- module
2355
- .get_unbound_module_script(tc)
2356
- .create_code_cache()
2357
- .map(|c| c.to_vec())
2358
- } else {
2359
- None
2360
- };
2361
- let hash = module.get_identity_hash().get();
2362
- let g = v8::Global::new(tc, module);
2363
- let id = {
2364
- let m = &mut istate!(tc).modules;
2365
- let id = m.next_id;
2366
- m.next_id += 1;
2367
- m.by_id
2368
- .insert(id, (g.clone(), filename.clone(), context_id));
2369
- m.by_hash.entry(hash).or_default().push((g, id));
2370
- id
2371
- };
2372
- Ok(Compiled {
2373
- id,
2374
- cached_data: produced,
2375
- cache_rejected,
2376
- })
2377
- }
2378
- None if tc.has_terminated() => Err(VmError::Terminated),
2379
- // A module compile failure is a parse error
2380
- // (compile-time), not a thrown exception.
2381
- None => {
2382
- let msg = tc
2383
- .exception()
2384
- .map(|e| e.to_rust_string_lossy(tc))
2385
- .unwrap_or_else(|| "module parse error".to_string());
2386
- let message = tc.message();
2387
- let res = message
2388
- .and_then(|m| m.get_script_resource_name(tc))
2389
- .filter(|v| v.is_string())
2390
- .map(|v| v.to_rust_string_lossy(tc))
2391
- .unwrap_or_else(|| filename.clone());
2392
- let loc = match message.and_then(|m| m.get_line_number(tc)) {
2393
- Some(line) => format!(" at {res}:{line}"),
2394
- None => format!(" at {res}"),
2395
- };
2396
- Err(VmError::Parse(format!("{msg}{loc}")))
2397
- }
2398
- }
2399
- }
2400
- }
2401
- }
2402
- };
2403
- VmReply::ModuleCompiled(outcome)
2404
- }
2405
- Request::InstantiateModule { module_id } => {
2406
- // V8's module instantiation is NOT re-entrant: a nested
2407
- // instantiate issued from a resolve block walks the outer,
2408
- // half-built module graph and SEGVs the process. Refuse it
2409
- // cleanly — a resolve block may COMPILE dependencies lazily
2410
- // and return them; the outer instantiate links them.
2411
- if istate!(scope).instantiating {
2412
- VmReply::Done(Err(VmError::Runtime(
2413
- "instantiate is not re-entrant: another module is currently \
2414
- instantiating (compile the dependency and return it; the outer \
2415
- instantiate links it)"
2416
- .into(),
2417
- )))
2418
- } else {
2419
- istate!(scope).instantiating = true;
2420
- let handle = module_handle(istate!(scope), module_id);
2421
- let outcome = match handle {
2422
- None => Err(VmError::Runtime("unknown module".into())),
2423
- Some((g, cid)) => match context_for(istate!(scope), cid) {
2424
- None => Err(VmError::Runtime("module's context is gone".into())),
2425
- Some(cx) => {
2426
- let context = v8::Local::new(scope, &cx);
2427
- let scope = &mut v8::ContextScope::new(scope, context);
2428
- let module = v8::Local::new(scope, &g);
2429
- match module.get_status() {
2430
- // Already linked (or further along): a no-op,
2431
- // not an error — instantiate is idempotent.
2432
- v8::ModuleStatus::Instantiated
2433
- | v8::ModuleStatus::Evaluating
2434
- | v8::ModuleStatus::Evaluated => Ok(JsVal::Undefined),
2435
- // V8 CHECK-aborts on instantiating an errored
2436
- // module; surface its exception instead.
2437
- v8::ModuleStatus::Errored => Err(VmError::JsError {
2438
- message: module
2439
- .get_exception()
2440
- .to_rust_string_lossy(scope),
2441
- backtrace: vec![],
2442
- }),
2443
- _ => {
2444
- v8::tc_scope!(let tc, scope);
2445
- match module.instantiate_module(tc, resolve_imported) {
2446
- Some(true) => Ok(JsVal::Undefined),
2447
- _ if tc.has_terminated() => Err(VmError::Terminated),
2448
- // A resolver that RAISED is re-raised with its
2449
- // real class by instantiate_module (via the
2450
- // stashed exception); this generic link error
2451
- // is only used when no resolver exception was
2452
- // stashed.
2453
- _ => {
2454
- let exc = tc.exception();
2455
- let stack = tc.stack_trace();
2456
- Err(capture_js_error(tc, exc, stack))
2457
- }
2458
- }
2459
- }
2460
- }
2461
- }
2462
- }
2463
- };
2464
- istate!(scope).instantiating = false;
2465
- VmReply::Done(outcome)
2466
- }
2467
- }
2468
- Request::EvaluateModule { module_id, timeout_ms } => {
2469
- // Top-level module code (and, under :auto, the microtasks its
2470
- // TLA continuation drains) can loop, so it runs in the same
2471
- // watchdog bracket as Eval/Call/RunScript.
2472
- let outcome = run_js_bracketed(scope, outermost, timeout_ms, |scope, outermost| {
2473
- let handle = module_handle(istate!(scope), module_id);
2474
- match handle {
2475
- None => (false, Err(VmError::Runtime("unknown module".into()))),
2476
- Some((g, cid)) => match context_for(istate!(scope), cid) {
2477
- None => (false, Err(VmError::Runtime("module's context is gone".into()))),
2478
- Some(cx) => {
2479
- let context = v8::Local::new(scope, &cx);
2480
- let scope = &mut v8::ContextScope::new(scope, context);
2481
- let module = v8::Local::new(scope, &g);
2482
- // A top-level-await module's evaluate() returns a
2483
- // PENDING promise that only settles once the drain
2484
- // runs its continuation — remember it so we can read
2485
- // its post-drain state instead of reporting a stale Ok.
2486
- let mut eval_promise: Option<v8::Global<v8::Promise>> = None;
2487
- // ran_js is true ONLY for the Instantiated arm that
2488
- // actually calls evaluate(); the Errored/Evaluated/
2489
- // non-instantiated arms run no JS, so a raced watchdog
2490
- // must not override their real outcome to Terminated.
2491
- let mut did_eval = false;
2492
- // V8 CHECK-aborts the process if evaluate runs on a
2493
- // module that isn't exactly Instantiated, so guard
2494
- // status explicitly rather than crash.
2495
- let out = match module.get_status() {
2496
- v8::ModuleStatus::Errored => {
2497
- Err(VmError::JsError {
2498
- message: module
2499
- .get_exception()
2500
- .to_rust_string_lossy(scope),
2501
- backtrace: vec![],
2502
- })
2503
- }
2504
- v8::ModuleStatus::Evaluated => Ok(JsVal::Undefined),
2505
- v8::ModuleStatus::Instantiated => {
2506
- did_eval = true;
2507
- v8::tc_scope!(let tc, scope);
2508
- match module.evaluate(tc) {
2509
- // A synchronous top-level throw yields a
2510
- // *rejected* promise (not None); a pending
2511
- // (TLA) or fulfilled one is remembered and
2512
- // re-checked after the drain.
2513
- Some(value) => match v8::Local::<v8::Promise>::try_from(value) {
2514
- Ok(p) if p.state() == v8::PromiseState::Rejected => {
2515
- let reason = p.result(tc);
2516
- Err(VmError::JsError {
2517
- message: reason.to_rust_string_lossy(tc),
2518
- backtrace: vec![],
2519
- })
2520
- }
2521
- Ok(p) => {
2522
- eval_promise = Some(v8::Global::new(tc, p));
2523
- Ok(JsVal::Undefined)
2524
- }
2525
- _ => Ok(JsVal::Undefined),
2526
- },
2527
- None if tc.has_terminated() => Err(VmError::Terminated),
2528
- None => {
2529
- let exc = tc.exception();
2530
- let stack = tc.stack_trace();
2531
- Err(capture_js_error(tc, exc, stack))
2532
- }
2533
- }
2534
- }
2535
- _ => Err(VmError::Runtime(
2536
- "module must be instantiated before evaluate".into(),
2537
- )),
2538
- };
2539
- auto_drain(scope, outermost);
2540
- // The drain may have settled a TLA module's promise to
2541
- // rejected — surface that instead of the provisional Ok.
2542
- let result = if let (true, Some(g)) = (out.is_ok(), eval_promise) {
2543
- let p = v8::Local::new(scope, &g);
2544
- if p.state() == v8::PromiseState::Rejected {
2545
- let reason = p.result(scope);
2546
- Err(VmError::JsError {
2547
- message: reason.to_rust_string_lossy(scope),
2548
- backtrace: vec![],
2549
- })
2550
- } else {
2551
- out
2552
- }
2553
- } else {
2554
- out
2555
- };
2556
- (did_eval, result)
2557
- }
2558
- }
2559
- }
2560
- });
2561
- VmReply::Done(outcome)
2562
- }
2563
- Request::ModuleNamespace { module_id } => {
2564
- let handle = module_handle(istate!(scope), module_id);
2565
- let outcome = match handle {
2566
- None => Err(VmError::Runtime("unknown module".into())),
2567
- Some((g, cid)) => match context_for(istate!(scope), cid) {
2568
- None => Err(VmError::Runtime("module's context is gone".into())),
2569
- Some(cx) => {
2570
- let context = v8::Local::new(scope, &cx);
2571
- let scope = &mut v8::ContextScope::new(scope, context);
2572
- let module = v8::Local::new(scope, &g);
2573
- // get_module_namespace CHECK-aborts unless the module
2574
- // is at least Instantiated.
2575
- match module.get_status() {
2576
- v8::ModuleStatus::Uninstantiated
2577
- | v8::ModuleStatus::Instantiating => Err(VmError::Runtime(
2578
- "module must be instantiated before namespace".into(),
2579
- )),
2580
- _ => {
2581
- let ns = module.get_module_namespace();
2582
- Ok(js_to_jsval(scope, ns))
2583
- }
2584
- }
2585
- }
2586
- }
2587
- };
2588
- VmReply::Done(outcome)
2589
- }
2590
- Request::ModuleStatus { module_id } => {
2591
- let handle = module_handle(istate!(scope), module_id);
2592
- let outcome = match handle {
2593
- None => Err(VmError::Runtime("unknown module".into())),
2594
- Some((g, _cid)) => {
2595
- let module = v8::Local::new(scope, &g);
2596
- let name = match module.get_status() {
2597
- v8::ModuleStatus::Uninstantiated => "uninstantiated",
2598
- v8::ModuleStatus::Instantiating => "instantiating",
2599
- v8::ModuleStatus::Instantiated => "instantiated",
2600
- v8::ModuleStatus::Evaluating => "evaluating",
2601
- v8::ModuleStatus::Evaluated => "evaluated",
2602
- v8::ModuleStatus::Errored => "errored",
2603
- };
2604
- Ok(JsVal::Str(name.into()))
2605
- }
2606
- };
2607
- VmReply::Done(outcome)
2608
- }
2609
- Request::DisposeModule { module_id } => {
2610
- let m = &mut istate!(scope).modules;
2611
- m.by_id.remove(&module_id);
2612
- for bucket in m.by_hash.values_mut() {
2613
- bucket.retain(|(_, id)| *id != module_id);
2614
- }
2615
- VmReply::Done(Ok(JsVal::Undefined))
2616
- }
2617
- Request::CompileScript {
2618
- context_id,
2619
- source,
2620
- filename,
2621
- cached_data,
2622
- produce_cache,
2623
- eager,
2624
- } => {
2625
- let ctx = context_for(istate!(scope), context_id);
2626
- let outcome = match ctx {
2627
- None => Err(VmError::Runtime("context disposed or unknown".into())),
2628
- Some(cx) => {
2629
- let context = v8::Local::new(scope, &cx);
2630
- let scope = &mut v8::ContextScope::new(scope, context);
2631
- v8::tc_scope!(let tc, scope);
2632
- match v8::String::new(tc, &source) {
2633
- None => Err(VmError::Runtime("script source too large".into())),
2634
- Some(code) => {
2635
- let origin = script_origin(tc, &filename);
2636
- let (mut src, opts) = compile_source(code, &origin, &cached_data, eager);
2637
- match v8::script_compiler::compile_unbound_script(
2638
- tc,
2639
- &mut src,
2640
- opts,
2641
- v8::script_compiler::NoCacheReason::NoReason,
2642
- ) {
2643
- Some(unbound) => {
2644
- let cache_rejected = cached_data.is_some()
2645
- && src.get_cached_data().is_some_and(|c| c.rejected());
2646
- let produced = if produce_cache {
2647
- unbound.create_code_cache().map(|c| c.to_vec())
2648
- } else {
2649
- None
2650
- };
2651
- let g = v8::Global::new(tc, unbound);
2652
- let id = {
2653
- let s = &mut istate!(tc).scripts;
2654
- let id = s.next_id;
2655
- s.next_id += 1;
2656
- s.by_id.insert(id, (g, context_id));
2657
- id
2658
- };
2659
- Ok(Compiled {
2660
- id,
2661
- cached_data: produced,
2662
- cache_rejected,
2663
- })
2664
- }
2665
- None if tc.has_terminated() => Err(VmError::Terminated),
2666
- // Compile failure = a parse error (with location).
2667
- None => {
2668
- let msg = tc
2669
- .exception()
2670
- .map(|e| e.to_rust_string_lossy(tc))
2671
- .unwrap_or_else(|| "script parse error".to_string());
2672
- let message = tc.message();
2673
- let res = message
2674
- .and_then(|m| m.get_script_resource_name(tc))
2675
- .filter(|v| v.is_string())
2676
- .map(|v| v.to_rust_string_lossy(tc))
2677
- .unwrap_or_else(|| filename.clone());
2678
- let loc = match message.and_then(|m| m.get_line_number(tc)) {
2679
- Some(line) => format!(" at {res}:{line}"),
2680
- None => format!(" at {res}"),
2681
- };
2682
- Err(VmError::Parse(format!("{msg}{loc}")))
2683
- }
2684
- }
2685
- }
2686
- }
2687
- }
2688
- };
2689
- VmReply::ScriptCompiled(outcome)
2690
- }
2691
- Request::RunScript {
2692
- script_id,
2693
- timeout_ms,
2694
- } => {
2695
- let outcome = run_js_bracketed(scope, outermost, timeout_ms, |scope, outermost| {
2696
- let handle = script_handle(istate!(scope), script_id);
2697
- match handle {
2698
- None => (false, Err(VmError::Runtime("unknown script".into()))),
2699
- Some((g, cid)) => match context_for(istate!(scope), cid) {
2700
- None => (false, Err(VmError::Runtime("script's context is gone".into()))),
2701
- Some(cx) => {
2702
- let context = v8::Local::new(scope, &cx);
2703
- let scope = &mut v8::ContextScope::new(scope, context);
2704
- let unbound = v8::Local::new(scope, &g);
2705
- let script = unbound.bind_to_current_context(scope);
2706
- let out = {
2707
- v8::tc_scope!(let tc, scope);
2708
- match script.run(tc) {
2709
- Some(value) => Ok(js_to_jsval(tc, value)),
2710
- None if tc.has_terminated() => Err(VmError::Terminated),
2711
- None => {
2712
- let exc = tc.exception();
2713
- let stack = tc.stack_trace();
2714
- Err(capture_js_error(tc, exc, stack))
2715
- }
2716
- }
2717
- };
2718
- auto_drain(scope, outermost);
2719
- (true, out)
2720
- }
2721
- }
2722
- }
2723
- });
2724
- VmReply::Done(outcome)
2725
- }
2726
- Request::DisposeScript { script_id } => {
2727
- istate!(scope).scripts.by_id.remove(&script_id);
2728
- VmReply::Done(Ok(JsVal::Undefined))
2729
- }
2730
- // Serialize the script's CURRENT compile state. The stored handle is
2731
- // the UnboundScript, which V8 fills in with inner-function bytecode as
2732
- // run() lazily compiles them — so calling this after run() captures
2733
- // the functions that actually executed (a warm cache). None when V8
2734
- // can't serialize, or when the realm was reset/disposed out from under
2735
- // the script (its handle is gone): produce nil, not an error.
2736
- Request::ScriptCodeCache { script_id } => {
2737
- let handle = script_handle(istate!(scope), script_id);
2738
- let outcome = match handle {
2739
- None => Ok(None),
2740
- Some((g, _cid)) => {
2741
- let unbound = v8::Local::new(scope, &g);
2742
- Ok(unbound.create_code_cache().map(|c| c.to_vec()))
2743
- }
2744
- };
2745
- VmReply::CodeCache(outcome)
2746
- }
2747
- // Same, for a module: get_unbound_module_script gives the shared
2748
- // compiled script, which evaluate() fills with inner-function bytecode.
2749
- // It needs the module's context entered (unlike UnboundScript), so
2750
- // a gone realm yields nil.
2751
- Request::ModuleCodeCache { module_id } => {
2752
- let mh = module_handle(istate!(scope), module_id);
2753
- let handle = mh.and_then(|(g, cid)| context_for(istate!(scope), cid).map(|cx| (g, cx)));
2754
- let outcome = match handle {
2755
- None => Ok(None),
2756
- Some((g, cx)) => {
2757
- let context = v8::Local::new(scope, &cx);
2758
- let scope = &mut v8::ContextScope::new(scope, context);
2759
- let module = v8::Local::new(scope, &g);
2760
- let unbound = module.get_unbound_module_script(scope);
2761
- Ok(unbound.create_code_cache().map(|c| c.to_vec()))
2762
- }
2763
- };
2764
- VmReply::CodeCache(outcome)
2765
- }
2766
- }
2767
- }
2768
- }
2769
-
2770
1000
  // The id of |context|, read O(1) from the realm-id stamped in by new_realm.
2771
1001
  // None when the context is not a LIVE realm of this isolate — a context reset
2772
1002
  // away still carries its old stamp, so confirm the id currently maps back to
@@ -2855,6 +1085,11 @@ struct Core {
2855
1085
  // Default per-eval/call timeout (ms); 0 = none. eval(timeout_ms:)'s explicit
2856
1086
  // value overrides it. Guards against an in-V8 infinite loop without a watchdog.
2857
1087
  default_timeout_ms: u64,
1088
+ // Per-isolate heap ceiling (bytes); 0 = none. When set, the isolate is created
1089
+ // with this as V8's max heap and near_heap_limit_cb is registered; Core::run's
1090
+ // OOM recovery resets the ceiling back to this after each OOM. Space-axis twin
1091
+ // of default_timeout_ms.
1092
+ memory_limit: usize,
2858
1093
  // Set by Context#dynamic_import_resolver=; called for a JS import() to map
2859
1094
  // (specifier, referrer) to an already-loaded Module. GC-rooted like procs.
2860
1095
  dynamic_import_resolver: Mutex<Option<RootedProc>>,
@@ -2925,6 +1160,10 @@ fn init_v8() {
2925
1160
  std::env::var_os("RUSTY_RACER_STACK_DEBUG").is_some(),
2926
1161
  Ordering::Relaxed,
2927
1162
  );
1163
+ WATCHDOG_DEBUG.store(
1164
+ std::env::var_os("RUSTY_RACER_WATCHDOG_DEBUG").is_some(),
1165
+ Ordering::Relaxed,
1166
+ );
2928
1167
  let platform = v8::new_default_platform(0, false).make_shared();
2929
1168
  v8::V8::initialize_platform(platform);
2930
1169
  v8::V8::initialize();
@@ -3275,6 +1514,9 @@ fn build_snapshot(code: &str, base: Option<Vec<u8>>, warmup: bool) -> Result<Vec
3275
1514
  VmError::Parse(m) | VmError::Runtime(m) => m,
3276
1515
  VmError::JsError { message, .. } => message,
3277
1516
  VmError::Terminated => "snapshot code was terminated".to_string(),
1517
+ // Unreachable: the snapshot-creator isolate carries no
1518
+ // memory_limit, so near_heap_limit_cb is never registered.
1519
+ VmError::OutOfMemory => "snapshot code ran out of memory".to_string(),
3278
1520
  });
3279
1521
  }
3280
1522
  }
@@ -3310,16 +1552,22 @@ impl Isolate {
3310
1552
  host_namespace: Option<String>,
3311
1553
  snapshot: Option<magnus::typed_data::Obj<Snapshot>>,
3312
1554
  timeout_ms: u64,
1555
+ memory_limit: usize,
3313
1556
  explicit_microtasks: bool,
3314
1557
  ) -> Result<Self, Error> {
3315
1558
  init_v8();
3316
1559
  // A snapshot blob bakes globalThis state in: the first Context::new (in
3317
1560
  // new_realm below) deserializes that default context for free.
3318
1561
  let snapshot_bytes = snapshot.map(|s| s.blob.borrow().clone());
3319
- let create_params = match snapshot_bytes {
1562
+ let mut create_params = match snapshot_bytes {
3320
1563
  Some(bytes) => v8::CreateParams::default().snapshot_blob(v8::StartupData::from(bytes)),
3321
1564
  None => Default::default(),
3322
1565
  };
1566
+ // Cap V8's heap at the configured limit so its near-heap-limit callback
1567
+ // fires as the script approaches it (initial 0 = V8's default initial heap).
1568
+ if memory_limit > 0 {
1569
+ create_params = create_params.heap_limits(0, memory_limit);
1570
+ }
3323
1571
  let mut isolate = v8::Isolate::new(create_params);
3324
1572
  // Always Explicit at the V8 level; the binding performs the kAuto
3325
1573
  // end-of-script drain itself (auto_drain) so it stays inside the
@@ -3359,6 +1607,12 @@ impl Isolate {
3359
1607
  // registry moves only the 8-byte pointer; the boxed OwnedIsolate stays put.
3360
1608
  let mut boxed = Box::new(isolate);
3361
1609
  let iso_ptr = IsoPtr(&mut **boxed as *mut v8::Isolate);
1610
+ // Arm the memory limit now that iso_ptr is stable: the callback's data IS
1611
+ // this ptr (it reads the slot's oom_fired and terminates through it), and
1612
+ // Core::run resets the ceiling through the same ptr on recovery.
1613
+ if memory_limit > 0 {
1614
+ boxed.add_near_heap_limit_callback(near_heap_limit_cb, iso_ptr.0 as *mut c_void);
1615
+ }
3362
1616
  let iso_id = NEXT_ISOLATE_ID.fetch_add(1, Ordering::SeqCst);
3363
1617
  isolates().lock().unwrap().insert(iso_id, SendIso(boxed));
3364
1618
  // Root the owner Thread VALUE so its address can't be reused while this
@@ -3377,6 +1631,7 @@ impl Isolate {
3377
1631
  depth: std::sync::atomic::AtomicU32::new(0),
3378
1632
  procs: Mutex::new(ProcTable::default()),
3379
1633
  default_timeout_ms: timeout_ms,
1634
+ memory_limit,
3380
1635
  dynamic_import_resolver: Mutex::new(None),
3381
1636
  watchdog,
3382
1637
  watchdog_join: Mutex::new(Some(watchdog_join)),
@@ -3472,11 +1727,37 @@ impl Core {
3472
1727
  self.scan_start_field.load(Ordering::Relaxed),
3473
1728
  stack_top,
3474
1729
  );
3475
- let reply = std::panic::catch_unwind(AssertUnwindSafe(|| {
1730
+ let mut reply = std::panic::catch_unwind(AssertUnwindSafe(|| {
3476
1731
  v8::scope!(let scope, unsafe { &mut *iso });
3477
1732
  service_request(scope, request, true)
3478
1733
  }))
3479
1734
  .ok();
1735
+ // OOM recovery. The near-heap-limit callback bumped the ceiling and
1736
+ // terminated the op so it could unwind; the scope is closed and JS has
1737
+ // stopped now. Reclaim the runaway allocation (a forced GC) and reset
1738
+ // the ceiling back to memory_limit so the limit keeps protecting later
1739
+ // ops, then relabel the terminate as OutOfMemory. watchdog_fired stays
1740
+ // false for an OOM, so the request's end-sweep left the terminate flag
1741
+ // set — cancel it here.
1742
+ if self.memory_limit > 0 && std::mem::take(&mut istate!(unsafe { &mut *iso }).oom_fired) {
1743
+ let iso_ref = unsafe { &mut *iso };
1744
+ // Reclaim the runaway allocation, then reset the ceiling from the
1745
+ // doubled bump back to memory_limit (V8 clamps it no lower than the
1746
+ // live heap — a genuinely-retained set above the limit necessarily
1747
+ // loosens it, inherent to recovering the isolate rather than
1748
+ // discarding it), and re-arm the callback for the next op.
1749
+ iso_ref.low_memory_notification();
1750
+ iso_ref.remove_near_heap_limit_callback(near_heap_limit_cb, self.memory_limit);
1751
+ iso_ref.add_near_heap_limit_callback(near_heap_limit_cb, iso as *mut c_void);
1752
+ // Clear the terminate the OOM set (the request end-sweep skips it —
1753
+ // that only sweeps watchdog_fired). Do this AFTER the GC: the forced
1754
+ // GC above runs with the callback still armed, so a still-huge live
1755
+ // set can re-fire it mid-GC, re-setting both terminate and oom_fired;
1756
+ // clearing both here keeps either from leaking into the next op.
1757
+ iso_ref.cancel_terminate_execution();
1758
+ istate!(iso_ref).oom_fired = false;
1759
+ reply = reply.map(relabel_oom);
1760
+ }
3480
1761
  unsafe { (*iso).exit() };
3481
1762
  reply
3482
1763
  } else {
@@ -3692,6 +1973,7 @@ impl Core {
3692
1973
  }
3693
1974
 
3694
1975
  // Thin ESM primitives. compile_module returns the new module's id.
1976
+ #[allow(clippy::too_many_arguments)]
3695
1977
  fn compile_module(
3696
1978
  &self,
3697
1979
  ruby: &Ruby,
@@ -3785,6 +2067,7 @@ impl Core {
3785
2067
  }
3786
2068
 
3787
2069
  // Classic script: compile, run, dispose.
2070
+ #[allow(clippy::too_many_arguments)]
3788
2071
  fn compile_script(
3789
2072
  &self,
3790
2073
  ruby: &Ruby,
@@ -3865,11 +2148,7 @@ impl Core {
3865
2148
  fn teardown(&self) {
3866
2149
  // Stop + join the watchdog before we touch the isolate, so its handle
3867
2150
  // can't fire a terminate into an isolate we're mid-disposing.
3868
- {
3869
- let mut inner = self.watchdog.inner.lock().unwrap();
3870
- inner.shutdown = true;
3871
- }
3872
- self.watchdog.cv.notify_one();
2151
+ self.watchdog.request_shutdown();
3873
2152
  if let Some(join) = self.watchdog_join.lock().unwrap().take() {
3874
2153
  let _ = join.join();
3875
2154
  }
@@ -3890,6 +2169,13 @@ impl Core {
3890
2169
  st.scripts = ScriptReg::default();
3891
2170
  st.instantiate_resolve = None;
3892
2171
  }
2172
+ // Unregister the near-heap-limit callback before disposal: dropping the box
2173
+ // runs V8's teardown GC, which could otherwise re-invoke near_heap_limit_cb
2174
+ // (touching the just-reset slot of an isolate being destroyed). The watchdog
2175
+ // is already stopped above; this closes the matching space-axis hole.
2176
+ if self.memory_limit > 0 {
2177
+ unsafe { &mut *self.iso_ptr.0 }.remove_near_heap_limit_callback(near_heap_limit_cb, 0);
2178
+ }
3893
2179
  // Remove (and drop) the OwnedIsolate — V8 disposal runs here — AFTER the
3894
2180
  // watchdog joined and the Globals were cleared, while the isolate is
3895
2181
  // entered (above). Drop outside the lock so V8 teardown can't deadlock on
@@ -3946,11 +2232,7 @@ impl Drop for Core {
3946
2232
  // explicitly on the owner thread before the last wrapper drops avoids
3947
2233
  // this leak; the counter makes it observable (RustyRacer.leaked_isolate_count).
3948
2234
  LEAKED_ISOLATES.fetch_add(1, Ordering::Relaxed);
3949
- {
3950
- let mut inner = self.watchdog.inner.lock().unwrap();
3951
- inner.shutdown = true;
3952
- }
3953
- self.watchdog.cv.notify_one();
2235
+ self.watchdog.request_shutdown();
3954
2236
  }
3955
2237
  }
3956
2238
  }
@@ -4348,6 +2630,10 @@ fn vm_err(ruby: &Ruby, e: VmError) -> Error {
4348
2630
  err_class(ruby, "ScriptTerminatedError"),
4349
2631
  "JavaScript was terminated (timeout or stop)",
4350
2632
  ),
2633
+ VmError::OutOfMemory => Error::new(
2634
+ err_class(ruby, "V8OutOfMemoryError"),
2635
+ "JavaScript exceeded the isolate memory_limit",
2636
+ ),
4351
2637
  }
4352
2638
  }
4353
2639
 
@@ -4418,387 +2704,6 @@ fn resolve_module_via_ruby(
4418
2704
  Ok(Some(obj.module_id))
4419
2705
  }
4420
2706
 
4421
- fn jsval_to_ruby(ruby: &Ruby, val: &JsVal) -> Result<Value, Error> {
4422
- let mut built: HashMap<u32, Value> = HashMap::new();
4423
- jsval_to_ruby_d(ruby, val, &mut built)
4424
- }
4425
-
4426
- // `built` is a HashMap<u32, Value> — the same "bare Values in a heap container,
4427
- // hidden from the GC mark phase" shape that's a use-after-free in call_proc. It
4428
- // is safe HERE only because every entry is, at every allocating safepoint, ALSO
4429
- // reachable from a live stack local: each container arm (Array/Obj/Map/Set)
4430
- // keeps its arr/h/set as a live local while its children recurse and grafts each
4431
- // child into it (push/aset), so the child is marked transitively; Bytes inserts
4432
- // then immediately returns its live local `s`. So `built` never holds the sole
4433
- // reference. This invariant is load-bearing: do NOT refactor an arm to stash a
4434
- // value in `built` without keeping it rooted by a live local until it's grafted.
4435
-
4436
- fn jsval_to_ruby_d(
4437
- ruby: &Ruby,
4438
- val: &JsVal,
4439
- built: &mut HashMap<u32, Value>,
4440
- ) -> Result<Value, Error> {
4441
- Ok(match val {
4442
- JsVal::Undefined | JsVal::Null => ruby.qnil().as_value(),
4443
- JsVal::Bool(b) => (*b).into_value_with(ruby),
4444
- JsVal::Int(i) => (*i).into_value_with(ruby),
4445
- JsVal::Num(n) => (*n).into_value_with(ruby),
4446
- JsVal::Str(s) => s.clone().into_value_with(ruby),
4447
- // Bytes -> a binary (ASCII-8BIT) String: str_from_slice uses rb_str_new,
4448
- // which tags the result ASCII-8BIT — so it round-trips back to bytes.
4449
- // Registered under |id| so an aliased blob stays one String via Ref.
4450
- JsVal::Bytes { id, bytes } => {
4451
- let s = ruby.str_from_slice(bytes).as_value();
4452
- if let Some(id) = id {
4453
- built.insert(*id, s);
4454
- }
4455
- s
4456
- }
4457
- // Reconstruct the Ruby Integer from the hex magnitude (arbitrary
4458
- // precision); negate via Ruby so bignums stay exact.
4459
- JsVal::BigInt { negative, words } => {
4460
- let mag: Value = ruby
4461
- .str_new(&words_to_hex(words))
4462
- .funcall("to_i", (16i64,))?;
4463
- if *negative {
4464
- mag.funcall("-@", ())?
4465
- } else {
4466
- mag
4467
- }
4468
- }
4469
- // Time.at takes seconds; carry sub-second precision as the Float. An
4470
- // invalid Date (value_of NaN) raises RangeError, matching csim's
4471
- // des_date — never a silent nil.
4472
- JsVal::Date(ms) => {
4473
- if !ms.is_finite() {
4474
- return Err(Error::new(ruby.exception_range_error(), "invalid Date"));
4475
- }
4476
- ruby.class_object()
4477
- .const_get::<_, magnus::RClass>("Time")?
4478
- .funcall::<_, _, Value>("at", (*ms / 1000.0,))?
4479
- }
4480
- // Register before filling so a Ref from a descendant resolves to the
4481
- // same Ruby object (shared/cyclic graphs keep their identity).
4482
- JsVal::Array { id, items } => {
4483
- let arr = ruby.ary_new();
4484
- built.insert(*id, arr.as_value());
4485
- for it in items {
4486
- let _ = arr.push(jsval_to_ruby_d(ruby, it, built)?);
4487
- }
4488
- arr.as_value()
4489
- }
4490
- // JS objects -> string-keyed Hashes.
4491
- JsVal::Obj { id, entries } => {
4492
- let h = ruby.hash_new();
4493
- built.insert(*id, h.as_value());
4494
- for (k, it) in entries {
4495
- let _ = h.aset(k.as_str(), jsval_to_ruby_d(ruby, it, built)?);
4496
- }
4497
- h.as_value()
4498
- }
4499
- // JS Map -> Ruby Hash (arbitrary marshalled keys, not just strings).
4500
- JsVal::Map { id, pairs } => {
4501
- let h = ruby.hash_new();
4502
- built.insert(*id, h.as_value());
4503
- for (k, v) in pairs {
4504
- let kk = jsval_to_ruby_d(ruby, k, built)?;
4505
- let vv = jsval_to_ruby_d(ruby, v, built)?;
4506
- let _ = h.aset(kk, vv);
4507
- }
4508
- h.as_value()
4509
- }
4510
- // JS Set -> Ruby Set (stdlib); build empty then add so a cyclic Set
4511
- // (a Set containing itself) resolves through the Ref table.
4512
- JsVal::Set { id, items } => {
4513
- let set: Value = ruby
4514
- .class_object()
4515
- .const_get::<_, magnus::RClass>("Set")?
4516
- .funcall("new", ())?;
4517
- built.insert(*id, set);
4518
- for it in items {
4519
- let v = jsval_to_ruby_d(ruby, it, built)?;
4520
- let _: Value = set.funcall("add", (v,))?;
4521
- }
4522
- set
4523
- }
4524
- JsVal::Ref(id) => built
4525
- .get(id)
4526
- .copied()
4527
- .unwrap_or_else(|| ruby.qnil().as_value()),
4528
- })
4529
- }
4530
-
4531
- // A Ruby String marshalled by its encoding TAG (the tag is the type):
4532
- // - ASCII-8BIT (binary) -> JsVal::Bytes (a JS Uint8Array);
4533
- // - any text encoding -> JsVal::Str (UTF-8). Already-UTF-8 text is taken
4534
- // as-is; other text encodings transcode (Ruby raises on unmappable bytes).
4535
- // Either way the bytes must be VALID UTF-8 — invalid bytes RAISE, never
4536
- // silently degrade to U+FFFD (loud failure beats silent corruption). A
4537
- // text String mis-tagged binary surfaces loudly too (it becomes a Uint8Array).
4538
- fn string_to_jsval(ruby: &Ruby, s: RString) -> Result<JsVal, Error> {
4539
- use magnus::encoding::EncodingCapable;
4540
- if s.enc_get() == ruby.ascii8bit_encindex() {
4541
- // Binary: the bytes ARE the value (O(n) copy, no inflation). id: None —
4542
- // the identity-tracked path is the direct-String branch in
4543
- // ruby_to_jsval_d; a to_str result reaching here is transient.
4544
- return Ok(JsVal::Bytes {
4545
- id: None,
4546
- bytes: unsafe { s.as_slice() }.to_vec(),
4547
- });
4548
- }
4549
- // Text. encode('UTF-8') on an already-UTF-8 source is a no-op that does NOT
4550
- // validate, so skip it (one fewer copy) and let the from_utf8 check below
4551
- // catch invalid bytes; other encodings transcode (raising on unmappable).
4552
- let utf8: RString = if s.enc_get() == ruby.utf8_encindex() {
4553
- s
4554
- } else {
4555
- s.funcall("encode", ("UTF-8",))?
4556
- };
4557
- // Build the Rust String with a real UTF-8 check (not lossy): invalid bytes
4558
- // in a text-tagged String are an error, not silent U+FFFD substitution.
4559
- match String::from_utf8(unsafe { utf8.as_slice() }.to_vec()) {
4560
- Ok(s) => Ok(JsVal::Str(s)),
4561
- Err(_) => Err(Error::new(
4562
- ruby
4563
- .class_object()
4564
- .const_get::<_, ExceptionClass>("EncodingError")
4565
- .unwrap_or_else(|_| ruby.exception_runtime_error()),
4566
- "text-tagged String contains invalid UTF-8 bytes",
4567
- )),
4568
- }
4569
- }
4570
-
4571
- // A JS object key must be a string. A Ruby String key crosses by its bytes as
4572
- // UTF-8 — but unlike a binary VALUE (which becomes a Uint8Array), a key has
4573
- // nowhere to put raw bytes, so invalid UTF-8 RAISES rather than silently
4574
- // degrading to U+FFFD. None for a non-String (the caller then tries to_s).
4575
- fn string_key(ruby: &Ruby, val: Value) -> Option<Result<String, Error>> {
4576
- let s = RString::from_value(val)?;
4577
- let bytes = unsafe { s.as_slice() }.to_vec();
4578
- Some(String::from_utf8(bytes).map_err(|_| {
4579
- Error::new(
4580
- ruby.class_object()
4581
- .const_get::<_, ExceptionClass>("EncodingError")
4582
- .unwrap_or_else(|_| ruby.exception_runtime_error()),
4583
- "hash key is not valid UTF-8",
4584
- )
4585
- }))
4586
- }
4587
-
4588
- // A Ruby String's bytes interpreted as UTF-8 (invalid sequences become U+FFFD),
4589
- // regardless of the encoding tag. Used for the depth-truncation to_s fallback,
4590
- // where the value is already being lossily summarised.
4591
- fn lossy_string(val: Value) -> Option<String> {
4592
- let s = RString::from_value(val)?;
4593
- // Copy the bytes out before any further Ruby call can move/free them.
4594
- let bytes = unsafe { s.as_slice() }.to_vec();
4595
- Some(String::from_utf8_lossy(&bytes).into_owned())
4596
- }
4597
-
4598
- // Tracks Ruby containers already emitted this marshal (by object_id, which is
4599
- // exact — no collision handling needed) so shared/cyclic structures become Refs.
4600
- #[derive(Default)]
4601
- struct RbSeen {
4602
- next_id: u32,
4603
- map: HashMap<usize, u32>,
4604
- }
4605
-
4606
- fn ruby_to_jsval(val: Value) -> Result<JsVal, Error> {
4607
- let mut seen = RbSeen::default();
4608
- ruby_to_jsval_d(val, &mut seen, 0)
4609
- }
4610
-
4611
- fn ruby_to_jsval_d(val: Value, seen: &mut RbSeen, depth: u32) -> Result<JsVal, Error> {
4612
- let ruby = Ruby::get().unwrap();
4613
- if val.is_nil() {
4614
- return Ok(JsVal::Null);
4615
- }
4616
- // NB: bool::try_convert is RTEST (truthiness) — it returns Ok(true) for
4617
- // ANY non-false value — so check the actual true/false singletons by
4618
- // identity instead, or every Integer/String/Array would marshal as `true`.
4619
- if val.eql(ruby.qtrue()).unwrap_or(false) {
4620
- return Ok(JsVal::Bool(true));
4621
- }
4622
- if val.eql(ruby.qfalse()).unwrap_or(false) {
4623
- return Ok(JsVal::Bool(false));
4624
- }
4625
- // Ruby Time -> JS Date. Must precede the numeric checks: magnus's
4626
- // i64/f64 TryConvert coerces a Time via to_i/to_f, so it would otherwise
4627
- // marshal as a bare epoch number. Time#to_f is epoch seconds; Date wants ms.
4628
- if let Ok(time_class) = ruby.class_object().const_get::<_, magnus::RClass>("Time") {
4629
- if val.is_kind_of(time_class) {
4630
- let sec = val.funcall::<_, _, f64>("to_f", ())?;
4631
- return Ok(JsVal::Date(sec * 1000.0));
4632
- }
4633
- }
4634
- // Integer. A JS Number is an f64, so only integers exactly representable
4635
- // there (|n| <= 2^53) become Int/Number; anything larger (the rest of the
4636
- // i64 range AND true bignums) becomes a BigInt so no precision is lost.
4637
- // Use a strict Integer type check, NOT magnus::Integer::try_convert, which
4638
- // coerces a Float / to_int object — that would turn e.g. 1e300 into a BigInt
4639
- // instead of a Number.
4640
- if let Ok(int_class) = ruby.class_object().const_get::<_, magnus::RClass>("Integer") {
4641
- if val.is_kind_of(int_class) {
4642
- if let Ok(i) = i64::try_convert(val) {
4643
- if i.unsigned_abs() <= (1u64 << 53) {
4644
- return Ok(JsVal::Int(i));
4645
- }
4646
- }
4647
- let abs: Value = val.funcall("abs", ())?;
4648
- let hex: String = abs.funcall("to_s", (16i64,))?;
4649
- let negative = val.funcall::<_, _, bool>("negative?", ())?;
4650
- return Ok(JsVal::BigInt {
4651
- negative,
4652
- words: hex_to_words(&hex),
4653
- });
4654
- }
4655
- }
4656
- if let Ok(n) = f64::try_convert(val) {
4657
- return Ok(JsVal::Num(n));
4658
- }
4659
- // Bare Symbol -> JS string (one-way: it comes back as a Ruby String). A
4660
- // binary-encoded symbol surfaces the same curated EncodingError as a text
4661
- // String with invalid UTF-8, not magnus's raw "expected utf-8" message.
4662
- if let Some(sym) = magnus::Symbol::from_value(val) {
4663
- let name = sym.name().map_err(|_| {
4664
- Error::new(
4665
- ruby.class_object()
4666
- .const_get::<_, ExceptionClass>("EncodingError")
4667
- .unwrap_or_else(|_| ruby.exception_runtime_error()),
4668
- "symbol name is not valid UTF-8",
4669
- )
4670
- })?;
4671
- return Ok(JsVal::Str(name.into_owned()));
4672
- }
4673
- // Real Strings: the encoding tag is the type declaration. A binary
4674
- // (ASCII-8BIT) String -> bytes (JS Uint8Array), identity-tracked so an
4675
- // aliased blob stays one Uint8Array; any text encoding -> a JS string.
4676
- if let Some(rstr) = RString::from_value(val) {
4677
- use magnus::encoding::EncodingCapable;
4678
- if rstr.enc_get() == ruby.ascii8bit_encindex() {
4679
- // depth 0 — a binary blob is a leaf, so it stays faithful bytes even
4680
- // when deeply nested (never the depth-truncation-to-lossy-string);
4681
- // only the identity (Ref) check applies. Frozen/interned binary
4682
- // Strings share an object_id, so two `-"x".b` literals deliberately
4683
- // collapse to ONE Uint8Array (they ARE the same Ruby object).
4684
- let id = match rb_container_id(seen, val, 0)? {
4685
- RbId::New(id) => id,
4686
- RbId::Reuse(jv) => return Ok(jv),
4687
- };
4688
- return Ok(JsVal::Bytes {
4689
- id: Some(id),
4690
- bytes: unsafe { rstr.as_slice() }.to_vec(),
4691
- });
4692
- }
4693
- return string_to_jsval(&ruby, rstr);
4694
- }
4695
- // A String-like (to_str) gets the same tag-driven treatment, but its result
4696
- // is transient so it is not identity-tracked.
4697
- if val.respond_to("to_str", false).unwrap_or(false) {
4698
- let s: Value = val.funcall("to_str", ())?;
4699
- if let Some(rstr) = RString::from_value(s) {
4700
- return string_to_jsval(&ruby, rstr);
4701
- }
4702
- }
4703
- // Ruby Set -> JS Set. Before the Array/Hash checks (a Set is neither).
4704
- if let Ok(set_class) = ruby.class_object().const_get::<_, magnus::RClass>("Set") {
4705
- if val.is_kind_of(set_class) {
4706
- let id = match rb_container_id(seen, val, depth)? {
4707
- RbId::New(id) => id,
4708
- RbId::Reuse(jv) => return Ok(jv),
4709
- };
4710
- let arr: RArray = val.funcall("to_a", ())?;
4711
- let mut items = Vec::with_capacity(arr.len());
4712
- for i in 0..arr.len() {
4713
- let el: Value = arr.entry::<Value>(i as isize)?;
4714
- items.push(ruby_to_jsval_d(el, seen, depth + 1)?);
4715
- }
4716
- return Ok(JsVal::Set { id, items });
4717
- }
4718
- }
4719
- if let Ok(arr) = RArray::try_convert(val) {
4720
- let id = match rb_container_id(seen, val, depth)? {
4721
- RbId::New(id) => id,
4722
- RbId::Reuse(jv) => return Ok(jv),
4723
- };
4724
- let mut items = Vec::with_capacity(arr.len());
4725
- for i in 0..arr.len() {
4726
- let el: Value = arr.entry::<Value>(i as isize)?;
4727
- items.push(ruby_to_jsval_d(el, seen, depth + 1)?);
4728
- }
4729
- return Ok(JsVal::Array { id, items });
4730
- }
4731
- if let Ok(hash) = RHash::try_convert(val) {
4732
- let id = match rb_container_id(seen, val, depth)? {
4733
- RbId::New(id) => id,
4734
- RbId::Reuse(jv) => return Ok(jv),
4735
- };
4736
- let entries = RefCell::new(Vec::new());
4737
- hash.foreach(|k: Value, v: Value| {
4738
- // String/Symbol keys -> a UTF-8 String; anything else via to_s. A JS
4739
- // object key has nowhere to put raw bytes, so unlike a binary VALUE
4740
- // (-> Uint8Array) a binary KEY with invalid UTF-8 RAISES (string_key),
4741
- // and a to_s returning a non-String is a loud error, not a silent "".
4742
- let key = match string_key(&ruby, k) {
4743
- Some(r) => r?,
4744
- None => {
4745
- // A non-String key (Symbol, Integer, ...) -> to_s, then the
4746
- // same UTF-8 rule.
4747
- let s: Value = k.funcall("to_s", ())?;
4748
- match string_key(&ruby, s) {
4749
- Some(r) => r?,
4750
- None => {
4751
- return Err(Error::new(
4752
- ruby.exception_type_error(),
4753
- "hash key's to_s did not return a String",
4754
- ))
4755
- }
4756
- }
4757
- }
4758
- };
4759
- entries
4760
- .borrow_mut()
4761
- .push((key, ruby_to_jsval_d(v, seen, depth + 1)?));
4762
- Ok(magnus::r_hash::ForEach::Continue)
4763
- })?;
4764
- return Ok(JsVal::Obj {
4765
- id,
4766
- entries: entries.into_inner(),
4767
- });
4768
- }
4769
- Err(Error::new(
4770
- ruby.exception_type_error(),
4771
- "unsupported type crossing into JS",
4772
- ))
4773
- }
4774
-
4775
- enum RbId {
4776
- New(u32),
4777
- Reuse(JsVal),
4778
- }
4779
-
4780
- // Ruby-side mirror of js_container_id: New(id) to register and recurse, or
4781
- // Reuse(jsval) to emit directly (a Ref to an already-seen object, or a
4782
- // depth-truncated Str). Computes object_id once.
4783
- fn rb_container_id(seen: &mut RbSeen, val: Value, depth: u32) -> Result<RbId, Error> {
4784
- let oid = val.funcall::<_, _, usize>("object_id", ())?;
4785
- if let Some(id) = seen.map.get(&oid) {
4786
- return Ok(RbId::Reuse(JsVal::Ref(*id)));
4787
- }
4788
- if depth >= MAX_MARSHAL_DEPTH {
4789
- let ruby = Ruby::get().unwrap();
4790
- let s: Value = val.funcall("to_s", ())?;
4791
- let s = lossy_string(s).ok_or_else(|| {
4792
- Error::new(ruby.exception_type_error(), "to_s did not return a String")
4793
- })?;
4794
- return Ok(RbId::Reuse(JsVal::Str(s)));
4795
- }
4796
- let id = seen.next_id;
4797
- seen.next_id += 1;
4798
- seen.map.insert(oid, id);
4799
- Ok(RbId::New(id))
4800
- }
4801
-
4802
2707
  #[magnus::init]
4803
2708
  fn init(ruby: &Ruby) -> Result<(), Error> {
4804
2709
  let module = ruby.define_module("RustyRacer")?;
@@ -4806,7 +2711,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
4806
2711
  // The isolate (VM) + its isolate-level ops; hands out Contexts.
4807
2712
  let isolate = module.define_class("Isolate", ruby.class_object())?;
4808
2713
  // keyword-arg wrapper Isolate.new(snapshot:, ...) lives in lib/rusty_racer.rb
4809
- isolate.define_singleton_method("_new", function!(Isolate::new, 4))?;
2714
+ isolate.define_singleton_method("_new", function!(Isolate::new, 5))?;
4810
2715
  isolate.define_method("context", method!(Isolate::context, 0))?;
4811
2716
  isolate.define_method("create_context", method!(Isolate::create_context, 0))?;
4812
2717
  isolate.define_method("terminate", method!(Isolate::terminate, 0))?;