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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8bae20d8fe811c8b1e9b171fe272d6481934d7fa2b1fbe978846605dd51e76d4
4
- data.tar.gz: fa35ae7940c34c6c6ff54af93178b4f94200033c2a3fa4ca37f8e878bed829c3
3
+ metadata.gz: 7df2aeec586e04d155d2635f6df9b51e219b31108fad5829e7a5ed5eb8a784a3
4
+ data.tar.gz: '099e1e39f64e041d082fb86269ae4265406e4f05279e3d24909a9e6f4fa89de0'
5
5
  SHA512:
6
- metadata.gz: 8b98d2be3c82bf39d69dc5d3389927a1fd5872f8ace99028da3f9ffd5116464b17d8a2c05f932039f2da0a769599d22b5f870a01ac35b8ade7b8d263ae5f3c80
7
- data.tar.gz: 3391fb308a736f925d56d34cfb649a262a2d9c58f8227bd28706f4da8f366aaebad779510203a57f2e7eb1507888100eb578400318addafbbbb9c4a004d69ca9
6
+ metadata.gz: 5d8f2b15aca012b8c3933b960009e1d248cf2284774eff23c4740688796dfff97d4fe647fc0792f68e70fe4a66aa4fb4cdb7869451a82fac625178f57b8f56d2
7
+ data.tar.gz: c66d30a7c1bfd8d00f62c7ee99e6356f1f9f6c4a8aeebab9e05552a945c51d54f4dd31f3787fa95bd60e425e1dae3240fdb90186c9c2bb87a7b945a0b55b06cd
@@ -4,7 +4,7 @@
4
4
  # libv8-rusty needed under the cibuildgem native-per-platform model).
5
5
  [package]
6
6
  name = "rusty_racer"
7
- version = "0.1.9"
7
+ version = "0.1.10"
8
8
  edition = "2021"
9
9
  publish = false
10
10
 
@@ -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
- fn reply_value(ruby: &Ruby, reply: VmReply) -> Result<Value, Error> {
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
- Self::reply_value(ruby, reply)
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
- Self::reply_value(ruby, reply)
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
- Self::reply_value(ruby, reply)?;
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
- Self::reply_value(ruby, reply)
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
- Self::reply_value(ruby, reply)
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
- Self::reply_value(ruby, reply)
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 = Self::reply_value(ruby, reply)?;
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 = Self::reply_value(ruby, reply)?;
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
- Self::reply_value(ruby, reply)?;
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
- Self::reply_value(ruby, reply?)
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
- Self::reply_value(ruby, reply)
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
- Self::reply_value(ruby, reply)
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
- Self::reply_value(ruby, reply)
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
- Self::reply_value(ruby, reply).map(|_| ())
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
- Self::reply_value(ruby, reply)
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
- Self::reply_value(ruby, reply).map(|_| ())
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::sync::atomic::{AtomicBool, Ordering};
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).
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RustyRacer
4
- VERSION = "0.1.9"
4
+ VERSION = "0.1.10"
5
5
  end
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; end
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.9
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-18 00:00:00.000000000 Z
11
+ date: 2026-06-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys