rusty_racer 0.2.1 → 0.2.2

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: 074b269481c924fc8e99f129e361d29fb4847c9c91ac4afee96378721fe9eebd
4
- data.tar.gz: 68b312cba649752c393c0736e10efd8280f87ae0406ef7826b8d9fe3ec5505ce
3
+ metadata.gz: 03be69f3c1409a64a73622db3063447bbea7813f393227d508739f0c636c6859
4
+ data.tar.gz: 7a7ecb5203032be6f8b27133444ce8c3663eb7d9d49aa86726be8c28acf19425
5
5
  SHA512:
6
- metadata.gz: c3ff8f690a9ad98912f2328d7be4bc10c3d1f38499365f628fbe4dac5e782832323bfc86976ac05fbe9406f288b75191fac11f1eb4f920d1caae056561d0eceb
7
- data.tar.gz: a13dad93b256b9f15eef3baa27e79a97e2d7da61ec20c29aefc66907cd1cef4916fb0c84d65742233d182d48fed1fa369b48e86105bba7fede8a6afdaa008133
6
+ metadata.gz: 57fb55d034906210851ea93be3ad933ee366dfccd1419d4f7f712ed5407082565ccda4ed0e0abadde12ec9af1b958ffab908cbdfb40ef5efa188ed8d2f47154e
7
+ data.tar.gz: 56983cedc5a48dc0a9cf1570da7a7241a6be6a3a8c80ca7114204e6764bc377a0d72814147e318c64974093b625148e8260d8e75168a76e1fa22711a94e5ed69
data/README.md CHANGED
@@ -258,7 +258,12 @@ to one native thread (rusty_v8 exposes no `v8::Locker`), so using it from anothe
258
258
  thread raises `RustyRacer::WrongThreadError` rather than corrupting the VM.
259
259
 
260
260
  - **`Isolate#terminate` is the one exception** — it is safe to call from any
261
- thread (it stops a runaway script on the owner thread).
261
+ thread (it stops a runaway script on the owner thread). It stops JavaScript at
262
+ V8's next interrupt check, which a runaway script reaches almost immediately
263
+ and a *short* one may never reach: terminating from inside a host function that
264
+ then returns to a script which simply finishes lets that script finish, and the
265
+ request is dropped rather than carried into the next operation. Use it to stop
266
+ code that is running away, not as a way to fail the call you are inside.
262
267
  - **Dispose on the owner thread.** `Isolate#dispose` must run on the owner
263
268
  thread. If the last reference to an isolate is instead garbage-collected on a
264
269
  *different* thread (e.g. its owner thread already exited), it cannot be
@@ -290,6 +295,54 @@ process on the next GC or thrown exception. So **don't call into an isolate from
290
295
  inside a Fiber on a worker thread**; drive isolate ops directly on the thread, or
291
296
  keep Fiber/Enumerator-mediated JS calls on the main thread.
292
297
 
298
+ The other limit is on *where* a Fiber may switch. Operations on one isolate have
299
+ to nest: everything V8 keeps per isolate — the frame chain, the handle scopes,
300
+ the description of the stack — unwinds in the reverse of the order it was
301
+ entered. Resuming a Fiber from inside a host function keeps that (the outer
302
+ operation is simply blocked until the inner one returns, which is why
303
+ `Capybara::Result#find` inside a callback works), but **yielding out of a host
304
+ function** does not: it strands that operation mid-flight. That covers
305
+ `Fiber.yield` and `Fiber#transfer`, an `Enumerator::Yielder#<<` from inside a
306
+ callback, and any blocking call made under a Fiber scheduler (`async`, `falcon`),
307
+ which yields out of the Fiber for you — the last of those without a `Fiber`
308
+ keyword anywhere in your code.
309
+
310
+ What happens next depends on what the stranded operation's Fiber does:
311
+
312
+ - **Resumed while another operation is still running** — the two can now only
313
+ finish in the order they started, which V8 has no room for. There is no
314
+ exception to raise: raising *is* that unwind. So the binding prints what
315
+ happened, which host function did it, and where — then stops the process with
316
+ `SIGABRT` (exit 134). It is not rescuable and there is no opt-out; on a
317
+ threaded server that takes the whole worker with it.
318
+
319
+ ```
320
+ [rusty_racer] fatal: operations on this isolate stopped nesting.
321
+
322
+ The host function `hop` handed control to Ruby.
323
+ By the time it came back, the operations in flight on this isolate had
324
+ changed (now 2, was 1) — so they no longer nest. …
325
+ ```
326
+
327
+ - **Resumed only after the other operation has finished** — memory-safe, and it
328
+ keeps working. But note that *while* an operation is stranded, every other
329
+ operation on that isolate runs as a **nested** one, and only an outermost
330
+ operation drains microtasks. So a promise queued in the meantime does not
331
+ settle until the stranded operation finishes. **This is the shape a Fiber
332
+ scheduler produces**: it resumes the stranded Fiber once the other fiber's
333
+ operation is done, so `async`/`falcon` get the quiet variant, not the abort.
334
+
335
+ - **Never resumed** — Ruby frees an unresumed Fiber's stack without unwinding it,
336
+ so the operation's frames vanish while the isolate is still entered by them.
337
+ The isolate cannot be disposed after that; it is leaked, with a warning saying
338
+ why. If you still hold the Fiber, **`Fiber#kill` recovers it**: it resumes the
339
+ Fiber to unwind it, so the stranded operation finishes in order and the isolate
340
+ goes back to normal. (The one thing that defeats that is a script which catches
341
+ the error the killed callback throws and carries on — the kill only lands once
342
+ that operation ends, just as it would wait out any Ruby call in progress, and
343
+ until then the script can call further host functions, which run on the
344
+ already-killed Fiber.)
345
+
293
346
  ## Installation
294
347
 
295
348
  Precompiled gems bundle V8 — no V8 build, no Rust toolchain — for Ruby 3.3, 3.4,
@@ -5,7 +5,7 @@
5
5
  # links and loads with no custom archive.
6
6
  [package]
7
7
  name = "rusty_racer"
8
- version = "0.2.1"
8
+ version = "0.2.2"
9
9
  edition = "2024"
10
10
  publish = false
11
11
 
@@ -122,11 +122,14 @@ macro_rules! istate {
122
122
  pub(crate) use istate;
123
123
 
124
124
  // One attach()'d host fn: the realm it was attached into — so resetting or
125
- // disposing that realm can release the GC root — and the rooted proc itself
126
- // (None once released; the slot index stays valid as a host_fn_id).
125
+ // disposing that realm can release the GC root — the rooted proc itself (None
126
+ // once released; the slot index stays valid as a host_fn_id), and the name it
127
+ // was attached under, kept only so a diagnostic can say which host function is
128
+ // involved (one String per attach, never per call).
127
129
  struct ProcSlot {
128
130
  context_id: i32,
129
131
  proc: Option<RootedProc>,
132
+ name: String,
130
133
  }
131
134
 
132
135
  // The isolate's host-fn registry, indexed by host_fn_id. `free` lists slots
@@ -282,6 +285,39 @@ where
282
285
  job.r.unwrap()
283
286
  }
284
287
 
288
+ // What handed control to Ruby. Carried through with_gvl for one reason: when
289
+ // the op nesting breaks, the diagnostic can point at the caller's own code
290
+ // instead of saying "a host function". Rendered only on that path, so an
291
+ // ordinary callback pays for a pointer and nothing else.
292
+ enum RubyCall<'a> {
293
+ // An attach()'d host function, by host_fn_id — the name it was attached
294
+ // under lives in the isolate's proc table.
295
+ HostFn(usize),
296
+ // A module resolver (a static instantiate block, a dynamic auto-link, or
297
+ // import()), named by the specifier it was handed.
298
+ Resolver(&'a str),
299
+ }
300
+
301
+ impl RubyCall<'_> {
302
+ fn describe(&self, core: &Core) -> String {
303
+ match self {
304
+ // try_lock, not lock: nothing holds this across a callback (call_proc
305
+ // takes the proc out under the lock and releases it before calling),
306
+ // but blocking here would turn a diagnostic into a hang.
307
+ Self::HostFn(id) => match core
308
+ .procs
309
+ .try_lock()
310
+ .ok()
311
+ .and_then(|procs| procs.slots.get(*id).map(|slot| slot.name.clone()))
312
+ {
313
+ Some(name) => format!("The host function `{name}`"),
314
+ None => "A host function".to_string(),
315
+ },
316
+ Self::Resolver(spec) => format!("The module resolver, resolving `{spec}`,"),
317
+ }
318
+ }
319
+ }
320
+
285
321
  // The inverse trampoline: REACQUIRE the GVL to run |f| (a Ruby callback), then
286
322
  // release it again. Called from inside a V8 host callback / module resolver,
287
323
  // which runs GVL-released under the in-thread runner's `without_gvl`; the proc
@@ -289,20 +325,41 @@ where
289
325
  // the proc re-enters the runner, releasing the GVL again) is sound. |f| must
290
326
  // NOT let a Ruby exception escape as a longjmp — the callers convert proc
291
327
  // errors to a Result value and throw on the JS side instead.
292
- fn with_gvl<F, R>(f: F) -> R
328
+ //
329
+ // This is also the only place where control can leave an operation WITHOUT it
330
+ // finishing: the Ruby we hand it to may switch Fibers, and a yield out of a
331
+ // callback strands this op mid-flight while the resumer goes on to use the
332
+ // isolate again. Everything V8 keeps per isolate nests strictly — the frame
333
+ // chain, the handle scopes, the stack description — so `core.depth` (the ops in
334
+ // flight on this isolate) coming back unchanged is exactly the condition that
335
+ // they still do: every op started while Ruby held control has also finished.
336
+ // Two atomic loads per callback, and a mismatch is fatal — see
337
+ // fatal_nesting_broken. Both loads and the check live INSIDE the trampoline, so
338
+ // they run with the GVL held and on the stranded op's own stack, which is what
339
+ // lets the diagnostic there read a Ruby backtrace without taking the GVL again.
340
+ fn with_gvl<F, R>(core: &Core, call: RubyCall<'_>, f: F) -> R
293
341
  where
294
342
  F: FnOnce() -> R,
295
343
  {
296
- struct Job<F, R> {
344
+ struct Job<'a, F, R> {
345
+ core: &'a Core,
346
+ call: RubyCall<'a>,
297
347
  f: Option<F>,
298
348
  r: Option<R>,
299
349
  }
300
350
  unsafe extern "C" fn run<F: FnOnce() -> R, R>(data: *mut c_void) -> *mut c_void {
301
351
  let job = unsafe { &mut *(data as *mut Job<F, R>) };
352
+ let entry_depth = job.core.depth.load(Ordering::SeqCst);
302
353
  job.r = Some((job.f.take().unwrap())());
354
+ let exit_depth = job.core.depth.load(Ordering::SeqCst);
355
+ if exit_depth != entry_depth {
356
+ fatal_nesting_broken(job.core, &job.call, entry_depth, exit_depth);
357
+ }
303
358
  null_mut()
304
359
  }
305
360
  let mut job = Job::<F, R> {
361
+ core,
362
+ call,
306
363
  f: Some(f),
307
364
  r: None,
308
365
  };
@@ -312,6 +369,101 @@ where
312
369
  job.r.unwrap()
313
370
  }
314
371
 
372
+ // Ruby handed control back to a callback whose operation is no longer the
373
+ // innermost one on its isolate: the Ruby code switched Fibers, and an operation
374
+ // that started later is still running. The two would now have to unwind in the
375
+ // order they were STARTED rather than the reverse, and V8 has no room for that —
376
+ // finishing this one pops its HandleScope out from under the other's live
377
+ // handles, restores a frame chain that still has the other's frames linked into
378
+ // it, and (at depth 0) exits an isolate the other is still running in.
379
+ //
380
+ // None of the gentler endings exist. Reporting it as a Ruby exception IS that
381
+ // unwind, so raising is the corruption, not a report of it. Waiting for the
382
+ // other operation deadlocks: it is blocked on the Fiber this one is standing in.
383
+ // And refusing the second operation before it started was never possible either
384
+ // — "a Fiber resumed from inside a callback" (supported, and common: every
385
+ // Enumerator is a Fiber) and "a Fiber that yielded out of one" leave Ruby in the
386
+ // same observable state, and CRuby exposes no way to tell a resuming Fiber from
387
+ // a suspended one. The difference only shows up here, when control comes back.
388
+ //
389
+ // So the process stops, while the state is still intact and the cause can still
390
+ // be named, rather than letting V8 abort later on `Check failed:
391
+ // IsOnCentralStack()` — or worse, running JavaScript on freed handles. rb_raise
392
+ // and rb_fatal would longjmp through V8's C++ frames, so this doesn't go through
393
+ // Ruby's error path at all.
394
+ fn fatal_nesting_broken(core: &Core, call: &RubyCall<'_>, entry_depth: u32, exit_depth: u32) -> ! {
395
+ eprintln!(
396
+ "\n[rusty_racer] fatal: operations on this isolate stopped nesting.\n\
397
+ \n\
398
+ {who} handed control to Ruby.\n\
399
+ By the time it came back, the operations in flight on this isolate had\n\
400
+ changed (now {exit_depth}, was {entry_depth}) — so they no longer nest. The Ruby code\n\
401
+ switched Fibers and left this one stranded while the isolate was used\n\
402
+ again: `Fiber.yield` or `Fiber#transfer` out of the callback, or an\n\
403
+ `Enumerator::Yielder#<<` from inside it — anything that suspends the\n\
404
+ callback rather than returning from it.\n\
405
+ \n\
406
+ V8 requires the operations on one isolate to unwind in the reverse of the\n\
407
+ order they started, and that is now impossible: finishing this one would\n\
408
+ pop its handles out from under the other's. Raising a Ruby exception here\n\
409
+ would BE that unwind, and waiting is a deadlock — the other operation is\n\
410
+ blocked on the Fiber this one is standing in. Stopping now, while the\n\
411
+ state is still intact, is what is left.\n\
412
+ \n\
413
+ Resuming a Fiber from inside a callback IS supported, including calling\n\
414
+ back into the isolate from it. What isn't is yielding OUT of the callback\n\
415
+ and using the isolate before the Fiber is resumed again — let the Fiber\n\
416
+ run to completion inside the callback, or finish the operation first.\n\
417
+ \n\
418
+ See https://github.com/ursm/rusty_racer#fibers\n",
419
+ who = call.describe(core),
420
+ );
421
+ print_stranded_backtrace();
422
+ // Put SIGABRT back to the default. Ruby installs a handler for it that prints
423
+ // a [BUG] report — a Ruby-level backtrace, the loaded features, the whole
424
+ // memory map — which buries the explanation above under a page of noise that
425
+ // also reads as "Ruby crashed", which this is not. The process then dies of
426
+ // SIGABRT (exit 134); whether that leaves a core is the operator's ulimit to
427
+ // decide, not ours to override.
428
+ unsafe { libc::signal(libc::SIGABRT, libc::SIG_DFL) };
429
+ std::process::abort()
430
+ }
431
+
432
+ // The Ruby frames of the operation whose callback just came back — the one named
433
+ // in the message above, which is usually the stranded one (it regains control
434
+ // first) but is the resumer's if that one got here first. We hold the GVL and are
435
+ // standing on that operation's own stack, so `caller` here is its eval/call. The
436
+ // proc itself has already RETURNED — that return is what got us here — so the
437
+ // yield's own line is not among these frames; naming the callback is what stands
438
+ // in for it. rb_backtrace() would do this in one call, but prints
439
+ // outermost-first, backwards from every backtrace a Ruby reader has seen.
440
+ fn print_stranded_backtrace() {
441
+ use magnus::rb_sys::FromRawValue;
442
+ let Ok(raw) = magnus::rb_sys::protect(|| unsafe { rb_sys::rb_make_backtrace() }) else {
443
+ return;
444
+ };
445
+ let frames = magnus::RArray::from_value(unsafe { magnus::Value::from_raw(raw) })
446
+ .and_then(|a| a.to_vec::<String>().ok())
447
+ .unwrap_or_default();
448
+ // Drop the binding's own frames (`_eval` and the `eval` wrapping it, both at
449
+ // the same line of rusty_racer.rb) so the first line is the caller's. Keep
450
+ // everything if that would leave nothing to print.
451
+ let own = |f: &String| f.contains("/rusty_racer.rb:");
452
+ let user: Vec<&String> = frames.iter().filter(|f| !own(f)).collect();
453
+ let shown: Vec<&String> = if user.is_empty() {
454
+ frames.iter().collect()
455
+ } else {
456
+ user
457
+ };
458
+ if shown.is_empty() {
459
+ return;
460
+ }
461
+ eprintln!("That operation is here:");
462
+ for (i, frame) in shown.iter().enumerate() {
463
+ eprintln!("{}{frame}", if i == 0 { "\t" } else { "\tfrom " });
464
+ }
465
+ }
466
+
315
467
  // Identity of the calling RUBY thread (its Thread VALUE, stable for the thread's
316
468
  // life) — used to bind an isolate to its owner thread. Unlike a native ThreadId,
317
469
  // this survives Ruby's M:N scheduler moving the thread between native threads.
@@ -320,6 +472,19 @@ fn current_ruby_thread() -> usize {
320
472
  unsafe { rb_sys::rb_thread_current() as usize }
321
473
  }
322
474
 
475
+ // Identity of the calling FIBER, as its object_id — monotonic and never reused,
476
+ // unlike the VALUE, which is a heap address a collected Fiber hands back (and a
477
+ // Fiber stranded with a pending kill DOES get collected). Ids are a Fixnum on
478
+ // every platform this gem ships for, so the usize compared is an immediate, not
479
+ // a pointer that could alias in its turn. Assigning one can allocate, and this
480
+ // runs with V8 frames live and outside any rb_protect of ours, so it takes its
481
+ // own: 0 on failure, which reads as "no Fiber" at both ends and so drops a kill
482
+ // rather than misdelivers one. MUST be called with the GVL held.
483
+ fn current_fiber_id() -> usize {
484
+ magnus::rb_sys::protect(|| unsafe { rb_sys::rb_obj_id(rb_sys::rb_fiber_current()) })
485
+ .map_or(0, |id| id as usize)
486
+ }
487
+
323
488
  // Reduce a magnus Error to a single Exception INSTANCE so it can be GC-rooted and
324
489
  // re-raised later WITH ITS ORIGINAL CLASS. A Ruby proc's raise is already an
325
490
  // instance; an Error::new(class, msg) from our own code becomes an instance of
@@ -381,9 +546,9 @@ fn host_fn_callback(
381
546
  throw_js_error(scope, "host function has no owner");
382
547
  return;
383
548
  }
384
- let result: Result<JsVal, String> = with_gvl(|| {
549
+ let core = unsafe { &*core_ptr };
550
+ let result: Result<JsVal, String> = with_gvl(core, RubyCall::HostFn(host_fn_id), || {
385
551
  let ruby = Ruby::get().unwrap();
386
- let core = unsafe { &*core_ptr };
387
552
  // rb_protect so a Ruby exception raised during arg/return marshalling
388
553
  // (ary_new_capa / jsval_to_ruby / ruby_to_jsval can raise on OOM etc.)
389
554
  // is CAUGHT here instead of longjmp-ing through V8's C++ frames. The
@@ -395,7 +560,7 @@ fn host_fn_callback(
395
560
  ruby.qnil().as_raw()
396
561
  }) {
397
562
  Ok(_) => out.unwrap_or_else(|| Err("host function did not complete".into())),
398
- Err(e) => Err(format!("{e}")),
563
+ Err(e) => Err(core.swallow(e)),
399
564
  }
400
565
  });
401
566
  match result {
@@ -940,6 +1105,7 @@ fn resolve_imported<'s>(
940
1105
  if core_ptr.is_null() {
941
1106
  return None;
942
1107
  }
1108
+ let core = unsafe { &*core_ptr };
943
1109
  // The static instantiate block parked for THIS op (Some) vs a dynamic
944
1110
  // import's auto-link (None -> dynamic_import_resolver, with the initiating
945
1111
  // realm so it can resolve per-realm).
@@ -950,9 +1116,11 @@ fn resolve_imported<'s>(
950
1116
  // The error conversion happens INSIDE with_gvl: error_to_exception
951
1117
  // calls into Ruby (so it can allocate, so it can GC) and BoxValue::new
952
1118
  // registers a GC root — neither is safe with the GVL released.
953
- match with_gvl(|| {
954
- resolve_module_via_ruby(unsafe { &*core_ptr }, resolve, &spec, &ref_url, None)
955
- .map_err(|e| error_to_exception(&e).map(BoxValue::new))
1119
+ match with_gvl(core, RubyCall::Resolver(&spec), || {
1120
+ resolve_module_via_ruby(core, resolve, &spec, &ref_url, None).map_err(|e| {
1121
+ core.note_fatal(&e);
1122
+ error_to_exception(&e).map(BoxValue::new)
1123
+ })
956
1124
  }) {
957
1125
  Ok(id) => id,
958
1126
  // Stash the resolver's own raised exception (GC-rooted) so the
@@ -965,24 +1133,18 @@ fn resolve_imported<'s>(
965
1133
  }
966
1134
  }
967
1135
  None => {
968
- let resolver = unsafe { &*core_ptr }
1136
+ let resolver = core
969
1137
  .dynamic_import_resolver
970
1138
  .lock()
971
1139
  .unwrap()
972
1140
  .as_ref()
973
1141
  .map(|r| r.get());
974
1142
  match resolver {
975
- Some(p) => match with_gvl(|| {
976
- resolve_module_via_ruby(
977
- unsafe { &*core_ptr },
978
- p,
979
- &spec,
980
- &ref_url,
981
- Some(here.unwrap_or(0)),
982
- )
983
- // Rendering the message reads the Ruby exception, so it too
984
- // stays inside with_gvl.
985
- .map_err(|e| e.to_string())
1143
+ Some(p) => match with_gvl(core, RubyCall::Resolver(&spec), || {
1144
+ resolve_module_via_ruby(core, p, &spec, &ref_url, Some(here.unwrap_or(0)))
1145
+ // Rendering the message reads the Ruby exception, so it too
1146
+ // stays inside with_gvl.
1147
+ .map_err(|e| core.swallow(e))
986
1148
  }) {
987
1149
  Ok(id) => id,
988
1150
  // Unlike the static branch there is no Ruby frame waiting to
@@ -1087,7 +1249,8 @@ fn dynamic_import_cb<'s>(
1087
1249
  reject(scope, "dynamic import has no owner");
1088
1250
  return Some(promise);
1089
1251
  }
1090
- let resolver_proc = unsafe { &*core_ptr }
1252
+ let core = unsafe { &*core_ptr };
1253
+ let resolver_proc = core
1091
1254
  .dynamic_import_resolver
1092
1255
  .lock()
1093
1256
  .unwrap()
@@ -1095,11 +1258,17 @@ fn dynamic_import_cb<'s>(
1095
1258
  .map(|r| r.get());
1096
1259
  let id = match resolver_proc {
1097
1260
  // A raising resolver only fails the import() (it rejects generically);
1098
- // it must NOT abort the surrounding eval, so swallow the Err here.
1099
- Some(p) => with_gvl(|| {
1100
- resolve_module_via_ruby(unsafe { &*core_ptr }, p, &spec, &referrer, Some(initiating))
1101
- })
1102
- .unwrap_or(None),
1261
+ // it must NOT abort the surrounding eval, so swallow the Err here — but
1262
+ // note_fatal first, since this is the one with_gvl site that throws its
1263
+ // Err away and a Thread#kill would go with it.
1264
+ Some(p) => with_gvl(core, RubyCall::Resolver(&spec), || {
1265
+ resolve_module_via_ruby(core, p, &spec, &referrer, Some(initiating)).unwrap_or_else(
1266
+ |e| {
1267
+ core.note_fatal(&e);
1268
+ None
1269
+ },
1270
+ )
1271
+ }),
1103
1272
  None => None,
1104
1273
  };
1105
1274
  match id {
@@ -1346,6 +1515,17 @@ struct Core {
1346
1515
  // op. Owner-thread only, like the isolate itself; AtomicUsize for shared
1347
1516
  // &Core access.
1348
1517
  installed_stack_limit: std::sync::atomic::AtomicUsize,
1518
+ // Set when a callback's Ruby code unwound with TAG_FATAL — a Thread#kill —
1519
+ // which magnus catches like any other error because a longjmp through V8's
1520
+ // C++ frames is not survivable. Holds the object_id of the
1521
+ // Fiber that must finish the kill (never a raw VALUE: a stranded Fiber
1522
+ // holding a pending kill does get collected, and its address can then be
1523
+ // handed to a new one, whereas an object_id is monotonic and never reused);
1524
+ // 0 = nothing pending.
1525
+ // See Core::note_fatal / Core::resume_pending_fatal. Atomic for the same
1526
+ // reason as the rest: &Core is shared, though only the owner thread ever
1527
+ // touches this.
1528
+ pending_fatal: std::sync::atomic::AtomicUsize,
1349
1529
  // Re-entry depth for THIS isolate, readable without a scope (the runner needs
1350
1530
  // it to choose the scope kind before any scope exists): 0 = top-level op
1351
1531
  // (open a fresh HandleScope from iso_ptr); >0 = a host callback is on the V8
@@ -2139,6 +2319,7 @@ impl Isolate {
2139
2319
  iso_ptr,
2140
2320
  scan_start_field: std::sync::atomic::AtomicUsize::new(0),
2141
2321
  installed_stack_limit: std::sync::atomic::AtomicUsize::new(0),
2322
+ pending_fatal: std::sync::atomic::AtomicUsize::new(0),
2142
2323
  depth: std::sync::atomic::AtomicU32::new(0),
2143
2324
  procs: Mutex::new(ProcTable::default()),
2144
2325
  default_timeout_ms: timeout_ms,
@@ -2290,7 +2471,7 @@ impl Core {
2290
2471
  &self.installed_stack_limit,
2291
2472
  stack_top,
2292
2473
  );
2293
- if depth == 0 {
2474
+ let reply = if depth == 0 {
2294
2475
  let mut reply = std::panic::catch_unwind(AssertUnwindSafe(|| {
2295
2476
  v8::scope!(let scope, unsafe { &mut *iso });
2296
2477
  service_request(scope, request, true)
@@ -2359,15 +2540,38 @@ impl Core {
2359
2540
  unsafe { (*iso).exit() };
2360
2541
  }
2361
2542
  reply
2543
+ };
2544
+ // Everything that has to happen whatever else does goes HERE, inside
2545
+ // the closure, rather than after without_gvl returns:
2546
+ // rb_thread_call_without_gvl checks the thread's pending interrupts as
2547
+ // it re-acquires the GVL, and a Thread#raise, a Timeout, or a Ctrl-C
2548
+ // that lands there longjmps straight past everything written after the
2549
+ // call.
2550
+ //
2551
+ // Balancing the fetch_add is one: a missed decrement is permanent and
2552
+ // silent — the isolate then looks like it has an op in flight for the
2553
+ // rest of its life, so no later op is ever the outermost one
2554
+ // (microtasks stop draining, terminate flags stop being cleared) and
2555
+ // dispose refuses forever. The op is finished by this point: the scope
2556
+ // is dropped and the isolate exited in both branches above.
2557
+ //
2558
+ // Poisoning a panicked isolate is the other: None means the op left V8
2559
+ // possibly inconsistent, and an interrupt arriving on top of that must
2560
+ // not be what decides whether later ops are allowed to touch it. The
2561
+ // raise below still reports it — the lock is safe with the GVL
2562
+ // released, since every other holder takes it briefly and none waits
2563
+ // on an op.
2564
+ if reply.is_none() {
2565
+ self.shared.lock().unwrap().disposed = true;
2362
2566
  }
2567
+ self.depth.fetch_sub(1, Ordering::SeqCst);
2568
+ reply
2363
2569
  });
2364
- self.depth.fetch_sub(1, Ordering::SeqCst);
2365
2570
  match reply {
2366
2571
  Some(reply) => Ok(reply),
2367
2572
  None => {
2368
- // The op panicked: V8 may be left inconsistent, so POISON the
2369
- // isolate (every later op refuses) rather than risk using it.
2370
- self.shared.lock().unwrap().disposed = true;
2573
+ // The op panicked; the isolate was poisoned inside the closure
2574
+ // above (every later op refuses) rather than risk using it.
2371
2575
  Err(Error::new(
2372
2576
  err_class(ruby, "InternalError"),
2373
2577
  "internal error: operation panicked; the isolate has been disposed",
@@ -2376,10 +2580,58 @@ impl Core {
2376
2580
  }
2377
2581
  }
2378
2582
 
2583
+ // Finish a Thread#kill that a callback had to swallow (see note_fatal). This
2584
+ // is a longjmp — the same rb_jump_tag magnus performs for an Error::Jump
2585
+ // handed back to it — so nothing between here and Ruby runs Drop, and it may
2586
+ // only be called where no frame in between still owns anything that matters.
2587
+ // That rules out the end of `run`: instantiate_module parks the op's resolver
2588
+ // across the call and restores it afterwards, and skipping THAT would leave a
2589
+ // dead op's resolver parked for a later dynamic import to call. The end of
2590
+ // reply_value is past every such restore, and reaching it is what every
2591
+ // operation that can run Ruby at all does.
2592
+ //
2593
+ // An operation that returns Err from `run` — only a panic, which poisons the
2594
+ // isolate — doesn't reach it, and the kill is then dropped by whichever
2595
+ // operation gets here next (see the Fiber check). Frames above still leak
2596
+ // whatever they hold (an argument String, say), which is why every caller with
2597
+ // cleanup of its own runs that cleanup BEFORE calling this.
2598
+ fn resume_pending_fatal<T>(&self, outcome: Result<T, Error>) -> Result<T, Error> {
2599
+ let pending = self.pending_fatal.swap(0, Ordering::SeqCst);
2600
+ // Nothing pending — the case every op takes, and one atomic load.
2601
+ if pending == 0 {
2602
+ return outcome;
2603
+ }
2604
+ // A kill belongs to the Fiber whose callback swallowed it, and finishing
2605
+ // it anywhere else would kill an unrelated caller — the main thread, say,
2606
+ // mid-eval, with no exception and no message. A mismatch is an ordinary
2607
+ // outcome, not a should-not-happen: when the script CATCHES the error the
2608
+ // killed callback threw, that operation may never end, and the next one to
2609
+ // get here is somebody else's. Drop the kill rather than deliver it to the
2610
+ // wrong Fiber. (CRuby's own Fiber#kill bookkeeping still terminates such a
2611
+ // Fiber on its next resume; only its return value is lost.)
2612
+ if pending != current_fiber_id() {
2613
+ return outcome;
2614
+ }
2615
+ drop(outcome);
2616
+ // enum ruby_tag_type's RUBY_TAG_FATAL; not in rb_sys's bindings
2617
+ // (bindgen skips the anonymous enum), and stable since 1.9.
2618
+ const RUBY_TAG_FATAL: std::os::raw::c_int = 8;
2619
+ unsafe { rb_sys::rb_jump_tag(RUBY_TAG_FATAL) };
2620
+ }
2621
+
2379
2622
  // Map a terminal reply to a Ruby value (the common eval/call/run shape).
2380
2623
  // &self so a Terminated outcome can pick up the JS stack the watchdog snapshot
2381
2624
  // captured (see timeout_interrupt / take_timeout_backtrace).
2382
2625
  fn reply_value(&self, ruby: &Ruby, reply: VmReply) -> Result<Value, Error> {
2626
+ let outcome = self.reply_value_deferred(ruby, reply);
2627
+ // Every operation that can run Ruby — and so can have a Thread#kill
2628
+ // swallowed by one of its callbacks — ends here, except the few whose own
2629
+ // cleanup has to run first; those call the two halves in order.
2630
+ self.resume_pending_fatal(outcome)
2631
+ }
2632
+
2633
+ // reply_value without finishing a swallowed kill.
2634
+ fn reply_value_deferred(&self, ruby: &Ruby, reply: VmReply) -> Result<Value, Error> {
2383
2635
  match reply {
2384
2636
  VmReply::Done(Ok(val)) => jsval_to_ruby(ruby, &val),
2385
2637
  // Clone (don't take): a nested timeout's terminate unwinds the whole op
@@ -2407,6 +2659,64 @@ impl Core {
2407
2659
  .clone()
2408
2660
  }
2409
2661
 
2662
+ // A Ruby error raised under a live op cannot be allowed to longjmp — it would
2663
+ // cross V8's C++ frames — so every callback catches it and throws it into JS
2664
+ // as a message instead. That is right for an exception and wrong for exactly
2665
+ // one thing: Thread#kill unwinds with TAG_FATAL, which magnus catches like
2666
+ // anything else, and a killed thread that is merely told about it in
2667
+ // JavaScript is not killed at all — it goes on to run whatever comes next,
2668
+ // while the op's caller is handed a fabricated `RustyRacer::RuntimeError:
2669
+ // Error: Fatal`. Remember it here; Core::run re-asserts it the moment the op
2670
+ // is over and there are no V8 frames left to cross, which is what magnus
2671
+ // would have done with the jump if it could have.
2672
+ //
2673
+ // Only Fatal. The other jumps (a `break` or `next` out of a host proc) have
2674
+ // no live block to return to by the time we could resume them, so they stay
2675
+ // what they are today: an error on the JavaScript side.
2676
+ //
2677
+ // The kill lands at the END of the operation, not at the callback: the
2678
+ // callback still has to return through V8, and what it returns is an ordinary
2679
+ // JavaScript exception, which the script may catch and carry on from — during
2680
+ // which it can call further host functions, running Ruby on the already-killed
2681
+ // thread.
2682
+ //
2683
+ // Terminating the isolate here to make that uncatchable was tried and
2684
+ // REJECTED, for a reason worth writing down because the obvious objection is
2685
+ // the wrong one. V8 does see it: a termination requested from inside a host
2686
+ // callback survives the callback and is observed at the next interrupt check,
2687
+ // which is any JavaScript function entry or a loop long enough to exhaust
2688
+ // Ignition's interrupt budget — only a degenerate tail of host calls and
2689
+ // literals escapes. The problem is what happens when it escapes: the request
2690
+ // stays ARMED, and the sweep that clears a stale one runs only at an outermost
2691
+ // request (see service_request), which is exactly what a stranded operation
2692
+ // leaves the isolate without. It would then terminate an unrelated later
2693
+ // operation, which is a worse failure than the one being fixed — and it does
2694
+ // not even close the window it was for, since a script looping on host calls
2695
+ // alone reaches no interrupt check either.
2696
+ fn note_fatal(&self, e: &Error) {
2697
+ if !matches!(
2698
+ e.error_type(),
2699
+ magnus::error::ErrorType::Jump(magnus::error::Tag::Fatal)
2700
+ ) {
2701
+ return;
2702
+ }
2703
+ // Which Fiber has to finish the kill: this one, the one the killed
2704
+ // callback was running on. Checked again at the other end, so a pending
2705
+ // kill can never land on an unrelated caller.
2706
+ self.pending_fatal
2707
+ .store(current_fiber_id(), Ordering::SeqCst);
2708
+ }
2709
+
2710
+ // note_fatal + the message the callback will throw, for the map_err sites.
2711
+ // Rendering is safe to do here, outside any rb_protect with V8 frames live:
2712
+ // magnus formats an exception from its class name and address rather than
2713
+ // calling #message or #inspect, so a caller whose own class raises from those
2714
+ // (checked) cannot longjmp out of this.
2715
+ fn swallow(&self, e: Error) -> String {
2716
+ self.note_fatal(&e);
2717
+ e.to_string()
2718
+ }
2719
+
2410
2720
  fn call_proc(&self, ruby: &Ruby, host_fn_id: usize, args: &[JsVal]) -> Result<JsVal, String> {
2411
2721
  let proc = {
2412
2722
  let procs = self.procs.lock().unwrap();
@@ -2428,16 +2738,16 @@ impl Core {
2428
2738
  let ruby_args = ruby.ary_new_capa(args.len());
2429
2739
  for v in args {
2430
2740
  ruby_args
2431
- .push(jsval_to_ruby(ruby, v).map_err(|e| e.to_string())?)
2432
- .map_err(|e| e.to_string())?;
2741
+ .push(jsval_to_ruby(ruby, v).map_err(|e| self.swallow(e))?)
2742
+ .map_err(|e| self.swallow(e))?;
2433
2743
  }
2434
2744
  // SAFETY: ruby_args is a live local (so GC keeps it and its elements) and
2435
2745
  // is not mutated while the slice is borrowed — as_slice's contract. A VM
2436
2746
  // op the proc issues re-enters Core::run (depth > 0) directly — no nested
2437
2747
  // frame bookkeeping is needed any more (the call stack IS the nesting).
2438
2748
  let result: Result<Value, Error> = proc.call(unsafe { ruby_args.as_slice() });
2439
- let value = result.map_err(|e| e.to_string())?;
2440
- ruby_to_jsval(value).map_err(|e| e.to_string())
2749
+ let value = result.map_err(|e| self.swallow(e))?;
2750
+ ruby_to_jsval(value).map_err(|e| self.swallow(e))
2441
2751
  }
2442
2752
 
2443
2753
  // Context#call (and call_void). Resolves a dotted function path
@@ -2555,6 +2865,7 @@ impl Core {
2555
2865
  let host_fn_id = self.procs.lock().unwrap().alloc(ProcSlot {
2556
2866
  context_id,
2557
2867
  proc: Some(RootedProc(BoxValue::new(proc))),
2868
+ name: name.clone(),
2558
2869
  });
2559
2870
  let reply = self.run(
2560
2871
  ruby,
@@ -2592,6 +2903,7 @@ impl Core {
2592
2903
  let id = procs.alloc(ProcSlot {
2593
2904
  context_id,
2594
2905
  proc: Some(RootedProc(BoxValue::new(proc))),
2906
+ name: name.clone(),
2595
2907
  });
2596
2908
  (name, id)
2597
2909
  })
@@ -2619,11 +2931,20 @@ impl Core {
2619
2931
 
2620
2932
  fn reset(&self, ruby: &Ruby, context_id: i32) -> Result<Value, Error> {
2621
2933
  let reply = self.run(ruby, Request::Reset { context_id })?;
2622
- let out = self.reply_value(ruby, reply)?;
2934
+ // reply_value_deferred, not reply_value: releasing the procs has to happen
2935
+ // before a swallowed kill is finished, or the jump skips it and this
2936
+ // realm's procs stay GC-rooted and their slots unrecycled for the life of
2937
+ // the isolate — a permanent leak in a process that goes on running, since
2938
+ // a killed FIBER leaves its thread alive.
2939
+ let out = self.reply_value_deferred(ruby, reply);
2623
2940
  // Only on success — a refused reset (unknown/suspended realm) keeps
2624
- // its attached fns callable.
2625
- self.release_context_procs(context_id);
2626
- Ok(out)
2941
+ // its attached fns callable. No `?`: BOTH arms have to reach
2942
+ // resume_pending_fatal, or a kill swallowed during a reset that then
2943
+ // fails is left for some later operation, which drops it.
2944
+ if out.is_ok() {
2945
+ self.release_context_procs(context_id);
2946
+ }
2947
+ self.resume_pending_fatal(out)
2627
2948
  }
2628
2949
 
2629
2950
  // Build a new context; returns its id (replied as an Int).
@@ -2635,9 +2956,12 @@ impl Core {
2635
2956
 
2636
2957
  fn dispose_context(&self, ruby: &Ruby, context_id: i32) -> Result<(), Error> {
2637
2958
  let reply = self.run(ruby, Request::DisposeContext { context_id })?;
2638
- self.reply_value(ruby, reply)?;
2639
- self.release_context_procs(context_id);
2640
- Ok(())
2959
+ // Deferred, and both arms delivered, for the same reasons as reset's.
2960
+ let out = self.reply_value_deferred(ruby, reply);
2961
+ if out.is_ok() {
2962
+ self.release_context_procs(context_id);
2963
+ }
2964
+ self.resume_pending_fatal(out.map(|_| ()))
2641
2965
  }
2642
2966
 
2643
2967
  // Thin ESM primitives. compile_module returns the new module's id.
@@ -2714,7 +3038,10 @@ impl Core {
2714
3038
  // Reclaim THIS op's resolver error and restore the outer op's pair.
2715
3039
  let (_, resolver_err) = self.swap_instantiate(saved_resolve, saved_err);
2716
3040
  if let Some(exc) = resolver_err {
2717
- return Err(Error::from(*exc));
3041
+ // Through resume_pending_fatal like the tail below: this return is
3042
+ // just as much the end of the op, and skipping it would leave a
3043
+ // swallowed kill for some later op to assert.
3044
+ return self.resume_pending_fatal(Err(Error::from(*exc)));
2718
3045
  }
2719
3046
  self.reply_value(ruby, reply?)
2720
3047
  }
@@ -2892,7 +3219,10 @@ impl Core {
2892
3219
  if self.depth.load(Ordering::SeqCst) != 0 {
2893
3220
  return Err(Error::new(
2894
3221
  err_class(ruby, "Error"),
2895
- "RustyRacer: cannot dispose an isolate from within a running op or host callback",
3222
+ "RustyRacer: cannot dispose an isolate with an operation still in \
3223
+ flight — from inside a host callback, or from anywhere at all if a \
3224
+ host callback yielded out of a Fiber that has not been resumed \
3225
+ (see https://github.com/ursm/rusty_racer#fibers)",
2896
3226
  ));
2897
3227
  }
2898
3228
  shared.disposed = true;
@@ -2908,13 +3238,7 @@ impl Drop for Core {
2908
3238
  if self.shared.lock().unwrap().disposed {
2909
3239
  return;
2910
3240
  }
2911
- if current_ruby_thread() == self.owner {
2912
- // Last wrapper dropped on the owner thread: full teardown. depth is 0
2913
- // (a running op holds a wrapper alive, so the last drop can't race
2914
- // one), and Drop can't raise — so just tear down.
2915
- self.shared.lock().unwrap().disposed = true;
2916
- self.teardown();
2917
- } else {
3241
+ if current_ruby_thread() != self.owner {
2918
3242
  // Foreign-thread GC drop: a thread-bound isolate CANNOT be disposed
2919
3243
  // off its owner thread (that would SEGV) and Drop CANNOT raise — so
2920
3244
  // LEAK the OwnedIsolate (it stays in the owner thread's ISOLATES until
@@ -2923,15 +3247,45 @@ impl Drop for Core {
2923
3247
  // this leak; the counter makes it observable (RustyRacer.leaked_isolate_count).
2924
3248
  LEAKED_ISOLATES.fetch_add(1, Ordering::Relaxed);
2925
3249
  self.watchdog.request_shutdown();
3250
+ return;
2926
3251
  }
3252
+ // An op still in flight means a host callback yielded out of a Fiber that
3253
+ // was then never resumed — Ruby frees an unresumed Fiber's stack without
3254
+ // unwinding it, so the op's frames simply vanish and its wrapper with
3255
+ // them. The isolate is still ENTERED by those frames, and disposing an
3256
+ // entered isolate is a V8 fatal ("Disposing the isolate that is entered by
3257
+ // a thread"), which is the unexplained abort this binding exists to avoid.
3258
+ // Leak it instead: the process is on its way out anyway, and a leak that
3259
+ // says why beats a fatal that doesn't. This is the quiet twin of
3260
+ // fatal_nesting_broken — same cause, but nothing ever came back to detect
3261
+ // it, so it surfaces here at teardown.
3262
+ if self.depth.load(Ordering::SeqCst) != 0 {
3263
+ eprintln!(
3264
+ "\n[rusty_racer] warning: an isolate is being discarded with an \
3265
+ operation still in flight,\n\
3266
+ so it cannot be disposed and is leaked.\n\
3267
+ The usual cause is a host function that yielded out of a Fiber \
3268
+ which was never resumed;\n\
3269
+ `Fiber#kill` unwinds one cleanly if you still hold it. See\n\
3270
+ https://github.com/ursm/rusty_racer#fibers\n"
3271
+ );
3272
+ LEAKED_ISOLATES.fetch_add(1, Ordering::Relaxed);
3273
+ self.watchdog.request_shutdown();
3274
+ return;
3275
+ }
3276
+ // Last wrapper dropped on the owner thread with nothing in flight: full
3277
+ // teardown. Drop can't raise, so just tear down.
3278
+ self.shared.lock().unwrap().disposed = true;
3279
+ self.teardown();
2927
3280
  }
2928
3281
  }
2929
3282
 
2930
3283
  // RustyRacer.live_isolate_count -> Integer: isolates currently in the registry
2931
3284
  // (created, not yet disposed). RustyRacer.leaked_isolate_count -> Integer:
2932
- // isolates that could not be disposed because their last wrapper was dropped off
2933
- // the owner thread (see Drop) a workload that churns owner threads should keep
2934
- // this at 0 by disposing on the owner thread.
3285
+ // isolates that could not be disposed because their last wrapper was dropped
3286
+ // off the owner thread, or because an operation was still in flight (see Drop
3287
+ // for both) a workload that churns owner threads should keep this at 0 by
3288
+ // disposing on the owner thread.
2935
3289
  fn live_isolate_count() -> usize {
2936
3290
  isolates().lock().unwrap().len()
2937
3291
  }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RustyRacer
4
- VERSION = "0.2.1"
4
+ VERSION = "0.2.2"
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.2.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keita Urashima
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-14 00:00:00.000000000 Z
11
+ date: 2026-08-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys
@@ -54,7 +54,7 @@ metadata:
54
54
  source_code_uri: https://github.com/ursm/rusty_racer
55
55
  bug_tracker_uri: https://github.com/ursm/rusty_racer/issues
56
56
  rubygems_mfa_required: 'true'
57
- post_install_message:
57
+ post_install_message:
58
58
  rdoc_options: []
59
59
  require_paths:
60
60
  - lib
@@ -70,7 +70,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
70
70
  version: '0'
71
71
  requirements: []
72
72
  rubygems_version: 3.5.22
73
- signing_key:
73
+ signing_key:
74
74
  specification_version: 4
75
75
  summary: Embed V8 in Ruby via rusty_v8 + Magnus (rb-sys)
76
76
  test_files: []