rusty_racer 0.1.13 → 0.1.14

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: a578c93e9ec291527131f62e665b742da583aa112a79b9d92e525929d7282ed7
4
- data.tar.gz: 5de4f422556c933cacc87f8c4f64b1e630cd0f97c119427725ed9e92d5c9496f
3
+ metadata.gz: bef5f6ca0708cb89d0468f4aaca77ccdae184114733de79980b2a84c51660cb3
4
+ data.tar.gz: 843f938abbb31aeb8631bb943052d3b326990eee1e13d38537aecba4c409a7b5
5
5
  SHA512:
6
- metadata.gz: 706c9d1b291ab6e23924a3dfab1e070a64ec4b831e250d57b3562b3f54d4b59c64a25e92bd711d58a60b66a1f266198a500b3af9c83da937673f128a032bf6ea
7
- data.tar.gz: 54f9c4df89593690314993ab3008ee82bb860002d68e8faedd155bcfbbae2552d104520f72b8e110533767f04801695cf5804f42cc792bd63d6e6c35d9e54608
6
+ metadata.gz: db1ae8f6a5e6793b5ccb172d9412b94e70535e4b4adb25ba18ba8128bdee006ed29ce87a3de60c4aadaaf497239a27655ae1d9573dad3b4f8b764f12601c7bee
7
+ data.tar.gz: ed7e48d5c2e942cfbe368e1c61ca7169ef4c126a609e4f9ec189e73f6534946ebeb91e48e284c3965547d6437cdbf1819c6e0498e781adf151d286d1054a4422
data/README.md CHANGED
@@ -252,14 +252,19 @@ In-thread V8 runs on whatever stack the calling Ruby code is on — including a
252
252
  **Fiber**'s separate stack (a plain `Enumerator` is a Fiber, so this is common:
253
253
  `Capybara::Result#find`, lazy enumerators, …). This works on the **main thread**,
254
254
  where the process stack is the highest address and every Fiber sits below it.
255
-
256
- On a **non-main thread** it does not: V8 anchors its "is this the central stack?"
257
- check to that thread's native stack top (a pthread value it caches, with no API
258
- to retarget), and a Fiber allocated *above* that top — the usual case off the
259
- main thread falls outside the check, so V8 aborts the process on the next GC or
260
- thrown exception. So **don't call into an isolate from inside a Fiber on a
261
- worker thread**; drive isolate ops directly on the thread, or keep
262
- Fiber/Enumerator-mediated JS calls on the main thread.
255
+ That includes a Fiber entered *underneath* a running JS call — a host function
256
+ that resumes a Fiber which evals again since each operation describes its own
257
+ stack to V8 and restores the enclosing one when it returns.
258
+
259
+ On a **non-main thread** it does not. V8 decides "is this the central stack?" by
260
+ testing the stack pointer against a window that ends at the thread's native stack
261
+ top a pthread value it caches, with no API to retarget on POSIX. Only the
262
+ window's *lower* edge follows the per-operation stack limit, so a Fiber allocated
263
+ *above* that top — the usual case off the main thread, whose own stack sits below
264
+ later Fiber mmaps — is outside the window whatever we do, and V8 aborts the
265
+ process on the next GC or thrown exception. So **don't call into an isolate from
266
+ inside a Fiber on a worker thread**; drive isolate ops directly on the thread, or
267
+ keep Fiber/Enumerator-mediated JS calls on the main thread.
263
268
 
264
269
  ## Installation
265
270
 
@@ -5,7 +5,7 @@
5
5
  # links and loads with no custom archive.
6
6
  [package]
7
7
  name = "rusty_racer"
8
- version = "0.1.13"
8
+ version = "0.1.14"
9
9
  edition = "2024"
10
10
  publish = false
11
11
 
@@ -54,7 +54,7 @@ use marshal::{js_to_jsval, jsval_to_js, jsval_to_ruby, ruby_to_jsval, JsVal};
54
54
  mod ops;
55
55
  use ops::{run_source, service_request, Compiled, Request, VmReply};
56
56
  mod stack;
57
- use stack::{current_real_isolate, discover_scan_start_field, set_v8_stack_limit, STACK_DEBUG};
57
+ use stack::{current_real_isolate, discover_scan_start_field, StackScope, STACK_DEBUG};
58
58
  mod watchdog;
59
59
  use watchdog::{
60
60
  arm_watchdog, disarm_watchdog, run_js_bracketed, watchdog_loop, WatchdogShared, WATCHDOG_DEBUG,
@@ -1229,10 +1229,16 @@ struct Core {
1229
1229
  // the owner thread.
1230
1230
  iso_ptr: IsoPtr,
1231
1231
  // Address of V8's conservative-GC-scan stack_start field (see
1232
- // discover_scan_start_field), or 0 if discovery failed. set_v8_stack_limit
1233
- // writes the fiber region top here per fiber op so a GC scan stays mapped.
1234
- // Set once at creation, then read-only; AtomicUsize for shared &Core access.
1232
+ // discover_scan_start_field), or 0 if discovery failed. StackScope writes the
1233
+ // fiber region top here per fiber op so a GC scan stays mapped. Set once at
1234
+ // creation, then read-only; AtomicUsize for shared &Core access.
1235
1235
  scan_start_field: std::sync::atomic::AtomicUsize,
1236
+ // The stack limit currently installed in V8 for this isolate. StackScope
1237
+ // keeps it here because V8 exposes no getter and a nested op running on
1238
+ // another stack has to put the enclosing op's limit back. 0 before the first
1239
+ // op. Owner-thread only, like the isolate itself; AtomicUsize for shared
1240
+ // &Core access.
1241
+ installed_stack_limit: std::sync::atomic::AtomicUsize,
1236
1242
  // Re-entry depth for THIS isolate, readable without a scope (the runner needs
1237
1243
  // it to choose the scope kind before any scope exists): 0 = top-level op
1238
1244
  // (open a fresh HandleScope from iso_ptr); >0 = a host callback is on the V8
@@ -1998,6 +2004,7 @@ impl Isolate {
1998
2004
  _owner_root: RootedThread(BoxValue::new(owner_thread)),
1999
2005
  iso_ptr,
2000
2006
  scan_start_field: std::sync::atomic::AtomicUsize::new(0),
2007
+ installed_stack_limit: std::sync::atomic::AtomicUsize::new(0),
2001
2008
  depth: std::sync::atomic::AtomicU32::new(0),
2002
2009
  procs: Mutex::new(ProcTable::default()),
2003
2010
  default_timeout_ms: timeout_ms,
@@ -2084,29 +2091,69 @@ impl Core {
2084
2091
  // poison the isolate and raise instead of crashing the process.
2085
2092
  use std::panic::AssertUnwindSafe;
2086
2093
  let reply: Option<VmReply> = without_gvl(|| {
2087
- if depth == 0 {
2094
+ // iso_ptr is *mut v8::Isolate = *mut NonNull<RealIsolate>; the raw
2095
+ // v8::Isolate* the C++ entry points want is that NonNull's value.
2096
+ let real_isolate = unsafe { *(iso as *const *mut c_void) };
2097
+ // Whether this op has to ENTER the isolate itself. At depth 0 always:
2098
+ // between ops the isolate is exited, so each op enters around its run.
2099
+ //
2100
+ // A re-entrant op (a host callback or module resolver, having
2101
+ // reacquired the GVL to run a proc that issued this op, is on the V8
2102
+ // stack) USUALLY does not: the JS on the stack belongs to THIS
2103
+ // isolate, which is therefore already entered on THIS native thread,
2104
+ // and we bootstrap onto the ambient HandleScope instead.
2105
+ //
2106
+ // But an embedder driving MANY isolates (e.g. capybara-simulated's
2107
+ // windows) can interleave them: a host callback on isolate A runs
2108
+ // Ruby that evals isolate B, whose own callback re-enters A. Now A is
2109
+ // on the V8 stack (depth > 0) yet B — not A — is the isolate
2110
+ // CURRENTLY entered on this thread, so bootstrapping a scope on A and
2111
+ // opening a ContextScope would trip V8's "scope and Context do not
2112
+ // belong to the same Isolate" panic (it checks the scope's isolate
2113
+ // against Isolate::GetCurrent()). Detect that case and properly enter
2114
+ // A on top of B, restoring B on exit.
2115
+ let entered = depth == 0 || real_isolate != current_real_isolate();
2116
+ // Snapshot the ENCLOSING op's conservative-GC-scan start BEFORE
2117
+ // entering: Isolate::Enter re-points it at the native top, so reading
2118
+ // it afterwards would hand StackScope the clobbered value to
2119
+ // "restore" — stranding an enclosing FIBER op with a start on the
2120
+ // native stack, whose next GC walks off the fiber's mapped top and
2121
+ // SEGVs. None at depth 0, where the value in the field belongs to the
2122
+ // previous, already-finished op and so is nothing to preserve.
2123
+ let scan_start_field = self.scan_start_field.load(Ordering::Relaxed);
2124
+ let enclosing_scan_start = if depth > 0 && scan_start_field != 0 {
2125
+ Some(unsafe { *(scan_start_field as *const usize) })
2126
+ } else {
2127
+ None
2128
+ };
2129
+ if entered {
2088
2130
  unsafe { (*iso).enter() };
2089
- // In-thread: V8 runs on THIS native thread's stack. Re-point its
2090
- // stack limit at this thread's bottom before running JSthe
2091
- // create-time limit is fixed at a shallow (or other-thread)
2092
- // frame, so a deeper entry would false-overflow (and the bad
2093
- // throw trips V8's IsOnCentralStack CHECK -> fatal). iso_ptr is
2094
- // *mut v8::Isolate = *mut NonNull<RealIsolate>; the raw
2095
- // v8::Isolate* the C++ method wants is that NonNull's value.
2096
- let real_isolate = unsafe { *(iso as *const *mut c_void) };
2097
- // A live address ABOVE every V8 frame of this op (the scope and
2098
- // service_request below run in deeper frames). On a fiber it
2099
- // becomes V8's conservative-GC-scan stack_start so the scan stays
2100
- // within the live, mapped stack see set_v8_stack_limit.
2101
- let stack_top_marker = 0u8;
2102
- // Word-align (down): V8 CHECKs the scan start is pointer-aligned.
2103
- // Down stays above all V8 frames (they're a full frame below).
2104
- let stack_top = (&stack_top_marker as *const u8 as usize) & !(size_of::<usize>() - 1);
2105
- set_v8_stack_limit(
2106
- real_isolate,
2107
- self.scan_start_field.load(Ordering::Relaxed),
2108
- stack_top,
2109
- );
2131
+ }
2132
+ // In-thread: V8 runs on whatever stack the Ruby caller is on this
2133
+ // native thread's, or a Ruby Fiber's if a host callback resumed one.
2134
+ // Describe that stack to the isolate for the duration of the op, and
2135
+ // put the enclosing op's description back afterwards. Both the
2136
+ // create-time limit (fixed at a shallow, possibly other-thread frame)
2137
+ // and an enclosing op's are stale here, and a stale limit doesn't
2138
+ // merely false-overflow it makes the resulting throw FATAL. See
2139
+ // StackScope.
2140
+ //
2141
+ // stack_top_marker is a live address ABOVE every V8 frame of this op
2142
+ // (the scope and service_request below run in deeper frames); on a
2143
+ // fiber it becomes V8's conservative-GC-scan start so the scan stays
2144
+ // within the live, mapped stack.
2145
+ let stack_top_marker = 0u8;
2146
+ // Word-align (down): V8 CHECKs the scan start is pointer-aligned.
2147
+ // Down stays above all V8 frames (they're a full frame below).
2148
+ let stack_top = (&stack_top_marker as *const u8 as usize) & !(size_of::<usize>() - 1);
2149
+ let stack = StackScope::enter(
2150
+ real_isolate,
2151
+ scan_start_field,
2152
+ enclosing_scan_start,
2153
+ &self.installed_stack_limit,
2154
+ stack_top,
2155
+ );
2156
+ if depth == 0 {
2110
2157
  let mut reply = std::panic::catch_unwind(AssertUnwindSafe(|| {
2111
2158
  v8::scope!(let scope, unsafe { &mut *iso });
2112
2159
  service_request(scope, request, true)
@@ -2145,45 +2192,20 @@ impl Core {
2145
2192
  istate!(iso_ref).oom_fired = false;
2146
2193
  reply = reply.map(relabel_oom);
2147
2194
  }
2195
+ // Drop the stack description BEFORE popping the isolate — exit()
2196
+ // can leave a different one current. At depth 0 the drop restores
2197
+ // nothing (no enclosing op), so this just releases the scope.
2198
+ drop(stack);
2148
2199
  unsafe { (*iso).exit() };
2149
2200
  reply
2150
2201
  } else {
2151
- // Re-entrant (a host callback or module resolver, having
2152
- // reacquired the GVL to run a proc that issued this op, is on the
2153
- // V8 stack). USUALLY the JS on the stack belongs to THIS isolate
2154
- // (same-isolate reentry) which is therefore already entered on
2155
- // THIS native thread — so we bootstrap onto the ambient
2156
- // HandleScope without re-entering.
2157
- //
2158
- // But an embedder driving MANY isolates (e.g. capybara-simulated's
2159
- // windows) can interleave them: a host callback on isolate A runs
2160
- // Ruby that evals isolate B, whose own callback re-enters A. Now A
2161
- // is on the V8 stack (depth > 0) yet B — not A — is the isolate
2162
- // CURRENTLY entered on this thread, so bootstrapping a scope on A
2163
- // and opening a ContextScope would trip V8's "scope and Context do
2164
- // not belong to the same Isolate" panic (it checks the scope's
2165
- // isolate against Isolate::GetCurrent()). Detect that case and
2166
- // properly enter A on top of B, restoring B on exit. Entering an
2167
- // already-current isolate is also harmless (V8's entered-isolate
2168
- // stack nests), so this is correct for same-isolate reentry too —
2169
- // we only pay the enter/exit when a FOREIGN isolate is on top.
2170
- //
2171
- // The stack limit + scan-start set at depth 0 are NOT re-pointed
2172
- // here: reentry runs in DEEPER frames of the SAME stack, so the
2173
- // depth-0 values still bound it correctly (whichever isolate is
2174
- // current — each tracks its own limit). The one exception is a
2175
- // host callback that SWITCHES stacks — e.g. resumes a Ruby Fiber
2176
- // that itself evals — where the depth-0 (native) settings are
2177
- // stale for the fiber; that nested-fiber-under-callback case is an
2178
- // unsupported edge (the realistic fiber path is a depth-0 eval).
2179
- let foreign = unsafe { *(iso as *const *mut c_void) } != current_real_isolate();
2180
- if foreign {
2181
- unsafe { (*iso).enter() };
2182
- }
2202
+ // Re-entrant. `entered` is true only when a FOREIGN isolate was
2203
+ // on top (see above): that one had no ambient scope of ours under
2204
+ // its entry, so it opens a fresh HandleScope exactly as the
2205
+ // depth-0 path does. Same-isolate reentry bootstraps onto the
2206
+ // ambient one instead.
2183
2207
  let reply = std::panic::catch_unwind(AssertUnwindSafe(|| {
2184
- if foreign {
2185
- // A had no ambient scope under B's entry — open a fresh
2186
- // HandleScope on A, exactly as the depth-0 path does.
2208
+ if entered {
2187
2209
  v8::scope!(let scope, unsafe { &mut *iso });
2188
2210
  service_request(scope, request, false)
2189
2211
  } else {
@@ -2195,7 +2217,8 @@ impl Core {
2195
2217
  // Pop A back off (restoring B as current). Safe even after a
2196
2218
  // panic unwind: the scope's Drop ran but left A entered, and
2197
2219
  // exit() asserts A == GetCurrent(), which holds here.
2198
- if foreign {
2220
+ drop(stack);
2221
+ if entered {
2199
2222
  unsafe { (*iso).exit() };
2200
2223
  }
2201
2224
  reply
@@ -5,7 +5,7 @@
5
5
  // — no IsolateState/JsVal/marshalling. It also hosts current_real_isolate() (the
6
6
  // entered-isolate query) since that too is just one of the exported V8 symbols
7
7
  // below, kept here so the FFI block stays in one place. The crate uses
8
- // discover_scan_start_field (once per isolate), set_v8_stack_limit (per op),
8
+ // discover_scan_start_field (once per isolate), StackScope (per op),
9
9
  // current_real_isolate (per reentrant op), and STACK_DEBUG (set at init);
10
10
  // everything else is private to this module.
11
11
 
@@ -14,7 +14,7 @@ use std::ffi::c_void;
14
14
  // don't warn it unused.
15
15
  #[cfg(target_os = "linux")]
16
16
  use std::ptr::null_mut;
17
- use std::sync::atomic::{AtomicBool, Ordering};
17
+ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
18
18
 
19
19
  // rusty_v8 doesn't wrap the runtime `v8::Isolate::SetStackLimit(uintptr_t)`, so
20
20
  // link the public V8 symbol directly (stable across V8 versions). It sets the
@@ -24,7 +24,7 @@ unsafe extern "C" {
24
24
  fn v8__Isolate__SetStackLimit(isolate: *mut c_void, stack_limit: usize);
25
25
  // V8's own (exported) accessors down to the conservative-GC-scan Stack
26
26
  // object, so we can re-point its stack_start per op when V8 runs on a Ruby
27
- // Fiber (see set_fiber_scan_start / discover_scan_start_field). Member fns:
27
+ // Fiber (see StackScope / discover_scan_start_field). Member fns:
28
28
  // the first arg is `this`. The public v8::Isolate* IS i::Isolate*.
29
29
  #[link_name = "_ZN2v88internal7Isolate4heapEv"]
30
30
  fn v8__internal__Isolate__heap(isolate: *mut c_void) -> *mut c_void;
@@ -52,7 +52,7 @@ pub(crate) fn current_real_isolate() -> *mut c_void {
52
52
  }
53
53
 
54
54
  // Locate V8's conservative-GC-scan stack_start field
55
- // (heap::base::Stack::current_segment_.start) so set_fiber_scan_start can
55
+ // (heap::base::Stack::current_segment_.start) so StackScope can
56
56
  // re-point it per op. The scanner walks [SP, stack_start); on a Ruby Fiber V8's
57
57
  // stack_start is still the NATIVE thread top, a different region, so the walk
58
58
  // runs off the fiber's mapped top into the guard page and SEGVs (the residual
@@ -178,6 +178,13 @@ fn current_region_bounds_cached(addr: usize) -> (usize, usize) {
178
178
  // The [start, end) of the /proc/self/maps mapping containing `addr`. Linux only;
179
179
  // (0, 0) elsewhere (and the caller falls back). Reads the file fresh — slow, so
180
180
  // only called on a cache miss (a new fiber).
181
+ //
182
+ // Everything that needs a FIBER's real bounds degrades on the (0, 0) platforms,
183
+ // macOS being the shipped one: the stack limit drops to a fixed budget below the
184
+ // SP (see StackScope::enter), and a nested op can no longer widen its GC-scan
185
+ // start to the enclosing op's, so it narrows to its own frame. Both are the
186
+ // pre-fiber-support behaviour rather than a crash, but a darwin implementation
187
+ // (mach_vm_region on the current task) would close the gap.
181
188
  #[cfg(target_os = "linux")]
182
189
  fn query_region_bounds(addr: usize) -> (usize, usize) {
183
190
  use std::io::Read;
@@ -216,11 +223,17 @@ fn query_region_bounds(_addr: usize) -> (usize, usize) {
216
223
  // Set from RUSTY_RACER_STACK_DEBUG at init; gates the per-op stack diagnostics.
217
224
  pub(crate) static STACK_DEBUG: AtomicBool = AtomicBool::new(false);
218
225
 
219
- // Re-point V8's stack limit at the CURRENT stack each op. In-thread V8 runs
220
- // wherever the Ruby code is: usually the native thread's pthread stack, but also
221
- // a Ruby Fiber's separate mmap'd stack (Capybara::Result is an Enumerator) that
222
- // pthread can't see. The limit MUST sit between the current SP and the real
223
- // bottom of whatever stack we're on:
226
+ // How V8 describes the stack an op runs on: the stack limit, and on a Ruby
227
+ // Fiber the conservative-GC-scan start. Both live in the ISOLATE, not in the
228
+ // op, so an op that runs on a DIFFERENT stack than the one enclosing it (a host
229
+ // callback that resumes a Fiber which evals again) has to install its own and
230
+ // put the enclosing op's back on the way out. Hence a scope: after enter() V8
231
+ // describes the stack we are on now, after the drop the one it described before.
232
+ //
233
+ // In-thread V8 runs wherever the Ruby code is: usually the native thread's
234
+ // pthread stack, but also a Ruby Fiber's separate mmap'd stack (Capybara::Result
235
+ // is an Enumerator) that pthread can't see. The limit MUST sit between the
236
+ // current SP and the real bottom of whatever stack we're on:
224
237
  // * Too high (above SP) and V8 declares a FALSE overflow on entry.
225
238
  // * Too low (below the real bottom) and a deep recursion grows past the
226
239
  // mapped stack and SEGVs the unmapped guard page below it.
@@ -228,85 +241,229 @@ pub(crate) static STACK_DEBUG: AtomicBool = AtomicBool::new(false);
228
241
  // native stack, anchor to its pthread bottom; on a fiber, find the bottom of the
229
242
  // /proc/self/maps region holding the SP (the fiber's real bottom — anchoring to
230
243
  // SP minus a fixed guard punched through the bottom of Avo's small/deep Capybara
231
- // fibers and SEGV'd). Must be called with the isolate ENTERED. `real_isolate` is
232
- // the raw v8::Isolate* read out of iso_ptr.
244
+ // fibers and SEGV'd).
245
+ //
246
+ // The limit does double duty, which is why a STALE one is fatal rather than
247
+ // merely wrong: v8::Isolate::SetStackLimit also sets stack_size_ to
248
+ // (base::Stack::GetStackStart() - limit), and V8's "am I on the central stack?"
249
+ // test is (native_top - stack_size_ - margin) < addr <= native_top — that is,
250
+ // exactly (limit - margin, native_top]. Throwing (and GC) CHECKs that window and
251
+ // V8_Fatals the process on a miss. So an op running on a fiber under the
252
+ // ENCLOSING op's native-stack limit doesn't merely false-overflow: the
253
+ // RangeError it then tries to throw aborts. Installing the limit for the stack
254
+ // we are actually on fixes both at once.
233
255
  //
234
- // On a fiber it ALSO re-points V8's conservative-GC-scan stack_start (via
235
- // scan_start_field, discovered once per isolate) to `stack_top`: Enter just set
236
- // it to the native top, but the scanner walks [marker, stack_start), so a native
237
- // start runs the scan off the fiber's mapped stack into unmapped memory and
238
- // SEGVs (Avo's Capybara filter chain). scan_start_field is 0 when discovery
239
- // failed (override disabled).
256
+ // RESIDUAL (GC scan across a stack SWITCH): V8 tracks ONE scan segment per
257
+ // isolate, so while a nested op runs on a fiber its enclosing op's frames on the
258
+ // NATIVE stack aren't conservatively scanned the two live in different
259
+ // mappings and only one range can be described. (A nested op that did NOT switch
260
+ // stacks is fine: it keeps the enclosing op's start, which covers both.) Handles
261
+ // are unaffected — this
262
+ // build has v8_enable_direct_handle off, so every Local lives in a HandleScope
263
+ // block and is iterated precisely, and JS frames are walked precisely from the
264
+ // frame pointers whichever stack they sit on. Only a raw Tagged<> in V8's own
265
+ // C++ frames would be missed, and V8 keeps those in handles across anything that
266
+ // can allocate. There's no fix available: Stack has no API to register a second
267
+ // segment for the same thread.
240
268
  //
241
- // LIMITATION (worker-thread fibers): the GC and a thrown exception ALSO
242
- // `CHECK(IsOnCentralStack(SP))`, which tests the SP against
243
- // `base::Stack::GetStackStart()` — the pthread top, cached per native thread,
244
- // with no API to retarget — NOT the scan start we re-point above. A fiber mmap'd
245
- // ABOVE that top (the common case on a NON-main native thread, whose stack sits
246
- // below later fiber mmaps) fails the CHECK, so V8 aborts on the next GC or throw.
247
- // We can fix the scan (the SEGV) but not that CHECK. On the main thread the
248
- // process stack is the highest address, so every fiber is below it and both the
249
- // scan and the CHECK are safe — the Capybara/Avo case. See README.
250
- pub(crate) fn set_v8_stack_limit(real_isolate: *mut c_void, scan_start_field: usize, stack_top: usize) {
251
- let sp_marker = 0u8;
252
- let sp = &sp_marker as *const u8 as usize;
253
- let (nbottom, ntop) = native_stack_bounds_cached();
254
- let on_native = nbottom != 0 && sp > nbottom && sp <= ntop;
255
- // Reserve below the limit for V8's own RangeError-throw frames.
256
- const NATIVE_GUARD: usize = 128 * 1024;
257
- // V8 throws when SP descends to the limit, then needs some real stack BELOW
258
- // it to build the RangeError (and V8 itself allows growing a little past the
259
- // limit — its overflow slack). On a fiber that reserve must NOT cross the
260
- // fiber's real bottom (the mapping below it is an unmapped guard -> SEGV), so
261
- // keep it comfortably above V8's slack.
262
- const FIBER_RESERVE: usize = 64 * 1024;
263
- let mut region = (0usize, 0usize);
264
- let limit = if on_native {
265
- nbottom + NATIVE_GUARD
266
- } else {
267
- // Anchor to the FIBER's real bottom (the /proc/self/maps region holding
268
- // the SP), not the SP: SP - fixed_guard can punch through the bottom of a
269
- // small/deep fiber stack and SEGV (Avo's deep Capybara filter chain).
270
- // Reserve FIBER_RESERVE above the bottom for the throw, but keep the
271
- // limit below the SP so we don't false-overflow; on a nearly-full fiber
272
- // that clamps the headroom down (an early but CLEAN RangeError).
273
- region = current_region_bounds_cached(sp);
274
- if region.0 != 0 {
275
- (region.0 + FIBER_RESERVE).min(sp.saturating_sub(8 * 1024))
269
+ // LIMITATION (worker-thread fibers): only the window's LOWER bound follows the
270
+ // limit. Its upper bound is base::Stack::GetStackStart(), the native pthread
271
+ // top, cached per thread with no way to retarget it (on POSIX
272
+ // base::Stack::SetCurrentThreadStackBounds is UNREACHABLE()). A fiber mmap'd
273
+ // ABOVE that top the common case on a NON-main native thread, whose stack sits
274
+ // below later fiber mmaps is outside the window whatever limit we set, so V8
275
+ // aborts on the next throw or GC. On the main thread the process stack is the
276
+ // highest address, so every fiber is below it and the window covers it — the
277
+ // Capybara/Avo case. See README.
278
+ pub(crate) struct StackScope<'a> {
279
+ real_isolate: *mut c_void,
280
+ // Address of V8's conservative-GC-scan start field, or 0 when enter()
281
+ // installed nothing (discovery failed, or no sane limit) the drop then
282
+ // restores nothing.
283
+ scan_start_field: usize,
284
+ // The limit V8 currently holds for this isolate. Tracked by the caller
285
+ // because V8 exposes no getter, and a nested op needs the enclosing op's
286
+ // value to put back. 0 before the isolate's first op.
287
+ installed_limit: &'a AtomicUsize,
288
+ // What the drop has to put back, or 0 for "nothing changed, nothing to undo".
289
+ // Most re-entrant ops run in deeper frames of the SAME stack and so compute
290
+ // the very values already installed; skipping those writes keeps a nested op
291
+ // as cheap as it was before it started describing its own stack.
292
+ restore_limit: usize,
293
+ restore_scan_start: usize,
294
+ }
295
+
296
+ impl<'a> StackScope<'a> {
297
+ // Describe the stack THIS call runs on to the isolate, until the drop. Must
298
+ // be called with the isolate ENTERED: Isolate::Enter sets the scan start to
299
+ // the native top, so entering afterwards would clobber a fiber override.
300
+ //
301
+ // `enclosing_scan_start` is the scan start of the op this one is nested in,
302
+ // or None at the outermost op, where no enclosing op exists — then the drop
303
+ // restores nothing (the values left behind belong to no live frame, and the
304
+ // next op installs its own before any JS runs). Because Enter re-points the
305
+ // field, it must be read BEFORE entering. It doubles as the value the drop
306
+ // puts back and, when a nested op didn't switch stacks, as this op's own
307
+ // start (see below). `real_isolate` is the raw v8::Isolate* read out of
308
+ // iso_ptr; `stack_top` is a live address the caller captured ABOVE every V8
309
+ // frame of this op (used only on a fiber).
310
+ pub(crate) fn enter(
311
+ real_isolate: *mut c_void,
312
+ scan_start_field: usize,
313
+ enclosing_scan_start: Option<usize>,
314
+ installed_limit: &'a AtomicUsize,
315
+ stack_top: usize,
316
+ ) -> Self {
317
+ // Only a nested op has an enclosing description to preserve and restore.
318
+ let nested = enclosing_scan_start.is_some();
319
+ // 0 doubles as "no enclosing value" throughout: it is never a real start.
320
+ let prev_scan_start = enclosing_scan_start.unwrap_or(0);
321
+ let sp_marker = 0u8;
322
+ let sp = &sp_marker as *const u8 as usize;
323
+ let (nbottom, ntop) = native_stack_bounds_cached();
324
+ let on_native = nbottom != 0 && sp > nbottom && sp <= ntop;
325
+ // Reserve below the limit for V8's own RangeError-throw frames.
326
+ const NATIVE_GUARD: usize = 128 * 1024;
327
+ // V8 throws when SP descends to the limit, then needs some real stack
328
+ // BELOW it to build the RangeError (and V8 itself allows growing a little
329
+ // past the limit — its overflow slack). On a fiber that reserve must NOT
330
+ // cross the fiber's real bottom (the mapping below it is an unmapped
331
+ // guard -> SEGV), so keep it comfortably above V8's slack.
332
+ const FIBER_RESERVE: usize = 64 * 1024;
333
+ let mut region = (0usize, 0usize);
334
+ let limit = if on_native {
335
+ nbottom + NATIVE_GUARD
276
336
  } else {
277
- sp.saturating_sub(64 * 1024) // region unknown (non-linux) best effort
337
+ // Anchor to the FIBER's real bottom (the /proc/self/maps region
338
+ // holding the SP), not the SP: SP - fixed_guard can punch through the
339
+ // bottom of a small/deep fiber stack and SEGV (Avo's deep Capybara
340
+ // filter chain). Reserve FIBER_RESERVE above the bottom for the
341
+ // throw, but keep the limit below the SP so we don't false-overflow;
342
+ // on a nearly-full fiber that clamps the headroom down (an early but
343
+ // CLEAN RangeError).
344
+ region = current_region_bounds_cached(sp);
345
+ if region.0 != 0 {
346
+ (region.0 + FIBER_RESERVE).min(sp.saturating_sub(8 * 1024))
347
+ } else {
348
+ // Region unknown — only Linux can look a fiber's bounds up (see
349
+ // query_region_bounds), so this is the macOS path. Best effort:
350
+ // hand JS a fixed budget below the SP and hope the fiber is that
351
+ // deep. It usually is for an OUTERMOST fiber op, whose SP sits
352
+ // near the fiber's top, and much less reliably for a NESTED one,
353
+ // whose SP is already far down the fiber — there this can land
354
+ // below the fiber's mapped bottom, and V8 then grows through the
355
+ // guard page (SEGV) instead of throwing. Fixing that properly
356
+ // means a mach_vm_region lookup for darwin.
357
+ sp.saturating_sub(64 * 1024)
358
+ }
359
+ };
360
+ // Opt-in diagnostics (RUSTY_RACER_STACK_DEBUG): the SP vs the native
361
+ // stack [nbottom, ntop], the fiber region (if any), the per-op limit, and
362
+ // whether the SP is above the limit. A crash with sp_above_limit=false
363
+ // means the limit is wrong for the current stack.
364
+ if STACK_DEBUG.load(Ordering::Relaxed) {
365
+ eprintln!(
366
+ "[rusty stack] sp={sp:#x} nbottom={nbottom:#x} ntop={ntop:#x} \
367
+ region=[{:#x},{:#x}) limit={limit:#x} fiber={} sp_above_limit={} \
368
+ fiber_above_native={}",
369
+ region.0,
370
+ region.1,
371
+ !on_native,
372
+ sp > limit,
373
+ !on_native && nbottom != 0 && sp > ntop,
374
+ );
375
+ }
376
+ if limit == 0 {
377
+ // Couldn't determine a sane limit — leave V8's default in place, and
378
+ // with it a scope that restores nothing.
379
+ return Self {
380
+ real_isolate,
381
+ scan_start_field: 0,
382
+ installed_limit,
383
+ restore_limit: 0,
384
+ restore_scan_start: 0,
385
+ };
386
+ }
387
+ // installed_limit is the sole record of what V8 holds (it has no getter),
388
+ // so an equal value means the isolate is already described correctly and
389
+ // the call can be skipped. Nothing else moves the limit behind our back:
390
+ // Isolate::Enter/Exit leave the stack guard alone, and V8 re-points it
391
+ // itself only under v8::Locker thread archiving (rusty_v8 exposes no
392
+ // Locker, and an isolate here is thread-confined) or wasm stack
393
+ // switching (no wasm stacks exist in this embedding).
394
+ let prev_limit = installed_limit.load(Ordering::Relaxed);
395
+ let changed_limit = limit != prev_limit;
396
+ if changed_limit {
397
+ unsafe { v8__Isolate__SetStackLimit(real_isolate, limit) };
398
+ installed_limit.store(limit, Ordering::Relaxed);
399
+ }
400
+ // Only a nested op owes anything back: at the outermost op prev_limit is
401
+ // the previous, already-finished op's, which describes no live frame.
402
+ let restore_limit = if nested && changed_limit { prev_limit } else { 0 };
403
+ // Point V8's conservative-GC-scan start at the stack we're on. The
404
+ // scanner walks [marker, start), so on a fiber a native start runs it off
405
+ // the fiber's mapped top into unmapped memory and SEGVs (Avo's Capybara
406
+ // filter chain). Always take the WIDEST start that is still on this
407
+ // stack, because everything between the marker and it gets scanned and an
408
+ // enclosing op's frames sit above ours:
409
+ // * native stack — the native top, i.e. what V8 itself uses.
410
+ // * a fiber we ENTERED (the enclosing op is elsewhere) — this op's
411
+ // `stack_top`, which keeps the range between two real stack pointers
412
+ // (marker..stack_top) and so guaranteed mapped. We can't use the
413
+ // /proc/maps region top: that mapping isn't reliably contiguous, so
414
+ // the scan could still hit a hole below it.
415
+ // * a fiber we were ALREADY on (a nested op under a host callback that
416
+ // didn't switch stacks) — the enclosing op's start, which is above
417
+ // ours and in the same mapping, so it covers both ops' frames.
418
+ let new_scan_start = if on_native {
419
+ unsafe { v8__base__Stack__GetStackStart() }
420
+ } else if nested && region.0 != 0 && prev_scan_start > sp && prev_scan_start < region.1 {
421
+ prev_scan_start
422
+ } else {
423
+ stack_top
424
+ };
425
+ // Install it, and work out what the drop owes the enclosing op. The value
426
+ // to put back is the caller's PRE-ENTER snapshot, not whatever the field
427
+ // holds now: Isolate::Enter re-points the start at the native top, so on
428
+ // the foreign-isolate reentry path the field has already been clobbered
429
+ // by the time we get here, and restoring that would strand an enclosing
430
+ // fiber op with a start on the wrong stack.
431
+ let restore_scan_start = if scan_start_field != 0 && new_scan_start != 0 {
432
+ let field = scan_start_field as *mut usize;
433
+ if unsafe { field.read() } != new_scan_start {
434
+ unsafe { field.write(new_scan_start) };
435
+ }
436
+ if nested && prev_scan_start != 0 && prev_scan_start != new_scan_start {
437
+ prev_scan_start
438
+ } else {
439
+ 0 // nothing enclosing to put back, or the drop would be a no-op
440
+ }
441
+ } else {
442
+ 0 // nothing installed -> nothing to undo
443
+ };
444
+ Self {
445
+ real_isolate,
446
+ scan_start_field,
447
+ installed_limit,
448
+ restore_limit,
449
+ restore_scan_start,
278
450
  }
279
- };
280
- if limit == 0 {
281
- return; // couldn't determine a sane limit — leave V8's default
282
- }
283
- unsafe { v8__Isolate__SetStackLimit(real_isolate, limit) };
284
- // On a fiber, re-point V8's conservative-GC-scan stack_start to `stack_top`
285
- // — a live address captured by the caller ABOVE every V8 frame of this op.
286
- // Enter() set the start to the NATIVE top (a different region); the scanner
287
- // walks [marker, start), so a native start runs it off the fiber's mapped
288
- // top into unmapped memory and SEGVs. Anchoring to stack_top keeps the whole
289
- // scan range between two real stack pointers (marker..stack_top), so it's
290
- // guaranteed mapped, and every V8 root (all below stack_top) is still found.
291
- // (We can't use the /proc/maps region top here: that mapping isn't reliably
292
- // contiguous, so the scan could still hit a hole below it.)
293
- if !on_native && stack_top != 0 && scan_start_field != 0 {
294
- unsafe { (scan_start_field as *mut usize).write(stack_top) };
295
451
  }
296
- // Opt-in diagnostics (RUSTY_RACER_STACK_DEBUG): the SP vs the native stack
297
- // [nbottom, ntop], the fiber region (if any), the per-op limit, and whether
298
- // the SP is above the limit. A crash with sp_above_limit=false means the
299
- // limit is wrong for the current stack.
300
- if STACK_DEBUG.load(Ordering::Relaxed) {
301
- eprintln!(
302
- "[rusty stack] sp={sp:#x} nbottom={nbottom:#x} ntop={ntop:#x} \
303
- region=[{:#x},{:#x}) limit={limit:#x} fiber={} sp_above_limit={} \
304
- fiber_above_native={}",
305
- region.0,
306
- region.1,
307
- !on_native,
308
- sp > limit,
309
- !on_native && nbottom != 0 && sp > ntop,
310
- );
452
+ }
453
+
454
+ impl Drop for StackScope<'_> {
455
+ // Put the enclosing op's description back, in the reverse order enter()
456
+ // installed it. A zero means there is nothing to put back: enter() changed
457
+ // nothing, or this was the isolate's first op and there is no earlier
458
+ // description. Leaving the last op's settings behind is harmless — every op
459
+ // installs its own before any JS runs.
460
+ fn drop(&mut self) {
461
+ if self.scan_start_field != 0 && self.restore_scan_start != 0 {
462
+ unsafe { (self.scan_start_field as *mut usize).write(self.restore_scan_start) };
463
+ }
464
+ if self.restore_limit != 0 {
465
+ unsafe { v8__Isolate__SetStackLimit(self.real_isolate, self.restore_limit) };
466
+ self.installed_limit.store(self.restore_limit, Ordering::Relaxed);
467
+ }
311
468
  }
312
469
  }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RustyRacer
4
- VERSION = "0.1.13"
4
+ VERSION = "0.1.14"
5
5
  end
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.13
4
+ version: 0.1.14
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-07-27 00:00:00.000000000 Z
11
+ date: 2026-07-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys