rusty_racer 0.2.0 → 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: f26a45e0afbed589acb2749fc1817e7d8508622c81a70fc378dc4af4a0f54714
4
- data.tar.gz: 718074b8d9f468b9ab3da144391ecbeff1d00db347812e5209cd11d866f78f6e
3
+ metadata.gz: 03be69f3c1409a64a73622db3063447bbea7813f393227d508739f0c636c6859
4
+ data.tar.gz: 7a7ecb5203032be6f8b27133444ce8c3663eb7d9d49aa86726be8c28acf19425
5
5
  SHA512:
6
- metadata.gz: 03b227aa3eba1f597b34bca826f947c4345a8c16accebce84f267d5fcb3df2e9c72e3343e261be857387ada97000769dfca0b449130c9056bc8c6aceb569cd03
7
- data.tar.gz: fbe970e400edf1041271954df3ba7f3be5d1f8f3775ca29d09398065b85d9b813bc9ca8b9ae8302bd32757884f8c47af5ba9953937ca6de0486af8bd7e50959b
6
+ metadata.gz: 57fb55d034906210851ea93be3ad933ee366dfccd1419d4f7f712ed5407082565ccda4ed0e0abadde12ec9af1b958ffab908cbdfb40ef5efa188ed8d2f47154e
7
+ data.tar.gz: 56983cedc5a48dc0a9cf1570da7a7241a6be6a3a8c80ca7114204e6764bc377a0d72814147e318c64974093b625148e8260d8e75168a76e1fa22711a94e5ed69
data/README.md CHANGED
@@ -65,6 +65,7 @@ ctx.eval("({a: 1, b: [true, 'x']})") # => {"a"=>1, "b"=>[true, "x"]}
65
65
  ctx.eval("function add(a, b) { return a + b }")
66
66
  ctx.call("add", 20, 22) # => 42
67
67
  ctx.call_void("doSideEffect") # runs it; never marshals the return
68
+ ctx.eval_void("globalThis.app = boot()") # ditto for the completion value
68
69
 
69
70
  # Ruby callbacks into JS; a raised Ruby exception becomes a JS exception.
70
71
  ctx.attach("rubyUpcase", ->(s) { s.upcase })
@@ -85,6 +86,14 @@ covers the library. Under it: `ParseError`, `RuntimeError`,
85
86
  `DisposedError` for using an isolate (or anything it handed out) after
86
87
  disposing it; `WrongThreadError` for reaching an isolate from the wrong thread.
87
88
 
89
+ Reading a result is not passive — marshalling runs JS (getters, `Proxy` traps,
90
+ `toString`), and a throw there fails the operation even though the code itself
91
+ ran to completion. When the value is incidental, say so: `#eval_void`,
92
+ `Script#run_void` and `#call_void` run for the effect and never touch the
93
+ result. A statement list has a completion value whether you want one or not, so
94
+ plumbing an object into a global (`globalThis.win = someProxy`) is enough to
95
+ make a plain `#eval` fail after every one of its writes has landed.
96
+
88
97
  ES modules (the embedder owns the URL→module registry):
89
98
 
90
99
  ```ruby
@@ -249,7 +258,12 @@ to one native thread (rusty_v8 exposes no `v8::Locker`), so using it from anothe
249
258
  thread raises `RustyRacer::WrongThreadError` rather than corrupting the VM.
250
259
 
251
260
  - **`Isolate#terminate` is the one exception** — it is safe to call from any
252
- 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.
253
267
  - **Dispose on the owner thread.** `Isolate#dispose` must run on the owner
254
268
  thread. If the last reference to an isolate is instead garbage-collected on a
255
269
  *different* thread (e.g. its owner thread already exited), it cannot be
@@ -281,6 +295,54 @@ process on the next GC or thrown exception. So **don't call into an isolate from
281
295
  inside a Fiber on a worker thread**; drive isolate ops directly on the thread, or
282
296
  keep Fiber/Enumerator-mediated JS calls on the main thread.
283
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
+
284
346
  ## Installation
285
347
 
286
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.0"
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
@@ -1997,8 +2177,16 @@ fn build_snapshot(code: &str, base: Option<Vec<u8>>, warmup: bool) -> Result<Vec
1997
2177
  {
1998
2178
  let cscope = &mut v8::ContextScope::new(scope, context);
1999
2179
  if !code.is_empty()
2000
- && let Err(e) =
2001
- run_source(cscope, code, if warmup { "<warmup>" } else { "<snapshot>" })
2180
+ && let Err(e) = run_source(
2181
+ cscope,
2182
+ code,
2183
+ if warmup { "<warmup>" } else { "<snapshot>" },
2184
+ // Snapshot/warmup code runs for its EFFECT (the heap it
2185
+ // leaves behind); nothing reads its completion value, so
2186
+ // don't marshal it — a bundle ending in an expression must
2187
+ // not fail the snapshot over a value no one wants.
2188
+ true,
2189
+ )
2002
2190
  {
2003
2191
  err = Some(match e {
2004
2192
  VmError::Parse(m) | VmError::Runtime(m) => m,
@@ -2131,6 +2319,7 @@ impl Isolate {
2131
2319
  iso_ptr,
2132
2320
  scan_start_field: std::sync::atomic::AtomicUsize::new(0),
2133
2321
  installed_stack_limit: std::sync::atomic::AtomicUsize::new(0),
2322
+ pending_fatal: std::sync::atomic::AtomicUsize::new(0),
2134
2323
  depth: std::sync::atomic::AtomicU32::new(0),
2135
2324
  procs: Mutex::new(ProcTable::default()),
2136
2325
  default_timeout_ms: timeout_ms,
@@ -2282,7 +2471,7 @@ impl Core {
2282
2471
  &self.installed_stack_limit,
2283
2472
  stack_top,
2284
2473
  );
2285
- if depth == 0 {
2474
+ let reply = if depth == 0 {
2286
2475
  let mut reply = std::panic::catch_unwind(AssertUnwindSafe(|| {
2287
2476
  v8::scope!(let scope, unsafe { &mut *iso });
2288
2477
  service_request(scope, request, true)
@@ -2351,15 +2540,38 @@ impl Core {
2351
2540
  unsafe { (*iso).exit() };
2352
2541
  }
2353
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;
2354
2566
  }
2567
+ self.depth.fetch_sub(1, Ordering::SeqCst);
2568
+ reply
2355
2569
  });
2356
- self.depth.fetch_sub(1, Ordering::SeqCst);
2357
2570
  match reply {
2358
2571
  Some(reply) => Ok(reply),
2359
2572
  None => {
2360
- // The op panicked: V8 may be left inconsistent, so POISON the
2361
- // isolate (every later op refuses) rather than risk using it.
2362
- 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.
2363
2575
  Err(Error::new(
2364
2576
  err_class(ruby, "InternalError"),
2365
2577
  "internal error: operation panicked; the isolate has been disposed",
@@ -2368,10 +2580,58 @@ impl Core {
2368
2580
  }
2369
2581
  }
2370
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
+
2371
2622
  // Map a terminal reply to a Ruby value (the common eval/call/run shape).
2372
2623
  // &self so a Terminated outcome can pick up the JS stack the watchdog snapshot
2373
2624
  // captured (see timeout_interrupt / take_timeout_backtrace).
2374
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> {
2375
2635
  match reply {
2376
2636
  VmReply::Done(Ok(val)) => jsval_to_ruby(ruby, &val),
2377
2637
  // Clone (don't take): a nested timeout's terminate unwinds the whole op
@@ -2399,6 +2659,64 @@ impl Core {
2399
2659
  .clone()
2400
2660
  }
2401
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
+
2402
2720
  fn call_proc(&self, ruby: &Ruby, host_fn_id: usize, args: &[JsVal]) -> Result<JsVal, String> {
2403
2721
  let proc = {
2404
2722
  let procs = self.procs.lock().unwrap();
@@ -2420,16 +2738,16 @@ impl Core {
2420
2738
  let ruby_args = ruby.ary_new_capa(args.len());
2421
2739
  for v in args {
2422
2740
  ruby_args
2423
- .push(jsval_to_ruby(ruby, v).map_err(|e| e.to_string())?)
2424
- .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))?;
2425
2743
  }
2426
2744
  // SAFETY: ruby_args is a live local (so GC keeps it and its elements) and
2427
2745
  // is not mutated while the slice is borrowed — as_slice's contract. A VM
2428
2746
  // op the proc issues re-enters Core::run (depth > 0) directly — no nested
2429
2747
  // frame bookkeeping is needed any more (the call stack IS the nesting).
2430
2748
  let result: Result<Value, Error> = proc.call(unsafe { ruby_args.as_slice() });
2431
- let value = result.map_err(|e| e.to_string())?;
2432
- 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))
2433
2751
  }
2434
2752
 
2435
2753
  // Context#call (and call_void). Resolves a dotted function path
@@ -2522,6 +2840,7 @@ impl Core {
2522
2840
  source: String,
2523
2841
  filename: String,
2524
2842
  timeout_ms: u64,
2843
+ void: bool,
2525
2844
  ) -> Result<Value, Error> {
2526
2845
  let reply = self.run(
2527
2846
  ruby,
@@ -2530,6 +2849,7 @@ impl Core {
2530
2849
  source,
2531
2850
  filename,
2532
2851
  timeout_ms,
2852
+ void,
2533
2853
  },
2534
2854
  )?;
2535
2855
  self.reply_value(ruby, reply)
@@ -2545,6 +2865,7 @@ impl Core {
2545
2865
  let host_fn_id = self.procs.lock().unwrap().alloc(ProcSlot {
2546
2866
  context_id,
2547
2867
  proc: Some(RootedProc(BoxValue::new(proc))),
2868
+ name: name.clone(),
2548
2869
  });
2549
2870
  let reply = self.run(
2550
2871
  ruby,
@@ -2582,6 +2903,7 @@ impl Core {
2582
2903
  let id = procs.alloc(ProcSlot {
2583
2904
  context_id,
2584
2905
  proc: Some(RootedProc(BoxValue::new(proc))),
2906
+ name: name.clone(),
2585
2907
  });
2586
2908
  (name, id)
2587
2909
  })
@@ -2609,11 +2931,20 @@ impl Core {
2609
2931
 
2610
2932
  fn reset(&self, ruby: &Ruby, context_id: i32) -> Result<Value, Error> {
2611
2933
  let reply = self.run(ruby, Request::Reset { context_id })?;
2612
- 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);
2613
2940
  // Only on success — a refused reset (unknown/suspended realm) keeps
2614
- // its attached fns callable.
2615
- self.release_context_procs(context_id);
2616
- 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)
2617
2948
  }
2618
2949
 
2619
2950
  // Build a new context; returns its id (replied as an Int).
@@ -2625,9 +2956,12 @@ impl Core {
2625
2956
 
2626
2957
  fn dispose_context(&self, ruby: &Ruby, context_id: i32) -> Result<(), Error> {
2627
2958
  let reply = self.run(ruby, Request::DisposeContext { context_id })?;
2628
- self.reply_value(ruby, reply)?;
2629
- self.release_context_procs(context_id);
2630
- 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(|_| ()))
2631
2965
  }
2632
2966
 
2633
2967
  // Thin ESM primitives. compile_module returns the new module's id.
@@ -2704,7 +3038,10 @@ impl Core {
2704
3038
  // Reclaim THIS op's resolver error and restore the outer op's pair.
2705
3039
  let (_, resolver_err) = self.swap_instantiate(saved_resolve, saved_err);
2706
3040
  if let Some(exc) = resolver_err {
2707
- 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)));
2708
3045
  }
2709
3046
  self.reply_value(ruby, reply?)
2710
3047
  }
@@ -2773,12 +3110,13 @@ impl Core {
2773
3110
  }
2774
3111
  }
2775
3112
 
2776
- fn run_script(&self, ruby: &Ruby, script_id: i32) -> Result<Value, Error> {
3113
+ fn run_script(&self, ruby: &Ruby, script_id: i32, void: bool) -> Result<Value, Error> {
2777
3114
  let reply = self.run(
2778
3115
  ruby,
2779
3116
  Request::RunScript {
2780
3117
  script_id,
2781
3118
  timeout_ms: self.default_timeout_ms,
3119
+ void,
2782
3120
  },
2783
3121
  )?;
2784
3122
  self.reply_value(ruby, reply)
@@ -2881,7 +3219,10 @@ impl Core {
2881
3219
  if self.depth.load(Ordering::SeqCst) != 0 {
2882
3220
  return Err(Error::new(
2883
3221
  err_class(ruby, "Error"),
2884
- "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)",
2885
3226
  ));
2886
3227
  }
2887
3228
  shared.disposed = true;
@@ -2897,13 +3238,7 @@ impl Drop for Core {
2897
3238
  if self.shared.lock().unwrap().disposed {
2898
3239
  return;
2899
3240
  }
2900
- if current_ruby_thread() == self.owner {
2901
- // Last wrapper dropped on the owner thread: full teardown. depth is 0
2902
- // (a running op holds a wrapper alive, so the last drop can't race
2903
- // one), and Drop can't raise — so just tear down.
2904
- self.shared.lock().unwrap().disposed = true;
2905
- self.teardown();
2906
- } else {
3241
+ if current_ruby_thread() != self.owner {
2907
3242
  // Foreign-thread GC drop: a thread-bound isolate CANNOT be disposed
2908
3243
  // off its owner thread (that would SEGV) and Drop CANNOT raise — so
2909
3244
  // LEAK the OwnedIsolate (it stays in the owner thread's ISOLATES until
@@ -2912,15 +3247,45 @@ impl Drop for Core {
2912
3247
  // this leak; the counter makes it observable (RustyRacer.leaked_isolate_count).
2913
3248
  LEAKED_ISOLATES.fetch_add(1, Ordering::Relaxed);
2914
3249
  self.watchdog.request_shutdown();
3250
+ return;
2915
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();
2916
3280
  }
2917
3281
  }
2918
3282
 
2919
3283
  // RustyRacer.live_isolate_count -> Integer: isolates currently in the registry
2920
3284
  // (created, not yet disposed). RustyRacer.leaked_isolate_count -> Integer:
2921
- // isolates that could not be disposed because their last wrapper was dropped off
2922
- // the owner thread (see Drop) a workload that churns owner threads should keep
2923
- // 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.
2924
3289
  fn live_isolate_count() -> usize {
2925
3290
  isolates().lock().unwrap().len()
2926
3291
  }
@@ -3008,12 +3373,14 @@ impl Context {
3008
3373
  Ok(())
3009
3374
  }
3010
3375
  // timeout_ms 0 = use the isolate's default; an explicit value overrides it.
3376
+ // |void| discards the completion value instead of marshalling it.
3011
3377
  fn eval(
3012
3378
  ruby: &Ruby,
3013
3379
  rb_self: &Self,
3014
3380
  source: String,
3015
3381
  timeout_ms: u64,
3016
3382
  filename: String,
3383
+ void: bool,
3017
3384
  ) -> Result<Value, Error> {
3018
3385
  rb_self.check_live(ruby)?;
3019
3386
  let timeout = if timeout_ms == 0 {
@@ -3023,7 +3390,7 @@ impl Context {
3023
3390
  };
3024
3391
  rb_self
3025
3392
  .core
3026
- .eval_t(ruby, rb_self.id, source, filename, timeout)
3393
+ .eval_t(ruby, rb_self.id, source, filename, timeout, void)
3027
3394
  }
3028
3395
  fn call(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result<Value, Error> {
3029
3396
  rb_self.check_live(ruby)?;
@@ -3285,7 +3652,13 @@ impl Script {
3285
3652
  // thrown exception is a RuntimeError; a timeout/stop a ScriptTerminatedError.
3286
3653
  fn run(ruby: &Ruby, rb_self: &Self) -> Result<Value, Error> {
3287
3654
  rb_self.check_live(ruby)?;
3288
- rb_self.core.run_script(ruby, rb_self.script_id)
3655
+ rb_self.core.run_script(ruby, rb_self.script_id, false)
3656
+ }
3657
+ // Run it for its effect only, discarding the completion value (see
3658
+ // Context#eval_void) — what a <script> tag does.
3659
+ fn run_void(ruby: &Ruby, rb_self: &Self) -> Result<Value, Error> {
3660
+ rb_self.check_live(ruby)?;
3661
+ rb_self.core.run_script(ruby, rb_self.script_id, true)
3289
3662
  }
3290
3663
  fn cached_data(ruby: &Ruby, rb_self: &Self) -> Value {
3291
3664
  code_cache_value(ruby, rb_self.cached_data.as_ref())
@@ -3506,8 +3879,9 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
3506
3879
 
3507
3880
  // A v8::Context (realm): eval/call/attach/compile_module.
3508
3881
  let context = module.define_class("Context", ruby.class_object())?;
3509
- // keyword-arg wrapper Context#eval(source, timeout_ms:, filename:) in lib.
3510
- context.define_method("_eval", method!(Context::eval, 3))?;
3882
+ // Backs both keyword-arg wrappers in lib: Context#eval(source, timeout_ms:,
3883
+ // filename:) and #eval_void, which differ only in the 4th arg (the void flag).
3884
+ context.define_method("_eval", method!(Context::eval, 4))?;
3511
3885
  context.define_method("call", method!(Context::call, -1))?;
3512
3886
  context.define_method("call_void", method!(Context::call_void, -1))?;
3513
3887
  context.define_method("attach", method!(Context::attach, 2))?;
@@ -3523,6 +3897,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
3523
3897
  // Classic compiled script: Context#compile -> #run / #cached_data.
3524
3898
  let script = module.define_class("Script", ruby.class_object())?;
3525
3899
  script.define_method("run", method!(Script::run, 0))?;
3900
+ script.define_method("run_void", method!(Script::run_void, 0))?;
3526
3901
  script.define_method("cached_data", method!(Script::cached_data, 0))?;
3527
3902
  script.define_method("cache_rejected?", method!(Script::cache_rejected, 0))?;
3528
3903
  script.define_method("create_code_cache", method!(Script::create_code_cache, 0))?;
@@ -51,6 +51,11 @@ pub(crate) enum Request {
51
51
  source: String,
52
52
  filename: String,
53
53
  timeout_ms: u64,
54
+ // void = don't marshal the completion value (see Call's |void|). A
55
+ // statement-list eval run for its EFFECT still has one — the last
56
+ // statement's value — and marshalling it can throw (an accessor, a
57
+ // Proxy trap) or walk a huge graph, for a value the caller discards.
58
+ void: bool,
54
59
  },
55
60
  // Resolve a dotted function path on globalThis and invoke it with marshalled
56
61
  // args (v8::Function::call), preserving the holder as `this`. Distinct from
@@ -146,10 +151,12 @@ pub(crate) enum Request {
146
151
  produce_cache: bool,
147
152
  eager: bool,
148
153
  },
149
- // Bind the script to its context and run it; returns the completion value.
154
+ // Bind the script to its context and run it; returns the completion value
155
+ // (unless |void| — see Eval).
150
156
  RunScript {
151
157
  script_id: i32,
152
158
  timeout_ms: u64,
159
+ void: bool,
153
160
  },
154
161
  DisposeScript {
155
162
  script_id: i32,
@@ -220,6 +227,7 @@ pub(crate) fn run_source(
220
227
  scope: &mut v8::PinScope<'_, '_>,
221
228
  source: &str,
222
229
  filename: &str,
230
+ void: bool,
223
231
  ) -> Result<JsVal, VmError> {
224
232
  v8::tc_scope!(let tc, scope);
225
233
  // Compile and run as distinct phases so a compile failure maps to
@@ -252,6 +260,7 @@ pub(crate) fn run_source(
252
260
  }
253
261
  };
254
262
  match script.run(tc) {
263
+ Some(_) if void => Ok(JsVal::Undefined),
255
264
  Some(value) => marshalled!(tc, value),
256
265
  None if tc.has_terminated() => Err(VmError::Terminated),
257
266
  None => {
@@ -448,7 +457,10 @@ fn dispatch_one(
448
457
  source,
449
458
  filename,
450
459
  timeout_ms,
451
- } => op_eval(scope, context_id, source, filename, timeout_ms, outermost),
460
+ void,
461
+ } => op_eval(
462
+ scope, context_id, source, filename, timeout_ms, void, outermost,
463
+ ),
452
464
  Request::Call {
453
465
  context_id,
454
466
  name,
@@ -515,7 +527,8 @@ fn dispatch_one(
515
527
  Request::RunScript {
516
528
  script_id,
517
529
  timeout_ms,
518
- } => op_run_script(scope, script_id, timeout_ms, outermost),
530
+ void,
531
+ } => op_run_script(scope, script_id, timeout_ms, void, outermost),
519
532
  Request::DisposeScript { script_id } => op_dispose_script(scope, script_id),
520
533
  // Serialize the script's CURRENT compile state. The stored handle is
521
534
  // the UnboundScript, which V8 fills in with inner-function bytecode as
@@ -558,12 +571,14 @@ fn op_low_memory_notification(scope: &mut v8::PinScope<'_, '_, ()>) -> VmReply {
558
571
  VmReply::Done(Ok(JsVal::Undefined))
559
572
  }
560
573
 
574
+ #[allow(clippy::too_many_arguments)]
561
575
  fn op_eval(
562
576
  scope: &mut v8::PinScope<'_, '_, ()>,
563
577
  context_id: i32,
564
578
  source: String,
565
579
  filename: String,
566
580
  timeout_ms: u64,
581
+ void: bool,
567
582
  outermost: bool,
568
583
  ) -> VmReply {
569
584
  let outcome = run_js_bracketed(scope, outermost, timeout_ms, "eval", |scope, outermost| {
@@ -572,7 +587,7 @@ fn op_eval(
572
587
  Some(ctx) => {
573
588
  let context = v8::Local::new(scope, &ctx);
574
589
  let scope = &mut v8::ContextScope::new(scope, context);
575
- let out = run_source(scope, &source, &filename);
590
+ let out = run_source(scope, &source, &filename, void);
576
591
  auto_drain(scope, outermost);
577
592
  (true, out)
578
593
  }
@@ -1278,6 +1293,7 @@ fn op_run_script(
1278
1293
  scope: &mut v8::PinScope<'_, '_, ()>,
1279
1294
  script_id: i32,
1280
1295
  timeout_ms: u64,
1296
+ void: bool,
1281
1297
  outermost: bool,
1282
1298
  ) -> VmReply {
1283
1299
  let outcome = run_js_bracketed(
@@ -1302,6 +1318,7 @@ fn op_run_script(
1302
1318
  let out = {
1303
1319
  v8::tc_scope!(let tc, scope);
1304
1320
  match script.run(tc) {
1321
+ Some(_) if void => Ok(JsVal::Undefined),
1305
1322
  Some(value) => marshalled!(tc, value),
1306
1323
  None if tc.has_terminated() => Err(VmError::Terminated),
1307
1324
  None => {
@@ -35,9 +35,15 @@ module RustyRacer
35
35
  # ExecJS guarantees a bare global (no browser/Node ambient): V8 installs a
36
36
  # default `console`, so drop it to match the contract (consumers attach
37
37
  # their own if needed), exactly as the mini_racer runtime does.
38
- @context.eval('delete globalThis.console')
38
+ @context.eval_void('delete globalThis.console')
39
39
  source = encode(source)
40
- translate { @context.eval(source, filename: LOCATION) } if /\S/.match?(source)
40
+ # eval_void: a bundle is run to POPULATE the context, and its completion
41
+ # value — the last thing a UMD wrapper happened to evaluate — is nobody's
42
+ # answer. Marshalling it would walk the whole exports graph on every
43
+ # context creation, and a lazy/hostile property there would fail the
44
+ # constructor over a value ExecJS discards. #exec/#eval below must NOT
45
+ # use it: they read the JSON their wrapper returns.
46
+ translate { @context.eval_void(source, filename: LOCATION) } if /\S/.match?(source)
41
47
  end
42
48
 
43
49
  # Run statements in a function body and return what they `return` (nil when
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RustyRacer
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.2"
5
5
  end
data/lib/rusty_racer.rb CHANGED
@@ -112,7 +112,18 @@ module RustyRacer
112
112
  # `timeout_ms` (0 = the isolate default) caps this eval; `filename` names the
113
113
  # script in stack traces and parse-error locations.
114
114
  def eval(source, timeout_ms: 0, filename: '<eval>')
115
- _eval(source, timeout_ms, filename)
115
+ _eval(source, timeout_ms, filename, false)
116
+ end
117
+
118
+ # Run `source` for its EFFECT and return nil, without marshalling its
119
+ # completion value — the counterpart of #call_void, and what a <script> tag
120
+ # does. Worth reaching for whenever the value is incidental: a statement
121
+ # list still has one (its last statement's), and marshalling reads the
122
+ # value, which runs JS — a getter or a Proxy trap can throw, failing an eval
123
+ # whose writes all landed. `globalThis.win = someProxy` is the shape that
124
+ # bites.
125
+ def eval_void(source, timeout_ms: 0, filename: '<eval>')
126
+ _eval(source, timeout_ms, filename, true)
116
127
  end
117
128
 
118
129
  # Compile a classic <script>; returns a RustyRacer::Script to #run.
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.0
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: []