rusty_racer 0.1.2 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b0b742605170382bce639eaa6c8acd1917f6ed4e6f8a5d4f83d40701ffb14b25
4
- data.tar.gz: ce873858c2a28e9903ca9abec1cc72e061f3561f1cf2c8d5b0ff3694d613ec55
3
+ metadata.gz: 746bc2e7e881d601b38f6052fb45f4f87d72d4580a46ea738e2c716af6641f82
4
+ data.tar.gz: 4fcc456589eabd593e506d7c941b7fe95635400225aac9807908a008f63e73ad
5
5
  SHA512:
6
- metadata.gz: 1e3960b00b14c62813d731ee85c8f41cca8f04a0ff7657446f205dda2f24c5e11fd07b00da000b1d7be503bb00f083cf389f3736fac8d6344343a6ec26a3a11e
7
- data.tar.gz: 3ee0d6217b064b70362a7e2475e5db840a0b613922f91a418d3548464066ea1824d1c073870e5d9d6da4cdc351fa481e490d667520123675f2b7d730a926d23e
6
+ metadata.gz: 20aeb8c39cc3f321b5015b78ff2c4ae54553babd54735bbb256aeaea16c2428cccc03141a55c179ebad34822243d2d39508517df33f34d6236a7a368cbe274b5
7
+ data.tar.gz: 5ff495f94287dfd8464855d5e20c84c4743b91a3d320760decf17244dad5efa67c503b74dd2994792059e0f7236b10992157bfd5c4cb5681b02fe162b47a8a4d
data/README.md CHANGED
@@ -21,6 +21,9 @@ Embed [V8](https://v8.dev/) in Ruby, built on [rusty_v8](https://crates.io/crate
21
21
  - **Drop-in [ExecJS](#execjs) runtime** — any ExecJS consumer switches with no
22
22
  code change.
23
23
  - **Snapshots, realms (`Context`s), host callbacks, and a bytecode cache.**
24
+ - **Resource limits on both axes** — a `timeout_ms` (time) and a `memory_limit`
25
+ (space), each catchable: a runaway script fails just its own `eval`, leaving
26
+ the isolate usable, instead of aborting the process.
24
27
  - **Precompiled gems** bundle V8 for Linux/macOS × Ruby 3.3–4.0 — no V8 build,
25
28
  no Rust toolchain.
26
29
 
@@ -33,9 +36,10 @@ dynamic import** (mini_racer is eval/classic-script oriented); **richer
33
36
  marshalling** (the types above round-trip natively instead of through a
34
37
  JSON-shaped projection); and **in-thread execution** with no per-op thread hop,
35
38
  which is faster for overhead-dominated workloads (lots of tiny `eval`/`call`) and
36
- at parity once the per-op JS work dominates. It is also younger and
37
- **experimental**fewer miles, no Windows yet, no per-isolate memory cap. Parity
38
- with mini_racer is not a goal; the overlap is convergent evolution, not a port.
39
+ at parity once the per-op JS work dominates. Both axes of resource limiting are
40
+ covereda `timeout_ms` (time) and a `memory_limit` (space), each catchable. It
41
+ is also younger and **experimental** fewer miles, no Windows yet. Parity with
42
+ mini_racer is not a goal; the overlap is convergent evolution, not a port.
39
43
 
40
44
  ## What it can do
41
45
 
@@ -83,6 +87,25 @@ app.namespace["r"] # => 42
83
87
 
84
88
  Classic `<script>`s work the same way: `ctx.compile("1 + 1").run` # => 2.
85
89
 
90
+ ### Resource limits
91
+
92
+ An isolate can cap untrusted code on both axes. Each limit terminates the
93
+ offending op and raises a catchable error — the isolate stays usable afterward,
94
+ so one runaway script fails just its own `eval`, not the whole process.
95
+
96
+ ```ruby
97
+ # Time: timeout_ms caps each eval/call (per-call override on Context#eval).
98
+ iso = RustyRacer::Isolate.new(timeout_ms: 1000)
99
+ iso.context.eval("for (;;) {}") # raises RustyRacer::ScriptTerminatedError
100
+
101
+ # Space: memory_limit caps the V8 heap in bytes (a soft limit, enforced at GC
102
+ # granularity). The isolate forces a GC and resets the ceiling on recovery.
103
+ iso = RustyRacer::Isolate.new(memory_limit: 64 * 1024 * 1024)
104
+ iso.context.eval("a = []; for (;;) a.push(new Array(1e6))")
105
+ # raises RustyRacer::V8OutOfMemoryError
106
+ iso.context.eval("1 + 1") # => 2 (still usable)
107
+ ```
108
+
86
109
  ### Bytecode caching
87
110
 
88
111
  V8 compiles lazily: the top level up front, each function body on first call.
@@ -4,7 +4,7 @@
4
4
  # libv8-rusty needed under the cibuildgem native-per-platform model).
5
5
  [package]
6
6
  name = "rusty_racer"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  edition = "2021"
9
9
  publish = false
10
10
 
@@ -186,7 +186,52 @@ enum VmError {
186
186
  message: String,
187
187
  backtrace: Vec<String>,
188
188
  },
189
- Terminated, // watchdog/stop -> RustyRacer::ScriptTerminatedError
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
+ }
190
235
  }
191
236
 
192
237
  // ---------------------------------------------------------------------------
@@ -522,6 +567,12 @@ struct IsolateState {
522
567
  instantiate_resolve: Option<RootedProc>,
523
568
  instantiate_resolve_err: Option<BoxValue<Exception>>,
524
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,
525
576
  }
526
577
 
527
578
  impl IsolateState {
@@ -543,6 +594,7 @@ impl IsolateState {
543
594
  instantiate_resolve: None,
544
595
  instantiate_resolve_err: None,
545
596
  watchdog: WatchdogShared::new(),
597
+ oom_fired: false,
546
598
  }
547
599
  }
548
600
  }
@@ -1033,6 +1085,11 @@ struct Core {
1033
1085
  // Default per-eval/call timeout (ms); 0 = none. eval(timeout_ms:)'s explicit
1034
1086
  // value overrides it. Guards against an in-V8 infinite loop without a watchdog.
1035
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,
1036
1093
  // Set by Context#dynamic_import_resolver=; called for a JS import() to map
1037
1094
  // (specifier, referrer) to an already-loaded Module. GC-rooted like procs.
1038
1095
  dynamic_import_resolver: Mutex<Option<RootedProc>>,
@@ -1457,6 +1514,9 @@ fn build_snapshot(code: &str, base: Option<Vec<u8>>, warmup: bool) -> Result<Vec
1457
1514
  VmError::Parse(m) | VmError::Runtime(m) => m,
1458
1515
  VmError::JsError { message, .. } => message,
1459
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(),
1460
1520
  });
1461
1521
  }
1462
1522
  }
@@ -1492,16 +1552,22 @@ impl Isolate {
1492
1552
  host_namespace: Option<String>,
1493
1553
  snapshot: Option<magnus::typed_data::Obj<Snapshot>>,
1494
1554
  timeout_ms: u64,
1555
+ memory_limit: usize,
1495
1556
  explicit_microtasks: bool,
1496
1557
  ) -> Result<Self, Error> {
1497
1558
  init_v8();
1498
1559
  // A snapshot blob bakes globalThis state in: the first Context::new (in
1499
1560
  // new_realm below) deserializes that default context for free.
1500
1561
  let snapshot_bytes = snapshot.map(|s| s.blob.borrow().clone());
1501
- let create_params = match snapshot_bytes {
1562
+ let mut create_params = match snapshot_bytes {
1502
1563
  Some(bytes) => v8::CreateParams::default().snapshot_blob(v8::StartupData::from(bytes)),
1503
1564
  None => Default::default(),
1504
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
+ }
1505
1571
  let mut isolate = v8::Isolate::new(create_params);
1506
1572
  // Always Explicit at the V8 level; the binding performs the kAuto
1507
1573
  // end-of-script drain itself (auto_drain) so it stays inside the
@@ -1541,6 +1607,12 @@ impl Isolate {
1541
1607
  // registry moves only the 8-byte pointer; the boxed OwnedIsolate stays put.
1542
1608
  let mut boxed = Box::new(isolate);
1543
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
+ }
1544
1616
  let iso_id = NEXT_ISOLATE_ID.fetch_add(1, Ordering::SeqCst);
1545
1617
  isolates().lock().unwrap().insert(iso_id, SendIso(boxed));
1546
1618
  // Root the owner Thread VALUE so its address can't be reused while this
@@ -1559,6 +1631,7 @@ impl Isolate {
1559
1631
  depth: std::sync::atomic::AtomicU32::new(0),
1560
1632
  procs: Mutex::new(ProcTable::default()),
1561
1633
  default_timeout_ms: timeout_ms,
1634
+ memory_limit,
1562
1635
  dynamic_import_resolver: Mutex::new(None),
1563
1636
  watchdog,
1564
1637
  watchdog_join: Mutex::new(Some(watchdog_join)),
@@ -1654,11 +1727,37 @@ impl Core {
1654
1727
  self.scan_start_field.load(Ordering::Relaxed),
1655
1728
  stack_top,
1656
1729
  );
1657
- let reply = std::panic::catch_unwind(AssertUnwindSafe(|| {
1730
+ let mut reply = std::panic::catch_unwind(AssertUnwindSafe(|| {
1658
1731
  v8::scope!(let scope, unsafe { &mut *iso });
1659
1732
  service_request(scope, request, true)
1660
1733
  }))
1661
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
+ }
1662
1761
  unsafe { (*iso).exit() };
1663
1762
  reply
1664
1763
  } else {
@@ -2070,6 +2169,13 @@ impl Core {
2070
2169
  st.scripts = ScriptReg::default();
2071
2170
  st.instantiate_resolve = None;
2072
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
+ }
2073
2179
  // Remove (and drop) the OwnedIsolate — V8 disposal runs here — AFTER the
2074
2180
  // watchdog joined and the Globals were cleared, while the isolate is
2075
2181
  // entered (above). Drop outside the lock so V8 teardown can't deadlock on
@@ -2524,6 +2630,10 @@ fn vm_err(ruby: &Ruby, e: VmError) -> Error {
2524
2630
  err_class(ruby, "ScriptTerminatedError"),
2525
2631
  "JavaScript was terminated (timeout or stop)",
2526
2632
  ),
2633
+ VmError::OutOfMemory => Error::new(
2634
+ err_class(ruby, "V8OutOfMemoryError"),
2635
+ "JavaScript exceeded the isolate memory_limit",
2636
+ ),
2527
2637
  }
2528
2638
  }
2529
2639
 
@@ -2601,7 +2711,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
2601
2711
  // The isolate (VM) + its isolate-level ops; hands out Contexts.
2602
2712
  let isolate = module.define_class("Isolate", ruby.class_object())?;
2603
2713
  // keyword-arg wrapper Isolate.new(snapshot:, ...) lives in lib/rusty_racer.rb
2604
- isolate.define_singleton_method("_new", function!(Isolate::new, 4))?;
2714
+ isolate.define_singleton_method("_new", function!(Isolate::new, 5))?;
2605
2715
  isolate.define_method("context", method!(Isolate::context, 0))?;
2606
2716
  isolate.define_method("create_context", method!(Isolate::create_context, 0))?;
2607
2717
  isolate.define_method("terminate", method!(Isolate::terminate, 0))?;
@@ -185,6 +185,7 @@ pub(crate) fn run_js_bracketed(
185
185
  && ran_js
186
186
  && !fired
187
187
  && matches!(outcome, Err(VmError::Terminated))
188
+ && !istate!(scope).oom_fired
188
189
  {
189
190
  report_watchdog_anomaly(scope, label, watchdog, timeout_ms, started.elapsed());
190
191
  }
@@ -193,6 +194,15 @@ pub(crate) fn run_js_bracketed(
193
194
  if ran_js {
194
195
  outcome = Err(VmError::Terminated);
195
196
  }
197
+ } else if ran_js && istate!(scope).oom_fired {
198
+ // The memory_limit callback fired TerminateExecution during this op. body
199
+ // may not have noticed it — a microtask/TLA drain that was interrupted
200
+ // leaves a pending promise and returns Ok (auto_drain just stops at the
201
+ // terminate) — so force the terminated outcome rather than let an OOM
202
+ // surface as a bogus success. Core::run relabels it to OutOfMemory and
203
+ // recovers the heap. (No watchdog_fired here: the OOM terminate is swept by
204
+ // Core::run's recovery, not the request end-sweep.)
205
+ outcome = Err(VmError::Terminated);
196
206
  }
197
207
  outcome
198
208
  }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RustyRacer
4
- VERSION = "0.1.2"
4
+ VERSION = "0.1.3"
5
5
  end
data/lib/rusty_racer.rb CHANGED
@@ -27,6 +27,10 @@ module RustyRacer
27
27
  class ParseError < EvalError; end
28
28
  class RuntimeError < EvalError; end
29
29
  class ScriptTerminatedError < EvalError; end
30
+ # Raised when JS allocation exceeds the isolate's memory_limit. Catchable like
31
+ # any eval error — a runaway script fails its own eval instead of aborting the
32
+ # process. The space-axis twin of ScriptTerminatedError (the time axis).
33
+ class V8OutOfMemoryError < EvalError; end
30
34
  class SnapshotError < Error; end
31
35
  class PlatformAlreadyInitialized < Error; end
32
36
 
@@ -41,16 +45,22 @@ module RustyRacer
41
45
  # Keyword-arg constructor over the positional Rust primitive. A snapshot
42
46
  # (RustyRacer::Snapshot) boots the isolate with its baked-in state;
43
47
  # timeout_ms caps each eval/call (0 = no limit) against in-V8 infinite
44
- # loops. microtasks mirrors V8's kAuto/kExplicit: :auto (default) drains
48
+ # loops. memory_limit caps the V8 heap in bytes (0 = no limit): a script
49
+ # that exceeds it is terminated and raises V8OutOfMemoryError rather than
50
+ # aborting the process, and the isolate stays usable afterward. It is a soft
51
+ # limit — V8 enforces it at GC granularity, so usage may briefly overshoot,
52
+ # and it must comfortably exceed the isolate's baseline (and any snapshot's
53
+ # baked-in heap), since the limit is only armed once the isolate has booted.
54
+ # microtasks mirrors V8's kAuto/kExplicit: :auto (default) drains
45
55
  # the microtask queue when the outermost eval/call/run/evaluate completes
46
56
  # (the standard embedder contract); :explicit drains only on
47
57
  # #perform_microtask_checkpoint.
48
- def self.new(host_namespace: nil, snapshot: nil, timeout_ms: 0, microtasks: :auto)
58
+ def self.new(host_namespace: nil, snapshot: nil, timeout_ms: 0, memory_limit: 0, microtasks: :auto)
49
59
  unless %i[auto explicit].include?(microtasks)
50
60
  raise ArgumentError, "microtasks must be :auto or :explicit, got #{microtasks.inspect}"
51
61
  end
52
62
 
53
- _new(host_namespace, snapshot, timeout_ms, microtasks == :explicit)
63
+ _new(host_namespace, snapshot, timeout_ms, memory_limit, microtasks == :explicit)
54
64
  end
55
65
 
56
66
  # ->(specifier, referrer_url, context) { Module } for JS import(). |context|
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rusty_racer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keita Urashima
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-13 00:00:00.000000000 Z
11
+ date: 2026-06-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys