rusty_racer 0.2.1 → 0.2.3

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: 9a799692e0bcc6c18bbba590786cd92b0f3cd5c05c65be4da085ec3a739ae2ee
4
+ data.tar.gz: 0ca41e25987f9f721437abbfc1278c43d7aa688c996a1824cb342d4b1672ed70
5
5
  SHA512:
6
- metadata.gz: c3ff8f690a9ad98912f2328d7be4bc10c3d1f38499365f628fbe4dac5e782832323bfc86976ac05fbe9406f288b75191fac11f1eb4f920d1caae056561d0eceb
7
- data.tar.gz: a13dad93b256b9f15eef3baa27e79a97e2d7da61ec20c29aefc66907cd1cef4916fb0c84d65742233d182d48fed1fa369b48e86105bba7fede8a6afdaa008133
6
+ metadata.gz: c63a41df38fbacdfc440a4bb61ff8f780315d2ce267a55d698505a7601557ba76a3762896dd12a4989aaf34d27ba2a7eb9c751a7249e7a71ae572a37d5af6133
7
+ data.tar.gz: e8deb0c3a86b25577996ee84d15f2163f6a2a8eab952d3c8592e4513f6e2cb483f3734ec7947dff22bad19cea9c5239284edd910473058baea5761921c510b8f
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,80 @@ 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 operation itself fails on the way out — see below for
341
+ why a kill reaches it as an ordinary error.
342
+
343
+ ### Killing a thread or Fiber inside a host function
344
+
345
+ **A `Thread#kill` or `Fiber#kill` that lands while a host function's Ruby is
346
+ running does not kill it.** It surfaces as `RustyRacer::RuntimeError: Error:
347
+ Fatal` from the `eval`/`call` that was in flight, and the thread keeps going —
348
+ and CRuby has already marked that thread as killed, so **a second `Thread#kill`
349
+ is a no-op**. `Thread#raise` still works, and is the way out if you have to
350
+ reach in from outside.
351
+
352
+ A kill inside a **module resolver** is quieter still: the specifier it was
353
+ resolving simply fails to link, so the error names the import
354
+ (`failed to resolve module specifier "./dep.js" imported from /app.js`) with no
355
+ mention of a kill at all.
356
+
357
+ This is not a choice, it is the boundary. Ruby unwinds a kill with `TAG_FATAL`,
358
+ which arrives while V8's C++ frames are on the stack — and a `longjmp` through
359
+ those is not survivable, so the callback has to catch it like any other error.
360
+ Nor can it be put back afterwards: `rb_jump_tag` does not carry the unwind with
361
+ it, it re-enters whatever the VM still holds in `$!`, and anything protected
362
+ that ran in between (one more host function raising is enough) has cleared it.
363
+ Jumping into that is a segfault in the interpreter rather than a late kill. 0.2.2
364
+ shipped an attempt at this and had to be withdrawn in 0.2.3.
365
+
366
+ So: if you need to stop work that calls into an isolate, **stop it at a Ruby
367
+ boundary you control** — a flag the host function checks, a `Queue` it reads —
368
+ rather than by killing the thread from outside. `Isolate#terminate` stops the
369
+ *JavaScript* (at V8's next interrupt check, see above), which is the other half
370
+ of the same job.
371
+
293
372
  ## Installation
294
373
 
295
374
  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.3"
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.
@@ -381,9 +533,9 @@ fn host_fn_callback(
381
533
  throw_js_error(scope, "host function has no owner");
382
534
  return;
383
535
  }
384
- let result: Result<JsVal, String> = with_gvl(|| {
536
+ let core = unsafe { &*core_ptr };
537
+ let result: Result<JsVal, String> = with_gvl(core, RubyCall::HostFn(host_fn_id), || {
385
538
  let ruby = Ruby::get().unwrap();
386
- let core = unsafe { &*core_ptr };
387
539
  // rb_protect so a Ruby exception raised during arg/return marshalling
388
540
  // (ary_new_capa / jsval_to_ruby / ruby_to_jsval can raise on OOM etc.)
389
541
  // is CAUGHT here instead of longjmp-ing through V8's C++ frames. The
@@ -940,6 +1092,7 @@ fn resolve_imported<'s>(
940
1092
  if core_ptr.is_null() {
941
1093
  return None;
942
1094
  }
1095
+ let core = unsafe { &*core_ptr };
943
1096
  // The static instantiate block parked for THIS op (Some) vs a dynamic
944
1097
  // import's auto-link (None -> dynamic_import_resolver, with the initiating
945
1098
  // realm so it can resolve per-realm).
@@ -950,8 +1103,8 @@ fn resolve_imported<'s>(
950
1103
  // The error conversion happens INSIDE with_gvl: error_to_exception
951
1104
  // calls into Ruby (so it can allocate, so it can GC) and BoxValue::new
952
1105
  // 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)
1106
+ match with_gvl(core, RubyCall::Resolver(&spec), || {
1107
+ resolve_module_via_ruby(core, resolve, &spec, &ref_url, None)
955
1108
  .map_err(|e| error_to_exception(&e).map(BoxValue::new))
956
1109
  }) {
957
1110
  Ok(id) => id,
@@ -965,24 +1118,18 @@ fn resolve_imported<'s>(
965
1118
  }
966
1119
  }
967
1120
  None => {
968
- let resolver = unsafe { &*core_ptr }
1121
+ let resolver = core
969
1122
  .dynamic_import_resolver
970
1123
  .lock()
971
1124
  .unwrap()
972
1125
  .as_ref()
973
1126
  .map(|r| r.get());
974
1127
  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())
1128
+ Some(p) => match with_gvl(core, RubyCall::Resolver(&spec), || {
1129
+ resolve_module_via_ruby(core, p, &spec, &ref_url, Some(here.unwrap_or(0)))
1130
+ // Rendering the message reads the Ruby exception, so it too
1131
+ // stays inside with_gvl.
1132
+ .map_err(|e| e.to_string())
986
1133
  }) {
987
1134
  Ok(id) => id,
988
1135
  // Unlike the static branch there is no Ruby frame waiting to
@@ -1087,7 +1234,8 @@ fn dynamic_import_cb<'s>(
1087
1234
  reject(scope, "dynamic import has no owner");
1088
1235
  return Some(promise);
1089
1236
  }
1090
- let resolver_proc = unsafe { &*core_ptr }
1237
+ let core = unsafe { &*core_ptr };
1238
+ let resolver_proc = core
1091
1239
  .dynamic_import_resolver
1092
1240
  .lock()
1093
1241
  .unwrap()
@@ -1096,8 +1244,8 @@ fn dynamic_import_cb<'s>(
1096
1244
  let id = match resolver_proc {
1097
1245
  // A raising resolver only fails the import() (it rejects generically);
1098
1246
  // 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))
1247
+ Some(p) => with_gvl(core, RubyCall::Resolver(&spec), || {
1248
+ resolve_module_via_ruby(core, p, &spec, &referrer, Some(initiating))
1101
1249
  })
1102
1250
  .unwrap_or(None),
1103
1251
  None => None,
@@ -2290,7 +2438,7 @@ impl Core {
2290
2438
  &self.installed_stack_limit,
2291
2439
  stack_top,
2292
2440
  );
2293
- if depth == 0 {
2441
+ let reply = if depth == 0 {
2294
2442
  let mut reply = std::panic::catch_unwind(AssertUnwindSafe(|| {
2295
2443
  v8::scope!(let scope, unsafe { &mut *iso });
2296
2444
  service_request(scope, request, true)
@@ -2359,15 +2507,38 @@ impl Core {
2359
2507
  unsafe { (*iso).exit() };
2360
2508
  }
2361
2509
  reply
2510
+ };
2511
+ // Everything that has to happen whatever else does goes HERE, inside
2512
+ // the closure, rather than after without_gvl returns:
2513
+ // rb_thread_call_without_gvl checks the thread's pending interrupts as
2514
+ // it re-acquires the GVL, and a Thread#raise, a Timeout, or a Ctrl-C
2515
+ // that lands there longjmps straight past everything written after the
2516
+ // call.
2517
+ //
2518
+ // Balancing the fetch_add is one: a missed decrement is permanent and
2519
+ // silent — the isolate then looks like it has an op in flight for the
2520
+ // rest of its life, so no later op is ever the outermost one
2521
+ // (microtasks stop draining, terminate flags stop being cleared) and
2522
+ // dispose refuses forever. The op is finished by this point: the scope
2523
+ // is dropped and the isolate exited in both branches above.
2524
+ //
2525
+ // Poisoning a panicked isolate is the other: None means the op left V8
2526
+ // possibly inconsistent, and an interrupt arriving on top of that must
2527
+ // not be what decides whether later ops are allowed to touch it. The
2528
+ // raise below still reports it — the lock is safe with the GVL
2529
+ // released, since every other holder takes it briefly and none waits
2530
+ // on an op.
2531
+ if reply.is_none() {
2532
+ self.shared.lock().unwrap().disposed = true;
2362
2533
  }
2534
+ self.depth.fetch_sub(1, Ordering::SeqCst);
2535
+ reply
2363
2536
  });
2364
- self.depth.fetch_sub(1, Ordering::SeqCst);
2365
2537
  match reply {
2366
2538
  Some(reply) => Ok(reply),
2367
2539
  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;
2540
+ // The op panicked; the isolate was poisoned inside the closure
2541
+ // above (every later op refuses) rather than risk using it.
2371
2542
  Err(Error::new(
2372
2543
  err_class(ruby, "InternalError"),
2373
2544
  "internal error: operation panicked; the isolate has been disposed",
@@ -2555,6 +2726,7 @@ impl Core {
2555
2726
  let host_fn_id = self.procs.lock().unwrap().alloc(ProcSlot {
2556
2727
  context_id,
2557
2728
  proc: Some(RootedProc(BoxValue::new(proc))),
2729
+ name: name.clone(),
2558
2730
  });
2559
2731
  let reply = self.run(
2560
2732
  ruby,
@@ -2592,6 +2764,7 @@ impl Core {
2592
2764
  let id = procs.alloc(ProcSlot {
2593
2765
  context_id,
2594
2766
  proc: Some(RootedProc(BoxValue::new(proc))),
2767
+ name: name.clone(),
2595
2768
  });
2596
2769
  (name, id)
2597
2770
  })
@@ -2892,7 +3065,10 @@ impl Core {
2892
3065
  if self.depth.load(Ordering::SeqCst) != 0 {
2893
3066
  return Err(Error::new(
2894
3067
  err_class(ruby, "Error"),
2895
- "RustyRacer: cannot dispose an isolate from within a running op or host callback",
3068
+ "RustyRacer: cannot dispose an isolate with an operation still in \
3069
+ flight — from inside a host callback, or from anywhere at all if a \
3070
+ host callback yielded out of a Fiber that has not been resumed \
3071
+ (see https://github.com/ursm/rusty_racer#fibers)",
2896
3072
  ));
2897
3073
  }
2898
3074
  shared.disposed = true;
@@ -2908,13 +3084,7 @@ impl Drop for Core {
2908
3084
  if self.shared.lock().unwrap().disposed {
2909
3085
  return;
2910
3086
  }
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 {
3087
+ if current_ruby_thread() != self.owner {
2918
3088
  // Foreign-thread GC drop: a thread-bound isolate CANNOT be disposed
2919
3089
  // off its owner thread (that would SEGV) and Drop CANNOT raise — so
2920
3090
  // LEAK the OwnedIsolate (it stays in the owner thread's ISOLATES until
@@ -2923,15 +3093,45 @@ impl Drop for Core {
2923
3093
  // this leak; the counter makes it observable (RustyRacer.leaked_isolate_count).
2924
3094
  LEAKED_ISOLATES.fetch_add(1, Ordering::Relaxed);
2925
3095
  self.watchdog.request_shutdown();
3096
+ return;
2926
3097
  }
3098
+ // An op still in flight means a host callback yielded out of a Fiber that
3099
+ // was then never resumed — Ruby frees an unresumed Fiber's stack without
3100
+ // unwinding it, so the op's frames simply vanish and its wrapper with
3101
+ // them. The isolate is still ENTERED by those frames, and disposing an
3102
+ // entered isolate is a V8 fatal ("Disposing the isolate that is entered by
3103
+ // a thread"), which is the unexplained abort this binding exists to avoid.
3104
+ // Leak it instead: the process is on its way out anyway, and a leak that
3105
+ // says why beats a fatal that doesn't. This is the quiet twin of
3106
+ // fatal_nesting_broken — same cause, but nothing ever came back to detect
3107
+ // it, so it surfaces here at teardown.
3108
+ if self.depth.load(Ordering::SeqCst) != 0 {
3109
+ eprintln!(
3110
+ "\n[rusty_racer] warning: an isolate is being discarded with an \
3111
+ operation still in flight,\n\
3112
+ so it cannot be disposed and is leaked.\n\
3113
+ The usual cause is a host function that yielded out of a Fiber \
3114
+ which was never resumed;\n\
3115
+ `Fiber#kill` recovers one if you still hold it. See\n\
3116
+ https://github.com/ursm/rusty_racer#fibers\n"
3117
+ );
3118
+ LEAKED_ISOLATES.fetch_add(1, Ordering::Relaxed);
3119
+ self.watchdog.request_shutdown();
3120
+ return;
3121
+ }
3122
+ // Last wrapper dropped on the owner thread with nothing in flight: full
3123
+ // teardown. Drop can't raise, so just tear down.
3124
+ self.shared.lock().unwrap().disposed = true;
3125
+ self.teardown();
2927
3126
  }
2928
3127
  }
2929
3128
 
2930
3129
  // RustyRacer.live_isolate_count -> Integer: isolates currently in the registry
2931
3130
  // (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.
3131
+ // isolates that could not be disposed because their last wrapper was dropped
3132
+ // off the owner thread, or because an operation was still in flight (see Drop
3133
+ // for both) a workload that churns owner threads should keep this at 0 by
3134
+ // disposing on the owner thread.
2935
3135
  fn live_isolate_count() -> usize {
2936
3136
  isolates().lock().unwrap().len()
2937
3137
  }
@@ -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.3"
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.3
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-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