rusty_racer 0.1.3 → 0.1.4

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: 746bc2e7e881d601b38f6052fb45f4f87d72d4580a46ea738e2c716af6641f82
4
- data.tar.gz: 4fcc456589eabd593e506d7c941b7fe95635400225aac9807908a008f63e73ad
3
+ metadata.gz: 329c875cb612dccca54a8c70302d26ad0a38b8a1821733aa6eac9c4795e77d8b
4
+ data.tar.gz: 4d2665db69e108f6ef37fa9d4f0ae4bc3224ab1756e911abec76cfad558f8bd4
5
5
  SHA512:
6
- metadata.gz: 20aeb8c39cc3f321b5015b78ff2c4ae54553babd54735bbb256aeaea16c2428cccc03141a55c179ebad34822243d2d39508517df33f34d6236a7a368cbe274b5
7
- data.tar.gz: 5ff495f94287dfd8464855d5e20c84c4743b91a3d320760decf17244dad5efa67c503b74dd2994792059e0f7236b10992157bfd5c4cb5681b02fe162b47a8a4d
6
+ metadata.gz: f95ae2f448af2ade88af369bea7c0b41c500e4028aeed04e34574e148c7d5f460ac068804bc49b409ab55ad352d063fcb0269064b27b1cf3a2a0453a8e245e2f
7
+ data.tar.gz: 4b853defb2dbde4c0455b9eefb5b6ba9d40002e94953f5d1540c4035087e8128c6b6df843463429b0e5cbcb404a0d1a1c625d13a24fee301a300eaf50dc9ad84
data/README.md CHANGED
@@ -23,7 +23,9 @@ Embed [V8](https://v8.dev/) in Ruby, built on [rusty_v8](https://crates.io/crate
23
23
  - **Snapshots, realms (`Context`s), host callbacks, and a bytecode cache.**
24
24
  - **Resource limits on both axes** — a `timeout_ms` (time) and a `memory_limit`
25
25
  (space), each catchable: a runaway script fails just its own `eval`, leaving
26
- the isolate usable, instead of aborting the process.
26
+ the isolate usable, instead of aborting the process. A heap runaway is caught
27
+ even with no explicit `memory_limit` — V8's default ceiling raises instead of
28
+ aborting.
27
29
  - **Precompiled gems** bundle V8 for Linux/macOS × Ruby 3.3–4.0 — no V8 build,
28
30
  no Rust toolchain.
29
31
 
@@ -106,6 +108,13 @@ iso.context.eval("a = []; for (;;) a.push(new Array(1e6))")
106
108
  iso.context.eval("1 + 1") # => 2 (still usable)
107
109
  ```
108
110
 
111
+ Even without an explicit `memory_limit`, a heap runaway raises
112
+ `V8OutOfMemoryError` against V8's own default ceiling (~2 GB on 64-bit) rather
113
+ than aborting the process — pass `memory_limit:` for a tighter bound. (One
114
+ caveat: if the process's available memory — e.g. a container cgroup limit — sits
115
+ below the active ceiling, the OS may kill the process before V8's callback
116
+ fires; set an explicit `memory_limit` under that bound to keep it catchable.)
117
+
109
118
  ### Bytecode caching
110
119
 
111
120
  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.3"
7
+ version = "0.1.4"
8
8
  edition = "2021"
9
9
  publish = false
10
10
 
@@ -190,25 +190,36 @@ enum VmError {
190
190
  OutOfMemory, // memory_limit hit -> RustyRacer::V8OutOfMemoryError
191
191
  }
192
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 {
193
+ // V8's near-heap-limit callback (registered on EVERY isolate). V8 calls this,
194
+ // synchronously on the owner thread, when a GC still leaves the heap about to
195
+ // exceed the current ceiling — i.e. the script is running away on memory. That
196
+ // ceiling is the configured memory_limit when one was set, otherwise V8's own
197
+ // platform-derived default (the default-protection path), so a runaway raises a
198
+ // catchable error either way instead of aborting the process. `data` is the
199
+ // isolate ptr we registered (Core.iso_ptr). We flag the isolate and terminate the
200
+ // running JS so it unwinds with that catchable error. The return value becomes
201
+ // V8's new ceiling: hand it a DOUBLED limit so the unwind itself (and any pending
202
+ // finalizers) has room to allocate without tripping a hard OOM abort mid-unwind.
203
+ // Core::run, once the op has unwound, forces a GC to reclaim and resets the
204
+ // ceiling see the OOM recovery there. The bump is a no-op-after-the-fact:
205
+ // doubling here, GC + reset after, so the limit keeps protecting later ops.
206
+ unsafe extern "C" fn near_heap_limit_cb(data: *mut c_void, current_heap_limit: usize, initial: usize) -> usize {
205
207
  let isolate = unsafe { &mut *(data as *mut v8::Isolate) };
206
208
  // get_slot_mut (not istate!): this runs as an extern "C" callback from V8's
207
209
  // C++ allocator, where a panic would unwind across the FFI boundary. The slot
208
210
  // is always present once an op can run (set in Isolate::new before any JS), but
209
211
  // 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
+ // The closure releases the &mut borrow before terminate_execution (&self).
213
+ // Also record V8's `initial` ceiling so recovery can restore it when no
214
+ // explicit memory_limit was set (see oom_initial_limit).
215
+ let flagged = isolate
216
+ .get_slot_mut::<IsolateState>()
217
+ .map(|s| {
218
+ s.oom_fired = true;
219
+ s.oom_initial_limit = initial;
220
+ })
221
+ .is_some();
222
+ if flagged {
212
223
  isolate.terminate_execution();
213
224
  }
214
225
  current_heap_limit.saturating_mul(2)
@@ -567,12 +578,18 @@ struct IsolateState {
567
578
  instantiate_resolve: Option<RootedProc>,
568
579
  instantiate_resolve_err: Option<BoxValue<Exception>>,
569
580
  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).
581
+ // Set by near_heap_limit_cb when the heap ceiling is hit: it terminates the
582
+ // running JS, and Core::run reads this after the op to relabel the terminate as
583
+ // OutOfMemory and recover the heap (GC + reset the ceiling).
573
584
  // Plain bool (no atomic): the callback fires synchronously on the owner thread,
574
585
  // never concurrently with the bracket that reads it.
575
586
  oom_fired: bool,
587
+ // V8's original heap ceiling, captured from the callback's `initial` argument
588
+ // when it fires. Recovery resets the ceiling to memory_limit when one was set,
589
+ // but with no explicit limit (the default-protection path) the ceiling IS V8's
590
+ // platform-derived default, whose value we don't otherwise know — so we restore
591
+ // to this captured initial instead. 0 until the callback has fired at least once.
592
+ oom_initial_limit: usize,
576
593
  }
577
594
 
578
595
  impl IsolateState {
@@ -595,6 +612,7 @@ impl IsolateState {
595
612
  instantiate_resolve_err: None,
596
613
  watchdog: WatchdogShared::new(),
597
614
  oom_fired: false,
615
+ oom_initial_limit: 0,
598
616
  }
599
617
  }
600
618
  }
@@ -1085,10 +1103,12 @@ struct Core {
1085
1103
  // Default per-eval/call timeout (ms); 0 = none. eval(timeout_ms:)'s explicit
1086
1104
  // value overrides it. Guards against an in-V8 infinite loop without a watchdog.
1087
1105
  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.
1106
+ // Per-isolate heap ceiling (bytes); 0 = V8's default ceiling. When set, the
1107
+ // isolate is created with this as V8's max heap; near_heap_limit_cb is registered
1108
+ // either way (against this ceiling when set, V8's default otherwise), so a runaway
1109
+ // is always catchable rather than a process abort. Core::run's OOM recovery resets
1110
+ // the ceiling after each OOM (to this when set, else V8's captured default — see
1111
+ // oom_initial_limit). Space-axis twin of default_timeout_ms.
1092
1112
  memory_limit: usize,
1093
1113
  // Set by Context#dynamic_import_resolver=; called for a JS import() to map
1094
1114
  // (specifier, referrer) to an already-loaded Module. GC-rooted like procs.
@@ -1514,8 +1534,8 @@ fn build_snapshot(code: &str, base: Option<Vec<u8>>, warmup: bool) -> Result<Vec
1514
1534
  VmError::Parse(m) | VmError::Runtime(m) => m,
1515
1535
  VmError::JsError { message, .. } => message,
1516
1536
  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.
1537
+ // Unreachable: the snapshot-creator is a separate isolate
1538
+ // that never registers near_heap_limit_cb.
1519
1539
  VmError::OutOfMemory => "snapshot code ran out of memory".to_string(),
1520
1540
  });
1521
1541
  }
@@ -1565,6 +1585,8 @@ impl Isolate {
1565
1585
  };
1566
1586
  // Cap V8's heap at the configured limit so its near-heap-limit callback
1567
1587
  // fires as the script approaches it (initial 0 = V8's default initial heap).
1588
+ // With no explicit limit the callback is still registered below, against
1589
+ // V8's own default ceiling, so a runaway raises instead of aborting.
1568
1590
  if memory_limit > 0 {
1569
1591
  create_params = create_params.heap_limits(0, memory_limit);
1570
1592
  }
@@ -1607,12 +1629,12 @@ impl Isolate {
1607
1629
  // registry moves only the 8-byte pointer; the boxed OwnedIsolate stays put.
1608
1630
  let mut boxed = Box::new(isolate);
1609
1631
  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
- }
1632
+ // Arm the heap-limit callback now that iso_ptr is stable: the callback's
1633
+ // data IS this ptr (it reads the slot's oom_fired and terminates through
1634
+ // it), and Core::run resets the ceiling through the same ptr on recovery.
1635
+ // Registered unconditionally — with memory_limit it guards that ceiling,
1636
+ // without one it guards V8's default ceiling (catchable, not a process abort).
1637
+ boxed.add_near_heap_limit_callback(near_heap_limit_cb, iso_ptr.0 as *mut c_void);
1616
1638
  let iso_id = NEXT_ISOLATE_ID.fetch_add(1, Ordering::SeqCst);
1617
1639
  isolates().lock().unwrap().insert(iso_id, SendIso(boxed));
1618
1640
  // Root the owner Thread VALUE so its address can't be reused while this
@@ -1735,19 +1757,26 @@ impl Core {
1735
1757
  // OOM recovery. The near-heap-limit callback bumped the ceiling and
1736
1758
  // terminated the op so it could unwind; the scope is closed and JS has
1737
1759
  // 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) {
1760
+ // the ceiling so the limit keeps protecting later ops, then relabel the
1761
+ // terminate as OutOfMemory. watchdog_fired stays false for an OOM, so
1762
+ // the request's end-sweep left the terminate flag set — cancel it here.
1763
+ if std::mem::take(&mut istate!(unsafe { &mut *iso }).oom_fired) {
1743
1764
  let iso_ref = unsafe { &mut *iso };
1765
+ // The ceiling to restore: the configured memory_limit when one was
1766
+ // set, otherwise V8's default ceiling, which the callback captured
1767
+ // in oom_initial_limit (we don't otherwise know its value).
1768
+ let restore_to = if self.memory_limit > 0 {
1769
+ self.memory_limit
1770
+ } else {
1771
+ istate!(iso_ref).oom_initial_limit
1772
+ };
1744
1773
  // Reclaim the runaway allocation, then reset the ceiling from the
1745
- // doubled bump back to memory_limit (V8 clamps it no lower than the
1774
+ // doubled bump back to restore_to (V8 clamps it no lower than the
1746
1775
  // live heap — a genuinely-retained set above the limit necessarily
1747
1776
  // loosens it, inherent to recovering the isolate rather than
1748
1777
  // discarding it), and re-arm the callback for the next op.
1749
1778
  iso_ref.low_memory_notification();
1750
- iso_ref.remove_near_heap_limit_callback(near_heap_limit_cb, self.memory_limit);
1779
+ iso_ref.remove_near_heap_limit_callback(near_heap_limit_cb, restore_to);
1751
1780
  iso_ref.add_near_heap_limit_callback(near_heap_limit_cb, iso as *mut c_void);
1752
1781
  // Clear the terminate the OOM set (the request end-sweep skips it —
1753
1782
  // that only sweeps watchdog_fired). Do this AFTER the GC: the forced
@@ -2173,9 +2202,8 @@ impl Core {
2173
2202
  // runs V8's teardown GC, which could otherwise re-invoke near_heap_limit_cb
2174
2203
  // (touching the just-reset slot of an isolate being destroyed). The watchdog
2175
2204
  // 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
- }
2205
+ // Registered unconditionally (default protection), so always remove it.
2206
+ unsafe { &mut *self.iso_ptr.0 }.remove_near_heap_limit_callback(near_heap_limit_cb, 0);
2179
2207
  // Remove (and drop) the OwnedIsolate — V8 disposal runs here — AFTER the
2180
2208
  // watchdog joined and the Globals were cleared, while the isolate is
2181
2209
  // entered (above). Drop outside the lock so V8 teardown can't deadlock on
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RustyRacer
4
- VERSION = "0.1.3"
4
+ VERSION = "0.1.4"
5
5
  end
data/lib/rusty_racer.rb CHANGED
@@ -27,8 +27,9 @@ 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
30
+ # Raised when JS allocation exceeds the isolate's heap ceiling (the configured
31
+ # memory_limit, or V8's default ceiling when none was set). Catchable like any
32
+ # eval error — a runaway script fails its own eval instead of aborting the
32
33
  # process. The space-axis twin of ScriptTerminatedError (the time axis).
33
34
  class V8OutOfMemoryError < EvalError; end
34
35
  class SnapshotError < Error; end
@@ -45,12 +46,19 @@ module RustyRacer
45
46
  # Keyword-arg constructor over the positional Rust primitive. A snapshot
46
47
  # (RustyRacer::Snapshot) boots the isolate with its baked-in state;
47
48
  # timeout_ms caps each eval/call (0 = no limit) against in-V8 infinite
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.
49
+ # loops. memory_limit caps the V8 heap in bytes: a script that exceeds the
50
+ # ceiling is terminated and raises V8OutOfMemoryError rather than aborting
51
+ # the process, and the isolate stays usable afterward. 0 (the default) does
52
+ # NOT disable this it leaves V8's own platform-derived default ceiling
53
+ # (typically ~2 GB on 64-bit) in place, so a runaway is still catchable
54
+ # instead of a fatal abort; pass a smaller value for a tighter bound. It is
55
+ # a soft limit — V8 enforces it at GC granularity, so usage may briefly
56
+ # overshoot, and an explicit limit must comfortably exceed the isolate's
57
+ # baseline (and any snapshot's baked-in heap), since it is only armed once
58
+ # the isolate has booted. Caveat: if the process's available memory (e.g. a
59
+ # container cgroup limit) is below the active ceiling, the OS may kill the
60
+ # process before V8's callback fires — set an explicit memory_limit under
61
+ # that bound to keep the error catchable.
54
62
  # microtasks mirrors V8's kAuto/kExplicit: :auto (default) drains
55
63
  # the microtask queue when the outermost eval/call/run/evaluate completes
56
64
  # (the standard embedder contract); :explicit drains only on
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rusty_racer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keita Urashima
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
11
  date: 2026-06-14 00:00:00.000000000 Z
@@ -54,7 +54,7 @@ metadata:
54
54
  source_code_uri: https://github.com/ursm/rusty_racer
55
55
  bug_tracker_uri: https://github.com/ursm/rusty_racer/issues
56
56
  rubygems_mfa_required: 'true'
57
- post_install_message:
57
+ post_install_message:
58
58
  rdoc_options: []
59
59
  require_paths:
60
60
  - lib
@@ -70,7 +70,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
70
70
  version: '0'
71
71
  requirements: []
72
72
  rubygems_version: 3.5.22
73
- signing_key:
73
+ signing_key:
74
74
  specification_version: 4
75
75
  summary: Embed V8 in Ruby via rusty_v8 + Magnus (rb-sys)
76
76
  test_files: []