rusty_racer 0.1.9 → 0.1.10
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/ext/rusty_racer/Cargo.toml +1 -1
- data/ext/rusty_racer/src/lib.rs +85 -17
- data/ext/rusty_racer/src/watchdog.rs +123 -2
- data/lib/rusty_racer/version.rb +1 -1
- data/lib/rusty_racer.rb +8 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7df2aeec586e04d155d2635f6df9b51e219b31108fad5829e7a5ed5eb8a784a3
|
|
4
|
+
data.tar.gz: '099e1e39f64e041d082fb86269ae4265406e4f05279e3d24909a9e6f4fa89de0'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5d8f2b15aca012b8c3933b960009e1d248cf2284774eff23c4740688796dfff97d4fe647fc0792f68e70fe4a66aa4fb4cdb7869451a82fac625178f57b8f56d2
|
|
7
|
+
data.tar.gz: c66d30a7c1bfd8d00f62c7ee99e6356f1f9f6c4a8aeebab9e05552a945c51d54f4dd31f3787fa95bd60e425e1dae3240fdb90186c9c2bb87a7b945a0b55b06cd
|
data/ext/rusty_racer/Cargo.toml
CHANGED
data/ext/rusty_racer/src/lib.rs
CHANGED
|
@@ -629,6 +629,12 @@ struct IsolateState {
|
|
|
629
629
|
// platform-derived default, whose value we don't otherwise know — so we restore
|
|
630
630
|
// to this captured initial instead. 0 until the callback has fired at least once.
|
|
631
631
|
oom_initial_limit: usize,
|
|
632
|
+
// The JS stack captured at a watchdog timeout: when a deadline fires, the
|
|
633
|
+
// watchdog requests an interrupt that runs on THIS thread with JS still live
|
|
634
|
+
// (see timeout_interrupt) and snapshots the stack here BEFORE terminating, so
|
|
635
|
+
// the ScriptTerminatedError can name what was running. Taken (cleared) when the
|
|
636
|
+
// terminated op's error is built; None for any non-timeout outcome.
|
|
637
|
+
timeout_backtrace: Option<Vec<String>>,
|
|
632
638
|
}
|
|
633
639
|
|
|
634
640
|
impl IsolateState {
|
|
@@ -652,6 +658,7 @@ impl IsolateState {
|
|
|
652
658
|
watchdog: WatchdogShared::new(),
|
|
653
659
|
oom_fired: false,
|
|
654
660
|
oom_initial_limit: 0,
|
|
661
|
+
timeout_backtrace: None,
|
|
655
662
|
}
|
|
656
663
|
}
|
|
657
664
|
}
|
|
@@ -1972,6 +1979,10 @@ impl Isolate {
|
|
|
1972
1979
|
// Registered unconditionally — with memory_limit it guards that ceiling,
|
|
1973
1980
|
// without one it guards V8's default ceiling (catchable, not a process abort).
|
|
1974
1981
|
boxed.add_near_heap_limit_callback(near_heap_limit_cb, iso_ptr.0 as *mut c_void);
|
|
1982
|
+
// Wire the watchdog's interrupt target now that iso_ptr is stable: on a
|
|
1983
|
+
// timeout the loop requests an interrupt against this ptr to capture the JS
|
|
1984
|
+
// stack on the isolate thread before terminating (see timeout_interrupt).
|
|
1985
|
+
watchdog.set_iso_ptr(iso_ptr.0 as *mut c_void);
|
|
1975
1986
|
let iso_id = NEXT_ISOLATE_ID.fetch_add(1, Ordering::SeqCst);
|
|
1976
1987
|
isolates().lock().unwrap().insert(iso_id, SendIso(boxed));
|
|
1977
1988
|
// Root the owner Thread VALUE so its address can't be reused while this
|
|
@@ -2049,6 +2060,16 @@ impl Core {
|
|
|
2049
2060
|
self.ensure_owner_and_live(ruby)?;
|
|
2050
2061
|
let iso = self.iso_ptr.0;
|
|
2051
2062
|
let depth = self.depth.fetch_add(1, Ordering::SeqCst);
|
|
2063
|
+
// Drop any prior timeout's captured stack at the START of an OUTERMOST op,
|
|
2064
|
+
// so it can't leak onto this op's error. Cleared only at depth 0 (not for
|
|
2065
|
+
// nested ops) on purpose: a nested timeout's terminate is isolate-global
|
|
2066
|
+
// and tears down the whole op stack, so EVERY frame's error (nested and the
|
|
2067
|
+
// escalated outer one) should surface the same culprit — reply_value peeks
|
|
2068
|
+
// (clones) rather than takes, leaving it readable for the outer frame until
|
|
2069
|
+
// the next outermost op clears it here.
|
|
2070
|
+
if depth == 0 {
|
|
2071
|
+
istate!(unsafe { &mut *iso }).timeout_backtrace = None;
|
|
2072
|
+
}
|
|
2052
2073
|
// EVERYTHING that touches V8 — enter, the scope, the JS run, the scope
|
|
2053
2074
|
// drop, exit — happens inside ONE without_gvl, hence on ONE native thread
|
|
2054
2075
|
// with no GVL boundary in between: M:N could otherwise migrate us to a
|
|
@@ -2196,9 +2217,18 @@ impl Core {
|
|
|
2196
2217
|
}
|
|
2197
2218
|
|
|
2198
2219
|
// Map a terminal reply to a Ruby value (the common eval/call/run shape).
|
|
2199
|
-
|
|
2220
|
+
// &self so a Terminated outcome can pick up the JS stack the watchdog snapshot
|
|
2221
|
+
// captured (see timeout_interrupt / take_timeout_backtrace).
|
|
2222
|
+
fn reply_value(&self, ruby: &Ruby, reply: VmReply) -> Result<Value, Error> {
|
|
2200
2223
|
match reply {
|
|
2201
2224
|
VmReply::Done(Ok(val)) => jsval_to_ruby(ruby, &val),
|
|
2225
|
+
// Clone (don't take): a nested timeout's terminate unwinds the whole op
|
|
2226
|
+
// stack, so the escalated outer frame's error should name the same
|
|
2227
|
+
// culprit too. The capture is dropped at the next outermost op's start
|
|
2228
|
+
// (run), which keeps it from leaking onto an unrelated later op.
|
|
2229
|
+
VmReply::Done(Err(VmError::Terminated)) => {
|
|
2230
|
+
Err(terminated_error(ruby, self.peek_timeout_backtrace()))
|
|
2231
|
+
}
|
|
2202
2232
|
VmReply::Done(Err(e)) => Err(vm_err(ruby, e)),
|
|
2203
2233
|
_ => Err(Error::new(
|
|
2204
2234
|
ruby.exception_runtime_error(),
|
|
@@ -2207,6 +2237,14 @@ impl Core {
|
|
|
2207
2237
|
}
|
|
2208
2238
|
}
|
|
2209
2239
|
|
|
2240
|
+
// Clone the JS stack the watchdog's interrupt captured at the last timeout.
|
|
2241
|
+
// Owner thread, isolate live — called right after run() returns, the same
|
|
2242
|
+
// access pattern as swap_instantiate. Does NOT clear it (run() clears at the
|
|
2243
|
+
// next outermost op) so a nested timeout's outer frame can read it too.
|
|
2244
|
+
fn peek_timeout_backtrace(&self) -> Option<Vec<String>> {
|
|
2245
|
+
istate!(unsafe { &mut *self.iso_ptr.0 }).timeout_backtrace.clone()
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2210
2248
|
fn call_proc(
|
|
2211
2249
|
&self,
|
|
2212
2250
|
ruby: &Ruby,
|
|
@@ -2268,14 +2306,14 @@ impl Core {
|
|
|
2268
2306
|
void,
|
|
2269
2307
|
timeout_ms: self.default_timeout_ms,
|
|
2270
2308
|
})?;
|
|
2271
|
-
|
|
2309
|
+
self.reply_value(ruby, reply)
|
|
2272
2310
|
}
|
|
2273
2311
|
|
|
2274
2312
|
fn drain_microtasks(&self, ruby: &Ruby) -> Result<Value, Error> {
|
|
2275
2313
|
let reply = self.run(ruby, Request::DrainMicrotasks {
|
|
2276
2314
|
timeout_ms: self.default_timeout_ms,
|
|
2277
2315
|
})?;
|
|
2278
|
-
|
|
2316
|
+
self.reply_value(ruby, reply)
|
|
2279
2317
|
}
|
|
2280
2318
|
|
|
2281
2319
|
// Isolate#heap_statistics -> a Symbol-keyed Hash of v8::HeapStatistics
|
|
@@ -2303,7 +2341,7 @@ impl Core {
|
|
|
2303
2341
|
// Isolate#low_memory_notification: ask V8 to run a full GC now.
|
|
2304
2342
|
fn low_memory_notification(&self, ruby: &Ruby) -> Result<(), Error> {
|
|
2305
2343
|
let reply = self.run(ruby, Request::LowMemoryNotification)?;
|
|
2306
|
-
|
|
2344
|
+
self.reply_value(ruby, reply)?;
|
|
2307
2345
|
Ok(())
|
|
2308
2346
|
}
|
|
2309
2347
|
|
|
@@ -2321,7 +2359,7 @@ impl Core {
|
|
|
2321
2359
|
filename,
|
|
2322
2360
|
timeout_ms,
|
|
2323
2361
|
})?;
|
|
2324
|
-
|
|
2362
|
+
self.reply_value(ruby, reply)
|
|
2325
2363
|
}
|
|
2326
2364
|
|
|
2327
2365
|
fn attach(&self, ruby: &Ruby, context_id: i32, name: String, proc: Proc) -> Result<Value, Error> {
|
|
@@ -2335,7 +2373,7 @@ impl Core {
|
|
|
2335
2373
|
host_fn_id,
|
|
2336
2374
|
timeout_ms: self.default_timeout_ms,
|
|
2337
2375
|
})?;
|
|
2338
|
-
|
|
2376
|
+
self.reply_value(ruby, reply)
|
|
2339
2377
|
}
|
|
2340
2378
|
|
|
2341
2379
|
// attach_many: install several host fns in ONE round-trip to the V8 thread
|
|
@@ -2367,7 +2405,7 @@ impl Core {
|
|
|
2367
2405
|
entries: named_ids,
|
|
2368
2406
|
timeout_ms: self.default_timeout_ms,
|
|
2369
2407
|
})?;
|
|
2370
|
-
|
|
2408
|
+
self.reply_value(ruby, reply)
|
|
2371
2409
|
}
|
|
2372
2410
|
|
|
2373
2411
|
// Release the GC roots of the procs attached into |context_id| — its
|
|
@@ -2381,7 +2419,7 @@ impl Core {
|
|
|
2381
2419
|
|
|
2382
2420
|
fn reset(&self, ruby: &Ruby, context_id: i32) -> Result<Value, Error> {
|
|
2383
2421
|
let reply = self.run(ruby, Request::Reset { context_id })?;
|
|
2384
|
-
let out =
|
|
2422
|
+
let out = self.reply_value(ruby, reply)?;
|
|
2385
2423
|
// Only on success — a refused reset (unknown/suspended realm) keeps
|
|
2386
2424
|
// its attached fns callable.
|
|
2387
2425
|
self.release_context_procs(context_id);
|
|
@@ -2391,13 +2429,13 @@ impl Core {
|
|
|
2391
2429
|
// Build a new context; returns its id (replied as an Int).
|
|
2392
2430
|
fn create_context(&self, ruby: &Ruby) -> Result<i32, Error> {
|
|
2393
2431
|
let reply = self.run(ruby, Request::CreateContext)?;
|
|
2394
|
-
let id =
|
|
2432
|
+
let id = self.reply_value(ruby, reply)?;
|
|
2395
2433
|
i32::try_convert(id)
|
|
2396
2434
|
}
|
|
2397
2435
|
|
|
2398
2436
|
fn dispose_context(&self, ruby: &Ruby, context_id: i32) -> Result<(), Error> {
|
|
2399
2437
|
let reply = self.run(ruby, Request::DisposeContext { context_id })?;
|
|
2400
|
-
|
|
2438
|
+
self.reply_value(ruby, reply)?;
|
|
2401
2439
|
self.release_context_procs(context_id);
|
|
2402
2440
|
Ok(())
|
|
2403
2441
|
}
|
|
@@ -2470,7 +2508,7 @@ impl Core {
|
|
|
2470
2508
|
if let Some(exc) = resolver_err {
|
|
2471
2509
|
return Err(Error::from(*exc));
|
|
2472
2510
|
}
|
|
2473
|
-
|
|
2511
|
+
self.reply_value(ruby, reply?)
|
|
2474
2512
|
}
|
|
2475
2513
|
|
|
2476
2514
|
fn evaluate_module(&self, ruby: &Ruby, module_id: i32) -> Result<Value, Error> {
|
|
@@ -2478,22 +2516,22 @@ impl Core {
|
|
|
2478
2516
|
module_id,
|
|
2479
2517
|
timeout_ms: self.default_timeout_ms,
|
|
2480
2518
|
})?;
|
|
2481
|
-
|
|
2519
|
+
self.reply_value(ruby, reply)
|
|
2482
2520
|
}
|
|
2483
2521
|
|
|
2484
2522
|
fn module_namespace(&self, ruby: &Ruby, module_id: i32) -> Result<Value, Error> {
|
|
2485
2523
|
let reply = self.run(ruby, Request::ModuleNamespace { module_id })?;
|
|
2486
|
-
|
|
2524
|
+
self.reply_value(ruby, reply)
|
|
2487
2525
|
}
|
|
2488
2526
|
|
|
2489
2527
|
fn module_status(&self, ruby: &Ruby, module_id: i32) -> Result<Value, Error> {
|
|
2490
2528
|
let reply = self.run(ruby, Request::ModuleStatus { module_id })?;
|
|
2491
|
-
|
|
2529
|
+
self.reply_value(ruby, reply)
|
|
2492
2530
|
}
|
|
2493
2531
|
|
|
2494
2532
|
fn dispose_module(&self, ruby: &Ruby, module_id: i32) -> Result<(), Error> {
|
|
2495
2533
|
let reply = self.run(ruby, Request::DisposeModule { module_id })?;
|
|
2496
|
-
|
|
2534
|
+
self.reply_value(ruby, reply).map(|_| ())
|
|
2497
2535
|
}
|
|
2498
2536
|
|
|
2499
2537
|
// Classic script: compile, run, dispose.
|
|
@@ -2531,12 +2569,12 @@ impl Core {
|
|
|
2531
2569
|
script_id,
|
|
2532
2570
|
timeout_ms: self.default_timeout_ms,
|
|
2533
2571
|
})?;
|
|
2534
|
-
|
|
2572
|
+
self.reply_value(ruby, reply)
|
|
2535
2573
|
}
|
|
2536
2574
|
|
|
2537
2575
|
fn dispose_script(&self, ruby: &Ruby, script_id: i32) -> Result<(), Error> {
|
|
2538
2576
|
let reply = self.run(ruby, Request::DisposeScript { script_id })?;
|
|
2539
|
-
|
|
2577
|
+
self.reply_value(ruby, reply).map(|_| ())
|
|
2540
2578
|
}
|
|
2541
2579
|
|
|
2542
2580
|
// Serialize a fresh bytecode cache from a compiled handle's current state
|
|
@@ -3088,6 +3126,36 @@ fn vm_err(ruby: &Ruby, e: VmError) -> Error {
|
|
|
3088
3126
|
}
|
|
3089
3127
|
}
|
|
3090
3128
|
|
|
3129
|
+
// Build a RustyRacer::ScriptTerminatedError, attaching the JS stack the watchdog
|
|
3130
|
+
// snapshotted at the timeout (top frame first) as both #js_backtrace and — when
|
|
3131
|
+
// non-empty — the exception's Ruby backtrace, plus the top frame in the message
|
|
3132
|
+
// so even plain logging names what was running. js_backtrace is None for a stop
|
|
3133
|
+
// that isn't a watchdog timeout (e.g. Isolate#terminate), where no stack was
|
|
3134
|
+
// captured; then #js_backtrace is [] and the Ruby backtrace is left intact.
|
|
3135
|
+
fn terminated_error(ruby: &Ruby, js_backtrace: Option<Vec<String>>) -> Error {
|
|
3136
|
+
let class = err_class(ruby, "ScriptTerminatedError");
|
|
3137
|
+
let frames = js_backtrace.unwrap_or_default();
|
|
3138
|
+
let message = match frames.first() {
|
|
3139
|
+
Some(top) => format!("JavaScript was terminated (timeout or stop); running: {top}"),
|
|
3140
|
+
None => "JavaScript was terminated (timeout or stop)".to_string(),
|
|
3141
|
+
};
|
|
3142
|
+
let exc: Value = match class.funcall("new", (message.as_str(),)) {
|
|
3143
|
+
Ok(v) => v,
|
|
3144
|
+
Err(e) => return e,
|
|
3145
|
+
};
|
|
3146
|
+
// Always expose #js_backtrace (even []) so the accessor is never nil.
|
|
3147
|
+
let _ = exc.funcall::<_, _, Value>("instance_variable_set", ("@js_backtrace", frames.clone()));
|
|
3148
|
+
// Only override the Ruby backtrace when we actually have JS frames — for a
|
|
3149
|
+
// bare stop, keep Ruby's own backtrace (the eval call site) instead of [].
|
|
3150
|
+
if !frames.is_empty() {
|
|
3151
|
+
let _ = exc.funcall::<_, _, Value>("set_backtrace", (frames,));
|
|
3152
|
+
}
|
|
3153
|
+
match magnus::Exception::from_value(exc) {
|
|
3154
|
+
Some(e) => Error::from(e),
|
|
3155
|
+
None => Error::new(class, message),
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3091
3159
|
// Build a RustyRacer::RuntimeError carrying the JS stack as its Ruby backtrace.
|
|
3092
3160
|
// Constructs the exception instance so we can set_backtrace before raising;
|
|
3093
3161
|
// falls back to a plain Error if any of that fails.
|
|
@@ -12,13 +12,22 @@
|
|
|
12
12
|
// op handlers and isolate setup (still in lib.rs) call them;
|
|
13
13
|
// report_watchdog_anomaly is private to this module.
|
|
14
14
|
|
|
15
|
-
use std::
|
|
15
|
+
use std::ffi::c_void;
|
|
16
|
+
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
|
16
17
|
use std::sync::{Arc, Condvar, Mutex};
|
|
17
18
|
use std::time::{Duration, Instant};
|
|
18
19
|
|
|
19
20
|
use crate::istate;
|
|
20
21
|
use crate::{IsolateState, JsVal, VmError};
|
|
21
22
|
|
|
23
|
+
// Top N JS frames captured at a timeout — enough to name the culprit without
|
|
24
|
+
// walking a pathological deep stack.
|
|
25
|
+
const MAX_TIMEOUT_FRAMES: usize = 32;
|
|
26
|
+
// Per-frame string cap. Function names and (especially) script URLs are
|
|
27
|
+
// attacker-controlled JS and can be arbitrarily long (a multi-KB data: URL);
|
|
28
|
+
// cap each so a runaway can't bloat the error message / backtrace.
|
|
29
|
+
const MAX_FRAME_NAME: usize = 256;
|
|
30
|
+
|
|
22
31
|
// The watchdog runs on ONE persistent thread per isolate rather than a fresh
|
|
23
32
|
// std::thread per request: spawning + joining a thread on every op cost ~16µs
|
|
24
33
|
// (5.5x) when a timeout was set, dwarfing the actual work. The thread sleeps on
|
|
@@ -27,6 +36,11 @@ use crate::{IsolateState, JsVal, VmError};
|
|
|
27
36
|
pub(crate) struct WatchdogShared {
|
|
28
37
|
inner: Mutex<WatchdogInner>,
|
|
29
38
|
cv: Condvar,
|
|
39
|
+
// The owning isolate's raw `*mut v8::Isolate` as a usize (0 until wired up by
|
|
40
|
+
// set_iso_ptr, right after the isolate is boxed). The loop needs it to address
|
|
41
|
+
// the RequestInterrupt callback's `data` at fire time; kept as an atomic
|
|
42
|
+
// (outside the Mutex) so the loop reads it without serialising on arm/disarm.
|
|
43
|
+
iso_ptr: AtomicUsize,
|
|
30
44
|
}
|
|
31
45
|
|
|
32
46
|
impl WatchdogShared {
|
|
@@ -40,9 +54,17 @@ impl WatchdogShared {
|
|
|
40
54
|
shutdown: false,
|
|
41
55
|
}),
|
|
42
56
|
cv: Condvar::new(),
|
|
57
|
+
iso_ptr: AtomicUsize::new(0),
|
|
43
58
|
})
|
|
44
59
|
}
|
|
45
60
|
|
|
61
|
+
// Wire up the isolate pointer once it is stable (after the OwnedIsolate is
|
|
62
|
+
// boxed). Called before any op can arm a deadline, so the loop always sees it
|
|
63
|
+
// set by the time it could fire.
|
|
64
|
+
pub(crate) fn set_iso_ptr(&self, iso_ptr: *mut std::ffi::c_void) {
|
|
65
|
+
self.iso_ptr.store(iso_ptr as usize, Ordering::Relaxed);
|
|
66
|
+
}
|
|
67
|
+
|
|
46
68
|
// Signal the loop to stop and wake it. Called once at isolate teardown,
|
|
47
69
|
// before the isolate is touched, so the loop can't fire a terminate into an
|
|
48
70
|
// isolate we're mid-disposing.
|
|
@@ -94,8 +116,21 @@ pub(crate) fn watchdog_loop(shared: Arc<WatchdogShared>, handle: v8::IsolateHand
|
|
|
94
116
|
Some(frame) => {
|
|
95
117
|
let now = Instant::now();
|
|
96
118
|
if now >= frame.deadline {
|
|
97
|
-
handle.terminate_execution();
|
|
98
119
|
inner.fired_generation = Some(frame.generation);
|
|
120
|
+
// Don't terminate directly: request an interrupt so the stack
|
|
121
|
+
// is captured on the isolate thread (with JS live) before the
|
|
122
|
+
// terminate — see timeout_interrupt. fired_generation is set
|
|
123
|
+
// FIRST and we still hold `inner`, so the callback (which locks
|
|
124
|
+
// `inner` to read it) can't run until we release, and will see
|
|
125
|
+
// it set. Fall back to a direct terminate if the isolate ptr
|
|
126
|
+
// isn't wired yet (can't happen once an op has armed) — never
|
|
127
|
+
// leave a runaway un-terminated.
|
|
128
|
+
let iso = shared.iso_ptr.load(Ordering::Relaxed);
|
|
129
|
+
if iso != 0 {
|
|
130
|
+
handle.request_interrupt(timeout_interrupt, iso as *mut c_void);
|
|
131
|
+
} else {
|
|
132
|
+
handle.terminate_execution();
|
|
133
|
+
}
|
|
99
134
|
// Drop the fired frame so the loop moves on to the next
|
|
100
135
|
// deadline instead of re-firing this one every wakeup.
|
|
101
136
|
inner.frames.retain(|f| f.generation != frame.generation);
|
|
@@ -110,6 +145,92 @@ pub(crate) fn watchdog_loop(shared: Arc<WatchdogShared>, handle: v8::IsolateHand
|
|
|
110
145
|
|
|
111
146
|
// (The watchdog Arc now lives in IsolateState; arm/disarm reach it via istate!.)
|
|
112
147
|
|
|
148
|
+
// The RequestInterrupt callback the watchdog fires when a deadline passes. It
|
|
149
|
+
// runs on the ISOLATE thread with the runaway JS still on the stack, so it can
|
|
150
|
+
// snapshot the stack BEFORE TerminateExecution unwinds it. `data` is the
|
|
151
|
+
// `*mut v8::Isolate` (the same pointer near_heap_limit_cb uses); the
|
|
152
|
+
// UnsafeRawIsolatePtr arg is ignored.
|
|
153
|
+
//
|
|
154
|
+
// GUARDED by fired_generation: a RequestInterrupt callback can't be cancelled, so
|
|
155
|
+
// one still pending after its op already disarmed must NOT capture+terminate the
|
|
156
|
+
// NEXT op. It acts only while some fired deadline is still awaiting its terminate
|
|
157
|
+
// (fired_generation set) — which is exactly when a terminate is warranted, for
|
|
158
|
+
// whatever op is currently on the stack.
|
|
159
|
+
unsafe extern "C" fn timeout_interrupt(_isolate: v8::UnsafeRawIsolatePtr, data: *mut c_void) {
|
|
160
|
+
let isolate = unsafe { &mut *(data as *mut v8::Isolate) };
|
|
161
|
+
let pending = isolate
|
|
162
|
+
.get_slot::<IsolateState>()
|
|
163
|
+
.map(|s| s.watchdog.inner.lock().unwrap().fired_generation.is_some())
|
|
164
|
+
.unwrap_or(false);
|
|
165
|
+
if !pending {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// Enter SOME realm (the main one — always present) just to get a
|
|
169
|
+
// context-typed scope, which StackTrace::current_stack_trace requires.
|
|
170
|
+
// CurrentStackTrace reads the ISOLATE's running stack regardless of which
|
|
171
|
+
// realm is entered, so this captures the real runaway frames even if they're
|
|
172
|
+
// in another realm. Capture, stash for the unwinding op's error, THEN
|
|
173
|
+
// terminate (so the stack is still live when we snapshot it).
|
|
174
|
+
v8::scope!(let scope, isolate);
|
|
175
|
+
let Some(main) = istate!(scope).realms.main_context.clone() else {
|
|
176
|
+
scope.terminate_execution();
|
|
177
|
+
return;
|
|
178
|
+
};
|
|
179
|
+
let context = v8::Local::new(scope, &main);
|
|
180
|
+
let scope = &mut v8::ContextScope::new(scope, context);
|
|
181
|
+
let frames = capture_js_backtrace(scope, MAX_TIMEOUT_FRAMES);
|
|
182
|
+
istate!(scope).timeout_backtrace = Some(frames);
|
|
183
|
+
scope.terminate_execution();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Snapshot up to |max_frames| of the current JS stack as "func (script:line:col)"
|
|
187
|
+
// strings, top frame first. Empty when there's no JS stack (e.g. already
|
|
188
|
+
// terminating) — best-effort, never fatal.
|
|
189
|
+
fn capture_js_backtrace(scope: &mut v8::PinScope<'_, '_>, max_frames: usize) -> Vec<String> {
|
|
190
|
+
let Some(trace) = v8::StackTrace::current_stack_trace(scope, max_frames) else {
|
|
191
|
+
return Vec::new();
|
|
192
|
+
};
|
|
193
|
+
let count = trace.get_frame_count();
|
|
194
|
+
let mut out = Vec::with_capacity(count);
|
|
195
|
+
for i in 0..count {
|
|
196
|
+
let Some(frame) = trace.get_frame(scope, i) else {
|
|
197
|
+
continue;
|
|
198
|
+
};
|
|
199
|
+
let func = clamp_name(
|
|
200
|
+
frame
|
|
201
|
+
.get_function_name(scope)
|
|
202
|
+
.map(|s| s.to_rust_string_lossy(scope))
|
|
203
|
+
.filter(|s| !s.is_empty())
|
|
204
|
+
.unwrap_or_else(|| "<anonymous>".to_string()),
|
|
205
|
+
);
|
|
206
|
+
let script = clamp_name(
|
|
207
|
+
frame
|
|
208
|
+
.get_script_name_or_source_url(scope)
|
|
209
|
+
.map(|s| s.to_rust_string_lossy(scope))
|
|
210
|
+
.filter(|s| !s.is_empty())
|
|
211
|
+
.unwrap_or_else(|| "<unknown>".to_string()),
|
|
212
|
+
);
|
|
213
|
+
out.push(format!(
|
|
214
|
+
"{func} ({script}:{}:{})",
|
|
215
|
+
frame.get_line_number(),
|
|
216
|
+
frame.get_column()
|
|
217
|
+
));
|
|
218
|
+
}
|
|
219
|
+
out
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Cap a frame name/script string at MAX_FRAME_NAME chars (on a char boundary),
|
|
223
|
+
// appending an ellipsis when truncated, so an attacker-controlled long name can't
|
|
224
|
+
// bloat the error.
|
|
225
|
+
fn clamp_name(mut s: String) -> String {
|
|
226
|
+
if s.len() > MAX_FRAME_NAME {
|
|
227
|
+
let end = (0..=MAX_FRAME_NAME).rev().find(|&i| s.is_char_boundary(i)).unwrap_or(0);
|
|
228
|
+
s.truncate(end);
|
|
229
|
+
s.push('…');
|
|
230
|
+
}
|
|
231
|
+
s
|
|
232
|
+
}
|
|
233
|
+
|
|
113
234
|
// Arm the watchdog for this request: push a frame with its own deadline and
|
|
114
235
|
// wake the loop. Returns the generation token to hand to `disarm_watchdog`
|
|
115
236
|
// (None when timeout_ms is 0 — no watchdog for this request).
|
data/lib/rusty_racer/version.rb
CHANGED
data/lib/rusty_racer.rb
CHANGED
|
@@ -26,7 +26,14 @@ module RustyRacer
|
|
|
26
26
|
class EvalError < Error; end
|
|
27
27
|
class ParseError < EvalError; end
|
|
28
28
|
class RuntimeError < EvalError; end
|
|
29
|
-
class ScriptTerminatedError < EvalError
|
|
29
|
+
class ScriptTerminatedError < EvalError
|
|
30
|
+
# The JS stack at the moment the call timed out (watchdog), as
|
|
31
|
+
# ["func (script:line:col)", ...], top frame first — captured on the isolate
|
|
32
|
+
# thread just before TerminateExecution. [] when no stack was captured (e.g. a
|
|
33
|
+
# bare Isolate#terminate rather than a timeout). The same frames are also set
|
|
34
|
+
# as the exception's #backtrace when present.
|
|
35
|
+
def js_backtrace = @js_backtrace || []
|
|
36
|
+
end
|
|
30
37
|
# Raised when JS allocation exceeds the isolate's heap ceiling (the configured
|
|
31
38
|
# memory_limit, or V8's default ceiling when none was set). Catchable like any
|
|
32
39
|
# eval error — a runaway script fails its own eval instead of aborting the
|
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.10
|
|
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-
|
|
11
|
+
date: 2026-06-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rb_sys
|