rusty_racer 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +13 -8
- data/ext/rusty_racer/Cargo.toml +1 -1
- data/ext/rusty_racer/src/lib.rs +365 -205
- data/ext/rusty_racer/src/marshal.rs +155 -124
- data/ext/rusty_racer/src/ops.rs +410 -272
- data/ext/rusty_racer/src/stack.rs +383 -106
- data/ext/rusty_racer/src/watchdog.rs +8 -2
- data/lib/rusty_racer/version.rb +1 -1
- metadata +2 -2
|
@@ -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),
|
|
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
|
|
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
|
|
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
|
|
@@ -151,33 +151,140 @@ fn native_stack_bounds() -> (usize, usize) {
|
|
|
151
151
|
(0, 0)
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
// Lower bound (and upper, for
|
|
155
|
-
// — i.e. the BOTTOM of the stack `addr` is on. Used for a Ruby Fiber,
|
|
156
|
-
// mmap'd stack pthread can't see: V8's limit must sit ABOVE this bottom or
|
|
157
|
-
// deep fiber recursion overflows the real stack and SEGVs the unmapped guard.
|
|
158
|
-
//
|
|
159
|
-
//
|
|
154
|
+
// Lower bound (and upper, for the cache test) of the memory region containing
|
|
155
|
+
// `addr` — i.e. the BOTTOM of the stack `addr` is on. Used for a Ruby Fiber,
|
|
156
|
+
// whose mmap'd stack pthread can't see: V8's limit must sit ABOVE this bottom or
|
|
157
|
+
// a deep fiber recursion overflows the real stack and SEGVs the unmapped guard.
|
|
158
|
+
// (0, 0) if unknown.
|
|
159
|
+
//
|
|
160
|
+
// Cached per native thread, several regions at a time, because a miss re-parses
|
|
161
|
+
// all of /proc/self/maps and costs far more than the op it serves: with ONE slot
|
|
162
|
+
// a workload that alternates between fibers missed on every single op, and an
|
|
163
|
+
// eval went from 1.2us to 31us as soon as two fibers took turns (measured on a
|
|
164
|
+
// 212-line maps; a Rails process has many times that). Ruby pools fiber stacks,
|
|
165
|
+
// so the live set is small and stable — one Enumerator per Capybara result, say
|
|
166
|
+
// — and a handful of slots turns that back into hits.
|
|
167
|
+
//
|
|
168
|
+
// Eviction is RANDOM, not round-robin (or LRU, which behaves the same here):
|
|
169
|
+
// ops cycling through more fibers than there are slots are exactly the pattern
|
|
170
|
+
// those policies handle worst, since each eviction takes the entry needed next
|
|
171
|
+
// and the hit rate collapses to zero — one fiber over the size measured as slow
|
|
172
|
+
// as having no cache at all. Evicting at random keeps roughly
|
|
173
|
+
// slots/live-fibers of the accesses, so going over the size degrades gradually
|
|
174
|
+
// instead of falling off a cliff. Empty slots are filled before anything is
|
|
175
|
+
// evicted. Entries are never invalidated: a dead fiber's keeps its slot until
|
|
176
|
+
// some miss draws it, and with a live set that fits there are no misses at all
|
|
177
|
+
// — which is why a hit has to be self-justifying rather than merely plausible.
|
|
178
|
+
//
|
|
179
|
+
// So the mapping's top is never used, or even kept, as an upper bound. A fiber
|
|
180
|
+
// stack's mapping can extend far above the stack itself, where the kernel merged
|
|
181
|
+
// it with an adjacent anonymous mapping of the same protection that has nothing
|
|
182
|
+
// to do with the fiber pool (measured: a 644KB stack inside an 8840KB mapping,
|
|
183
|
+
// with an unrelated 400MB mapping directly above). An entry that answered on the
|
|
184
|
+
// strength of that top would let a later allocation in the neighbour's range
|
|
185
|
+
// inherit a bottom megabytes below its own, and V8 would be told it has that
|
|
186
|
+
// much headroom on a 644KB stack.
|
|
187
|
+
//
|
|
188
|
+
// Each entry instead carries `seen`: the highest address a V8 op has ACTUALLY
|
|
189
|
+
// run at on that stack. It is both the top of the matching window and the bound
|
|
190
|
+
// returned to callers, so the scan-start test in StackScope::enter — "is this
|
|
191
|
+
// enclosing op's marker on the stack I am on?" — rests on an address we watched
|
|
192
|
+
// an op occupy rather than on the kernel's idea of where the mapping ends. A
|
|
193
|
+
// frame higher than anything seen so far misses once and widens the window in
|
|
194
|
+
// place, and a miss always re-queries, so it is only HITS that this has to keep
|
|
195
|
+
// honest.
|
|
196
|
+
//
|
|
197
|
+
// What it does not do is tell two stacks apart that genuinely share one mapping,
|
|
198
|
+
// since entries are keyed by the mapping's bottom: an op running higher up such
|
|
199
|
+
// a mapping re-queries, gets the same bottom back, and widens that one entry.
|
|
200
|
+
// Both then get the lower bottom and a window that spans both. That stays
|
|
201
|
+
// SAFE — one mapping means no guard page in between, so neither the limit nor a
|
|
202
|
+
// scan can walk into an unmapped page — but the upper stack is handed more
|
|
203
|
+
// headroom than it owns. It needs a stack with no guard page below it, which
|
|
204
|
+
// Ruby's fiber pool and pthread both rule out for the stacks we run on.
|
|
205
|
+
//
|
|
206
|
+
// Within that window a cached bottom is the fiber's real bottom: Ruby brackets
|
|
207
|
+
// every fiber stack with PROT_NONE guard pages, so the mapping cannot start
|
|
208
|
+
// below the stack, and a pooled slot is handed to the next fiber with identical
|
|
209
|
+
// bounds.
|
|
210
|
+
const FIBER_REGION_SLOTS: usize = 16;
|
|
211
|
+
|
|
212
|
+
#[derive(Clone, Copy)]
|
|
213
|
+
struct FiberRegion {
|
|
214
|
+
// The mapping's bottom, and the key: 0 marks the slot unused.
|
|
215
|
+
lo: usize,
|
|
216
|
+
// Highest address a V8 op has run at on this stack.
|
|
217
|
+
seen: usize,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
#[derive(Clone, Copy)]
|
|
221
|
+
struct FiberRegions {
|
|
222
|
+
slots: [FiberRegion; FIBER_REGION_SLOTS],
|
|
223
|
+
// xorshift64 state for picking a victim. Seeded to a fixed nonzero constant
|
|
224
|
+
// (the golden-ratio one): nothing here needs unpredictability, only that the
|
|
225
|
+
// eviction order doesn't line up with the caller's rotation through fibers.
|
|
226
|
+
rng: u64,
|
|
227
|
+
}
|
|
228
|
+
|
|
160
229
|
thread_local! {
|
|
161
|
-
static
|
|
230
|
+
static FIBER_REGIONS: std::cell::RefCell<FiberRegions> = const {
|
|
231
|
+
std::cell::RefCell::new(FiberRegions {
|
|
232
|
+
slots: [FiberRegion { lo: 0, seen: 0 }; FIBER_REGION_SLOTS],
|
|
233
|
+
rng: 0x9E37_79B9_7F4A_7C15,
|
|
234
|
+
})
|
|
235
|
+
};
|
|
162
236
|
}
|
|
163
237
|
|
|
238
|
+
// (bottom, highest-address-seen) of the stack `addr` is on. `addr` must be an
|
|
239
|
+
// address the CALLER knows is on that stack and as high up it as it can offer —
|
|
240
|
+
// it both answers the lookup and becomes the entry's `seen` on a miss, so a
|
|
241
|
+
// caller that passes its topmost frame gets the widest sound window. A RefCell
|
|
242
|
+
// rather than a Cell so a hit reads the slots in place instead of copying the
|
|
243
|
+
// whole table out on every fiber op.
|
|
164
244
|
fn current_region_bounds_cached(addr: usize) -> (usize, usize) {
|
|
165
|
-
|
|
166
|
-
let (
|
|
167
|
-
|
|
168
|
-
|
|
245
|
+
FIBER_REGIONS.with(|c| {
|
|
246
|
+
if let Some(r) = c
|
|
247
|
+
.borrow()
|
|
248
|
+
.slots
|
|
249
|
+
.iter()
|
|
250
|
+
.find(|r| r.lo != 0 && addr >= r.lo && addr <= r.seen)
|
|
251
|
+
{
|
|
252
|
+
return (r.lo, r.seen);
|
|
169
253
|
}
|
|
170
|
-
let
|
|
171
|
-
if
|
|
172
|
-
|
|
254
|
+
let (lo, _) = query_region_bounds(addr);
|
|
255
|
+
if lo == 0 {
|
|
256
|
+
return (0, 0);
|
|
173
257
|
}
|
|
174
|
-
|
|
258
|
+
let mut cache = c.borrow_mut();
|
|
259
|
+
// Prefer the entry for a stack we already know (a frame above anything
|
|
260
|
+
// seen on it so far, which just widens the window), then an empty slot,
|
|
261
|
+
// and only then evict.
|
|
262
|
+
let slot = cache
|
|
263
|
+
.slots
|
|
264
|
+
.iter()
|
|
265
|
+
.position(|r| r.lo == lo)
|
|
266
|
+
.or_else(|| cache.slots.iter().position(|r| r.lo == 0))
|
|
267
|
+
.unwrap_or_else(|| {
|
|
268
|
+
cache.rng ^= cache.rng << 13;
|
|
269
|
+
cache.rng ^= cache.rng >> 7;
|
|
270
|
+
cache.rng ^= cache.rng << 17;
|
|
271
|
+
(cache.rng % FIBER_REGION_SLOTS as u64) as usize
|
|
272
|
+
});
|
|
273
|
+
cache.slots[slot] = FiberRegion { lo, seen: addr };
|
|
274
|
+
(lo, addr)
|
|
175
275
|
})
|
|
176
276
|
}
|
|
177
277
|
|
|
178
278
|
// The [start, end) of the /proc/self/maps mapping containing `addr`. Linux only;
|
|
179
279
|
// (0, 0) elsewhere (and the caller falls back). Reads the file fresh — slow, so
|
|
180
280
|
// only called on a cache miss (a new fiber).
|
|
281
|
+
//
|
|
282
|
+
// Everything that needs a FIBER's real bounds degrades on the (0, 0) platforms,
|
|
283
|
+
// macOS being the shipped one: the stack limit drops to a fixed budget below the
|
|
284
|
+
// SP (see StackScope::enter), and a nested op can no longer widen its GC-scan
|
|
285
|
+
// start to the enclosing op's, so it narrows to its own frame. Both are the
|
|
286
|
+
// pre-fiber-support behaviour rather than a crash, but a darwin implementation
|
|
287
|
+
// (mach_vm_region on the current task) would close the gap.
|
|
181
288
|
#[cfg(target_os = "linux")]
|
|
182
289
|
fn query_region_bounds(addr: usize) -> (usize, usize) {
|
|
183
290
|
use std::io::Read;
|
|
@@ -196,13 +303,12 @@ fn query_region_bounds(addr: usize) -> (usize, usize) {
|
|
|
196
303
|
let Some((lo, hi)) = range.split_once('-') else {
|
|
197
304
|
continue;
|
|
198
305
|
};
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
306
|
+
let (Ok(lo), Ok(hi)) = (usize::from_str_radix(lo, 16), usize::from_str_radix(hi, 16))
|
|
307
|
+
else {
|
|
308
|
+
continue;
|
|
309
|
+
};
|
|
310
|
+
if addr >= lo && addr < hi {
|
|
311
|
+
return (lo, hi);
|
|
206
312
|
}
|
|
207
313
|
}
|
|
208
314
|
(0, 0)
|
|
@@ -216,11 +322,17 @@ fn query_region_bounds(_addr: usize) -> (usize, usize) {
|
|
|
216
322
|
// Set from RUSTY_RACER_STACK_DEBUG at init; gates the per-op stack diagnostics.
|
|
217
323
|
pub(crate) static STACK_DEBUG: AtomicBool = AtomicBool::new(false);
|
|
218
324
|
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
325
|
+
// How V8 describes the stack an op runs on: the stack limit, and — on a Ruby
|
|
326
|
+
// Fiber — the conservative-GC-scan start. Both live in the ISOLATE, not in the
|
|
327
|
+
// op, so an op that runs on a DIFFERENT stack than the one enclosing it (a host
|
|
328
|
+
// callback that resumes a Fiber which evals again) has to install its own and
|
|
329
|
+
// put the enclosing op's back on the way out. Hence a scope: after enter() V8
|
|
330
|
+
// describes the stack we are on now, after the drop the one it described before.
|
|
331
|
+
//
|
|
332
|
+
// In-thread V8 runs wherever the Ruby code is: usually the native thread's
|
|
333
|
+
// pthread stack, but also a Ruby Fiber's separate mmap'd stack (Capybara::Result
|
|
334
|
+
// is an Enumerator) that pthread can't see. The limit MUST sit between the
|
|
335
|
+
// current SP and the real bottom of whatever stack we're on:
|
|
224
336
|
// * Too high (above SP) and V8 declares a FALSE overflow on entry.
|
|
225
337
|
// * Too low (below the real bottom) and a deep recursion grows past the
|
|
226
338
|
// mapped stack and SEGVs the unmapped guard page below it.
|
|
@@ -228,85 +340,250 @@ pub(crate) static STACK_DEBUG: AtomicBool = AtomicBool::new(false);
|
|
|
228
340
|
// native stack, anchor to its pthread bottom; on a fiber, find the bottom of the
|
|
229
341
|
// /proc/self/maps region holding the SP (the fiber's real bottom — anchoring to
|
|
230
342
|
// SP minus a fixed guard punched through the bottom of Avo's small/deep Capybara
|
|
231
|
-
// fibers and SEGV'd).
|
|
232
|
-
//
|
|
343
|
+
// fibers and SEGV'd).
|
|
344
|
+
//
|
|
345
|
+
// The limit does double duty, which is why a STALE one is fatal rather than
|
|
346
|
+
// merely wrong: v8::Isolate::SetStackLimit also sets stack_size_ to
|
|
347
|
+
// (base::Stack::GetStackStart() - limit), and V8's "am I on the central stack?"
|
|
348
|
+
// test is (native_top - stack_size_ - margin) < addr <= native_top — that is,
|
|
349
|
+
// exactly (limit - margin, native_top]. Throwing (and GC) CHECKs that window and
|
|
350
|
+
// V8_Fatals the process on a miss. So an op running on a fiber under the
|
|
351
|
+
// ENCLOSING op's native-stack limit doesn't merely false-overflow: the
|
|
352
|
+
// RangeError it then tries to throw aborts. Installing the limit for the stack
|
|
353
|
+
// we are actually on fixes both at once.
|
|
233
354
|
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
355
|
+
// RESIDUAL (GC scan across a stack SWITCH): V8 tracks ONE scan segment per
|
|
356
|
+
// isolate, so while a nested op runs on a fiber its enclosing op's frames on the
|
|
357
|
+
// NATIVE stack aren't conservatively scanned — the two live in different
|
|
358
|
+
// mappings and only one range can be described. (A nested op that did NOT switch
|
|
359
|
+
// stacks is fine: it keeps the enclosing op's start, which covers both.) Handles
|
|
360
|
+
// are unaffected — this
|
|
361
|
+
// build has v8_enable_direct_handle off, so every Local lives in a HandleScope
|
|
362
|
+
// block and is iterated precisely, and JS frames are walked precisely from the
|
|
363
|
+
// frame pointers whichever stack they sit on. Only a raw Tagged<> in V8's own
|
|
364
|
+
// C++ frames would be missed, and V8 keeps those in handles across anything that
|
|
365
|
+
// can allocate. There's no fix available: Stack has no API to register a second
|
|
366
|
+
// segment for the same thread.
|
|
240
367
|
//
|
|
241
|
-
// LIMITATION (worker-thread fibers): the
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
// ABOVE that top
|
|
246
|
-
// below later fiber mmaps
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
pub(crate)
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
// V8
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
368
|
+
// LIMITATION (worker-thread fibers): only the window's LOWER bound follows the
|
|
369
|
+
// limit. Its upper bound is base::Stack::GetStackStart(), the native pthread
|
|
370
|
+
// top, cached per thread with no way to retarget it (on POSIX
|
|
371
|
+
// base::Stack::SetCurrentThreadStackBounds is UNREACHABLE()). A fiber mmap'd
|
|
372
|
+
// ABOVE that top — the common case on a NON-main native thread, whose stack sits
|
|
373
|
+
// below later fiber mmaps — is outside the window whatever limit we set, so V8
|
|
374
|
+
// aborts on the next throw or GC. On the main thread the process stack is the
|
|
375
|
+
// highest address, so every fiber is below it and the window covers it — the
|
|
376
|
+
// Capybara/Avo case. See README.
|
|
377
|
+
pub(crate) struct StackScope<'a> {
|
|
378
|
+
real_isolate: *mut c_void,
|
|
379
|
+
// Address of V8's conservative-GC-scan start field, or 0 when enter()
|
|
380
|
+
// installed nothing (discovery failed, or no sane limit) — the drop then
|
|
381
|
+
// restores nothing.
|
|
382
|
+
scan_start_field: usize,
|
|
383
|
+
// The limit V8 currently holds for this isolate. Tracked by the caller
|
|
384
|
+
// because V8 exposes no getter, and a nested op needs the enclosing op's
|
|
385
|
+
// value to put back. 0 before the isolate's first op.
|
|
386
|
+
installed_limit: &'a AtomicUsize,
|
|
387
|
+
// What the drop has to put back, or 0 for "nothing changed, nothing to undo".
|
|
388
|
+
// Most re-entrant ops run in deeper frames of the SAME stack and so compute
|
|
389
|
+
// the very values already installed; skipping those writes keeps a nested op
|
|
390
|
+
// as cheap as it was before it started describing its own stack.
|
|
391
|
+
restore_limit: usize,
|
|
392
|
+
restore_scan_start: usize,
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
impl<'a> StackScope<'a> {
|
|
396
|
+
// Describe the stack THIS call runs on to the isolate, until the drop. Must
|
|
397
|
+
// be called with the isolate ENTERED: Isolate::Enter sets the scan start to
|
|
398
|
+
// the native top, so entering afterwards would clobber a fiber override.
|
|
399
|
+
//
|
|
400
|
+
// `enclosing_scan_start` is the scan start of the op this one is nested in,
|
|
401
|
+
// or None at the outermost op, where no enclosing op exists — then the drop
|
|
402
|
+
// restores nothing (the values left behind belong to no live frame, and the
|
|
403
|
+
// next op installs its own before any JS runs). Because Enter re-points the
|
|
404
|
+
// field, it must be read BEFORE entering. It doubles as the value the drop
|
|
405
|
+
// puts back and, when a nested op didn't switch stacks, as this op's own
|
|
406
|
+
// start (see below). `real_isolate` is the raw v8::Isolate* read out of
|
|
407
|
+
// iso_ptr; `stack_top` is a live address the caller captured ABOVE every V8
|
|
408
|
+
// frame of this op (used only on a fiber).
|
|
409
|
+
pub(crate) fn enter(
|
|
410
|
+
real_isolate: *mut c_void,
|
|
411
|
+
scan_start_field: usize,
|
|
412
|
+
enclosing_scan_start: Option<usize>,
|
|
413
|
+
installed_limit: &'a AtomicUsize,
|
|
414
|
+
stack_top: usize,
|
|
415
|
+
) -> Self {
|
|
416
|
+
// Only a nested op has an enclosing description to preserve and restore.
|
|
417
|
+
let nested = enclosing_scan_start.is_some();
|
|
418
|
+
// 0 doubles as "no enclosing value" throughout: it is never a real start.
|
|
419
|
+
let prev_scan_start = enclosing_scan_start.unwrap_or(0);
|
|
420
|
+
let sp_marker = 0u8;
|
|
421
|
+
let sp = &sp_marker as *const u8 as usize;
|
|
422
|
+
let (nbottom, ntop) = native_stack_bounds_cached();
|
|
423
|
+
let on_native = nbottom != 0 && sp > nbottom && sp <= ntop;
|
|
424
|
+
// Reserve below the limit for V8's own RangeError-throw frames.
|
|
425
|
+
const NATIVE_GUARD: usize = 128 * 1024;
|
|
426
|
+
// V8 throws when SP descends to the limit, then needs some real stack
|
|
427
|
+
// BELOW it to build the RangeError (and V8 itself allows growing a little
|
|
428
|
+
// past the limit — its overflow slack). On a fiber that reserve must NOT
|
|
429
|
+
// cross the fiber's real bottom (the mapping below it is an unmapped
|
|
430
|
+
// guard -> SEGV), so keep it comfortably above V8's slack.
|
|
431
|
+
const FIBER_RESERVE: usize = 64 * 1024;
|
|
432
|
+
let mut region = (0usize, 0usize);
|
|
433
|
+
let limit = if on_native {
|
|
434
|
+
nbottom + NATIVE_GUARD
|
|
276
435
|
} else {
|
|
277
|
-
|
|
436
|
+
// Anchor to the FIBER's real bottom (the /proc/self/maps region
|
|
437
|
+
// holding the SP), not the SP: SP - fixed_guard can punch through the
|
|
438
|
+
// bottom of a small/deep fiber stack and SEGV (Avo's deep Capybara
|
|
439
|
+
// filter chain). Reserve FIBER_RESERVE above the bottom for the
|
|
440
|
+
// throw, but keep the limit below the SP so we don't false-overflow;
|
|
441
|
+
// on a nearly-full fiber that clamps the headroom down (an early but
|
|
442
|
+
// CLEAN RangeError).
|
|
443
|
+
// Look it up by `stack_top`, not the SP: it is this op's highest
|
|
444
|
+
// frame, so it widens the cache's window as far as this op honestly
|
|
445
|
+
// can, and the scan-start test below then has a bound it can trust.
|
|
446
|
+
// Falls back to the SP if the caller had no marker to offer.
|
|
447
|
+
region = current_region_bounds_cached(if stack_top != 0 { stack_top } else { sp });
|
|
448
|
+
if region.0 != 0 {
|
|
449
|
+
(region.0 + FIBER_RESERVE).min(sp.saturating_sub(8 * 1024))
|
|
450
|
+
} else {
|
|
451
|
+
// Region unknown — only Linux can look a fiber's bounds up (see
|
|
452
|
+
// query_region_bounds), so this is the macOS path. Best effort:
|
|
453
|
+
// hand JS a fixed budget below the SP and hope the fiber is that
|
|
454
|
+
// deep. It usually is for an OUTERMOST fiber op, whose SP sits
|
|
455
|
+
// near the fiber's top, and much less reliably for a NESTED one,
|
|
456
|
+
// whose SP is already far down the fiber — there this can land
|
|
457
|
+
// below the fiber's mapped bottom, and V8 then grows through the
|
|
458
|
+
// guard page (SEGV) instead of throwing. Fixing that properly
|
|
459
|
+
// means a mach_vm_region lookup for darwin.
|
|
460
|
+
sp.saturating_sub(64 * 1024)
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
// Opt-in diagnostics (RUSTY_RACER_STACK_DEBUG): the SP vs the native
|
|
464
|
+
// stack [nbottom, ntop], the fiber window (if any), the per-op limit, and
|
|
465
|
+
// whether the SP is above the limit. A crash with sp_above_limit=false
|
|
466
|
+
// means the limit is wrong for the current stack. The window is printed
|
|
467
|
+
// CLOSED because its top is the highest address an op was seen at on that
|
|
468
|
+
// stack, not the mapping's end — see current_region_bounds_cached.
|
|
469
|
+
if STACK_DEBUG.load(Ordering::Relaxed) {
|
|
470
|
+
eprintln!(
|
|
471
|
+
"[rusty stack] sp={sp:#x} nbottom={nbottom:#x} ntop={ntop:#x} \
|
|
472
|
+
window=[{:#x},{:#x}] limit={limit:#x} fiber={} sp_above_limit={} \
|
|
473
|
+
fiber_above_native={}",
|
|
474
|
+
region.0,
|
|
475
|
+
region.1,
|
|
476
|
+
!on_native,
|
|
477
|
+
sp > limit,
|
|
478
|
+
!on_native && nbottom != 0 && sp > ntop,
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
if limit == 0 {
|
|
482
|
+
// Couldn't determine a sane limit — leave V8's default in place, and
|
|
483
|
+
// with it a scope that restores nothing.
|
|
484
|
+
return Self {
|
|
485
|
+
real_isolate,
|
|
486
|
+
scan_start_field: 0,
|
|
487
|
+
installed_limit,
|
|
488
|
+
restore_limit: 0,
|
|
489
|
+
restore_scan_start: 0,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
// installed_limit is the sole record of what V8 holds (it has no getter),
|
|
493
|
+
// so an equal value means the isolate is already described correctly and
|
|
494
|
+
// the call can be skipped. Nothing else moves the limit behind our back:
|
|
495
|
+
// Isolate::Enter/Exit leave the stack guard alone, and V8 re-points it
|
|
496
|
+
// itself only under v8::Locker thread archiving (rusty_v8 exposes no
|
|
497
|
+
// Locker, and an isolate here is thread-confined) or wasm stack
|
|
498
|
+
// switching (no wasm stacks exist in this embedding).
|
|
499
|
+
let prev_limit = installed_limit.load(Ordering::Relaxed);
|
|
500
|
+
let changed_limit = limit != prev_limit;
|
|
501
|
+
if changed_limit {
|
|
502
|
+
unsafe { v8__Isolate__SetStackLimit(real_isolate, limit) };
|
|
503
|
+
installed_limit.store(limit, Ordering::Relaxed);
|
|
504
|
+
}
|
|
505
|
+
// Only a nested op owes anything back: at the outermost op prev_limit is
|
|
506
|
+
// the previous, already-finished op's, which describes no live frame.
|
|
507
|
+
let restore_limit = if nested && changed_limit {
|
|
508
|
+
prev_limit
|
|
509
|
+
} else {
|
|
510
|
+
0
|
|
511
|
+
};
|
|
512
|
+
// Point V8's conservative-GC-scan start at the stack we're on. The
|
|
513
|
+
// scanner walks [marker, start), so on a fiber a native start runs it off
|
|
514
|
+
// the fiber's mapped top into unmapped memory and SEGVs (Avo's Capybara
|
|
515
|
+
// filter chain). Always take the WIDEST start that is still on this
|
|
516
|
+
// stack, because everything between the marker and it gets scanned and an
|
|
517
|
+
// enclosing op's frames sit above ours:
|
|
518
|
+
// * native stack — the native top, i.e. what V8 itself uses.
|
|
519
|
+
// * a fiber we ENTERED (the enclosing op is elsewhere) — this op's
|
|
520
|
+
// `stack_top`, which keeps the range between two real stack pointers
|
|
521
|
+
// (marker..stack_top) and so guaranteed mapped. We can't use the
|
|
522
|
+
// /proc/maps region top: that mapping isn't reliably contiguous, so
|
|
523
|
+
// the scan could still hit a hole below it.
|
|
524
|
+
// * a fiber we were ALREADY on (a nested op under a host callback that
|
|
525
|
+
// didn't switch stacks) — the enclosing op's start, which is above
|
|
526
|
+
// ours and covers both ops' frames. Accepted only when it is at or
|
|
527
|
+
// below the highest address an op has been seen running at on this
|
|
528
|
+
// stack (the cache's `seen`, NOT the mapping top, which can reach far
|
|
529
|
+
// into an unrelated neighbour — see current_region_bounds_cached);
|
|
530
|
+
// otherwise we cannot tell it is on our stack at all, and adopting it
|
|
531
|
+
// would send the scan up through our guard page. Since that window
|
|
532
|
+
// lives in a cache with random eviction, the test can also fail
|
|
533
|
+
// because the entry was evicted between the two ops — the scan then
|
|
534
|
+
// narrows to this op's own frame, i.e. the same residual documented
|
|
535
|
+
// there for a real stack switch, now reachable at random once more
|
|
536
|
+
// fiber stacks are live than the cache holds.
|
|
537
|
+
let new_scan_start = if on_native {
|
|
538
|
+
unsafe { v8__base__Stack__GetStackStart() }
|
|
539
|
+
} else if nested && region.0 != 0 && prev_scan_start > sp && prev_scan_start <= region.1 {
|
|
540
|
+
prev_scan_start
|
|
541
|
+
} else {
|
|
542
|
+
stack_top
|
|
543
|
+
};
|
|
544
|
+
// Install it, and work out what the drop owes the enclosing op. The value
|
|
545
|
+
// to put back is the caller's PRE-ENTER snapshot, not whatever the field
|
|
546
|
+
// holds now: Isolate::Enter re-points the start at the native top, so on
|
|
547
|
+
// the foreign-isolate reentry path the field has already been clobbered
|
|
548
|
+
// by the time we get here, and restoring that would strand an enclosing
|
|
549
|
+
// fiber op with a start on the wrong stack.
|
|
550
|
+
let restore_scan_start = if scan_start_field != 0 && new_scan_start != 0 {
|
|
551
|
+
let field = scan_start_field as *mut usize;
|
|
552
|
+
if unsafe { field.read() } != new_scan_start {
|
|
553
|
+
unsafe { field.write(new_scan_start) };
|
|
554
|
+
}
|
|
555
|
+
if nested && prev_scan_start != 0 && prev_scan_start != new_scan_start {
|
|
556
|
+
prev_scan_start
|
|
557
|
+
} else {
|
|
558
|
+
0 // nothing enclosing to put back, or the drop would be a no-op
|
|
559
|
+
}
|
|
560
|
+
} else {
|
|
561
|
+
0 // nothing installed -> nothing to undo
|
|
562
|
+
};
|
|
563
|
+
Self {
|
|
564
|
+
real_isolate,
|
|
565
|
+
scan_start_field,
|
|
566
|
+
installed_limit,
|
|
567
|
+
restore_limit,
|
|
568
|
+
restore_scan_start,
|
|
278
569
|
}
|
|
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
570
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
//
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
impl Drop for StackScope<'_> {
|
|
574
|
+
// Put the enclosing op's description back, in the reverse order enter()
|
|
575
|
+
// installed it. A zero means there is nothing to put back: enter() changed
|
|
576
|
+
// nothing, or this was the isolate's first op and there is no earlier
|
|
577
|
+
// description. Leaving the last op's settings behind is harmless — every op
|
|
578
|
+
// installs its own before any JS runs.
|
|
579
|
+
fn drop(&mut self) {
|
|
580
|
+
if self.scan_start_field != 0 && self.restore_scan_start != 0 {
|
|
581
|
+
unsafe { (self.scan_start_field as *mut usize).write(self.restore_scan_start) };
|
|
582
|
+
}
|
|
583
|
+
if self.restore_limit != 0 {
|
|
584
|
+
unsafe { v8__Isolate__SetStackLimit(self.real_isolate, self.restore_limit) };
|
|
585
|
+
self.installed_limit
|
|
586
|
+
.store(self.restore_limit, Ordering::Relaxed);
|
|
587
|
+
}
|
|
311
588
|
}
|
|
312
589
|
}
|
|
@@ -224,7 +224,10 @@ fn capture_js_backtrace(scope: &mut v8::PinScope<'_, '_>, max_frames: usize) ->
|
|
|
224
224
|
// bloat the error.
|
|
225
225
|
fn clamp_name(mut s: String) -> String {
|
|
226
226
|
if s.len() > MAX_FRAME_NAME {
|
|
227
|
-
let end = (0..=MAX_FRAME_NAME)
|
|
227
|
+
let end = (0..=MAX_FRAME_NAME)
|
|
228
|
+
.rev()
|
|
229
|
+
.find(|&i| s.is_char_boundary(i))
|
|
230
|
+
.unwrap_or(0);
|
|
228
231
|
s.truncate(end);
|
|
229
232
|
s.push('…');
|
|
230
233
|
}
|
|
@@ -255,7 +258,10 @@ pub(crate) fn arm_watchdog(scope: &mut v8::PinScope<'_, '_, ()>, timeout_ms: u64
|
|
|
255
258
|
// Terminated and the outermost frame sweeps the leftover terminate via
|
|
256
259
|
// WATCHDOG_FIRED; removing only this frame keeps a late terminate from
|
|
257
260
|
// poisoning the next request without clobbering a still-running outer op.
|
|
258
|
-
pub(crate) fn disarm_watchdog(
|
|
261
|
+
pub(crate) fn disarm_watchdog(
|
|
262
|
+
scope: &mut v8::PinScope<'_, '_, ()>,
|
|
263
|
+
generation: Option<u64>,
|
|
264
|
+
) -> bool {
|
|
259
265
|
let Some(generation) = generation else {
|
|
260
266
|
return false;
|
|
261
267
|
};
|
data/lib/rusty_racer/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rusty_racer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.15
|
|
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-
|
|
11
|
+
date: 2026-07-28 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rb_sys
|