rusty_racer 0.2.2 → 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: 03be69f3c1409a64a73622db3063447bbea7813f393227d508739f0c636c6859
4
- data.tar.gz: 7a7ecb5203032be6f8b27133444ce8c3663eb7d9d49aa86726be8c28acf19425
3
+ metadata.gz: 9a799692e0bcc6c18bbba590786cd92b0f3cd5c05c65be4da085ec3a739ae2ee
4
+ data.tar.gz: 0ca41e25987f9f721437abbfc1278c43d7aa688c996a1824cb342d4b1672ed70
5
5
  SHA512:
6
- metadata.gz: 57fb55d034906210851ea93be3ad933ee366dfccd1419d4f7f712ed5407082565ccda4ed0e0abadde12ec9af1b958ffab908cbdfb40ef5efa188ed8d2f47154e
7
- data.tar.gz: 56983cedc5a48dc0a9cf1570da7a7241a6be6a3a8c80ca7114204e6764bc377a0d72814147e318c64974093b625148e8260d8e75168a76e1fa22711a94e5ed69
6
+ metadata.gz: c63a41df38fbacdfc440a4bb61ff8f780315d2ce267a55d698505a7601557ba76a3762896dd12a4989aaf34d27ba2a7eb9c751a7249e7a71ae572a37d5af6133
7
+ data.tar.gz: e8deb0c3a86b25577996ee84d15f2163f6a2a8eab952d3c8592e4513f6e2cb483f3734ec7947dff22bad19cea9c5239284edd910473058baea5761921c510b8f
data/README.md CHANGED
@@ -337,11 +337,37 @@ What happens next depends on what the stranded operation's Fiber does:
337
337
  The isolate cannot be disposed after that; it is leaked, with a warning saying
338
338
  why. If you still hold the Fiber, **`Fiber#kill` recovers it**: it resumes the
339
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.)
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.
345
371
 
346
372
  ## Installation
347
373
 
@@ -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.2"
8
+ version = "0.2.3"
9
9
  edition = "2024"
10
10
  publish = false
11
11
 
@@ -472,19 +472,6 @@ fn current_ruby_thread() -> usize {
472
472
  unsafe { rb_sys::rb_thread_current() as usize }
473
473
  }
474
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
-
488
475
  // Reduce a magnus Error to a single Exception INSTANCE so it can be GC-rooted and
489
476
  // re-raised later WITH ITS ORIGINAL CLASS. A Ruby proc's raise is already an
490
477
  // instance; an Error::new(class, msg) from our own code becomes an instance of
@@ -560,7 +547,7 @@ fn host_fn_callback(
560
547
  ruby.qnil().as_raw()
561
548
  }) {
562
549
  Ok(_) => out.unwrap_or_else(|| Err("host function did not complete".into())),
563
- Err(e) => Err(core.swallow(e)),
550
+ Err(e) => Err(format!("{e}")),
564
551
  }
565
552
  });
566
553
  match result {
@@ -1117,10 +1104,8 @@ fn resolve_imported<'s>(
1117
1104
  // calls into Ruby (so it can allocate, so it can GC) and BoxValue::new
1118
1105
  // registers a GC root — neither is safe with the GVL released.
1119
1106
  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
- })
1107
+ resolve_module_via_ruby(core, resolve, &spec, &ref_url, None)
1108
+ .map_err(|e| error_to_exception(&e).map(BoxValue::new))
1124
1109
  }) {
1125
1110
  Ok(id) => id,
1126
1111
  // Stash the resolver's own raised exception (GC-rooted) so the
@@ -1144,7 +1129,7 @@ fn resolve_imported<'s>(
1144
1129
  resolve_module_via_ruby(core, p, &spec, &ref_url, Some(here.unwrap_or(0)))
1145
1130
  // Rendering the message reads the Ruby exception, so it too
1146
1131
  // stays inside with_gvl.
1147
- .map_err(|e| core.swallow(e))
1132
+ .map_err(|e| e.to_string())
1148
1133
  }) {
1149
1134
  Ok(id) => id,
1150
1135
  // Unlike the static branch there is no Ruby frame waiting to
@@ -1258,17 +1243,11 @@ fn dynamic_import_cb<'s>(
1258
1243
  .map(|r| r.get());
1259
1244
  let id = match resolver_proc {
1260
1245
  // A raising resolver only fails the import() (it rejects generically);
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.
1246
+ // it must NOT abort the surrounding eval, so swallow the Err here.
1264
1247
  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
- }),
1248
+ resolve_module_via_ruby(core, p, &spec, &referrer, Some(initiating))
1249
+ })
1250
+ .unwrap_or(None),
1272
1251
  None => None,
1273
1252
  };
1274
1253
  match id {
@@ -1515,17 +1494,6 @@ struct Core {
1515
1494
  // op. Owner-thread only, like the isolate itself; AtomicUsize for shared
1516
1495
  // &Core access.
1517
1496
  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,
1529
1497
  // Re-entry depth for THIS isolate, readable without a scope (the runner needs
1530
1498
  // it to choose the scope kind before any scope exists): 0 = top-level op
1531
1499
  // (open a fresh HandleScope from iso_ptr); >0 = a host callback is on the V8
@@ -2319,7 +2287,6 @@ impl Isolate {
2319
2287
  iso_ptr,
2320
2288
  scan_start_field: std::sync::atomic::AtomicUsize::new(0),
2321
2289
  installed_stack_limit: std::sync::atomic::AtomicUsize::new(0),
2322
- pending_fatal: std::sync::atomic::AtomicUsize::new(0),
2323
2290
  depth: std::sync::atomic::AtomicU32::new(0),
2324
2291
  procs: Mutex::new(ProcTable::default()),
2325
2292
  default_timeout_ms: timeout_ms,
@@ -2580,58 +2547,10 @@ impl Core {
2580
2547
  }
2581
2548
  }
2582
2549
 
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
-
2622
2550
  // Map a terminal reply to a Ruby value (the common eval/call/run shape).
2623
2551
  // &self so a Terminated outcome can pick up the JS stack the watchdog snapshot
2624
2552
  // captured (see timeout_interrupt / take_timeout_backtrace).
2625
2553
  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> {
2635
2554
  match reply {
2636
2555
  VmReply::Done(Ok(val)) => jsval_to_ruby(ruby, &val),
2637
2556
  // Clone (don't take): a nested timeout's terminate unwinds the whole op
@@ -2659,64 +2578,6 @@ impl Core {
2659
2578
  .clone()
2660
2579
  }
2661
2580
 
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
-
2720
2581
  fn call_proc(&self, ruby: &Ruby, host_fn_id: usize, args: &[JsVal]) -> Result<JsVal, String> {
2721
2582
  let proc = {
2722
2583
  let procs = self.procs.lock().unwrap();
@@ -2738,16 +2599,16 @@ impl Core {
2738
2599
  let ruby_args = ruby.ary_new_capa(args.len());
2739
2600
  for v in args {
2740
2601
  ruby_args
2741
- .push(jsval_to_ruby(ruby, v).map_err(|e| self.swallow(e))?)
2742
- .map_err(|e| self.swallow(e))?;
2602
+ .push(jsval_to_ruby(ruby, v).map_err(|e| e.to_string())?)
2603
+ .map_err(|e| e.to_string())?;
2743
2604
  }
2744
2605
  // SAFETY: ruby_args is a live local (so GC keeps it and its elements) and
2745
2606
  // is not mutated while the slice is borrowed — as_slice's contract. A VM
2746
2607
  // op the proc issues re-enters Core::run (depth > 0) directly — no nested
2747
2608
  // frame bookkeeping is needed any more (the call stack IS the nesting).
2748
2609
  let result: Result<Value, Error> = proc.call(unsafe { ruby_args.as_slice() });
2749
- let value = result.map_err(|e| self.swallow(e))?;
2750
- ruby_to_jsval(value).map_err(|e| self.swallow(e))
2610
+ let value = result.map_err(|e| e.to_string())?;
2611
+ ruby_to_jsval(value).map_err(|e| e.to_string())
2751
2612
  }
2752
2613
 
2753
2614
  // Context#call (and call_void). Resolves a dotted function path
@@ -2931,20 +2792,11 @@ impl Core {
2931
2792
 
2932
2793
  fn reset(&self, ruby: &Ruby, context_id: i32) -> Result<Value, Error> {
2933
2794
  let reply = self.run(ruby, Request::Reset { context_id })?;
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);
2795
+ let out = self.reply_value(ruby, reply)?;
2940
2796
  // Only on success — a refused reset (unknown/suspended realm) keeps
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)
2797
+ // its attached fns callable.
2798
+ self.release_context_procs(context_id);
2799
+ Ok(out)
2948
2800
  }
2949
2801
 
2950
2802
  // Build a new context; returns its id (replied as an Int).
@@ -2956,12 +2808,9 @@ impl Core {
2956
2808
 
2957
2809
  fn dispose_context(&self, ruby: &Ruby, context_id: i32) -> Result<(), Error> {
2958
2810
  let reply = self.run(ruby, Request::DisposeContext { context_id })?;
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(|_| ()))
2811
+ self.reply_value(ruby, reply)?;
2812
+ self.release_context_procs(context_id);
2813
+ Ok(())
2965
2814
  }
2966
2815
 
2967
2816
  // Thin ESM primitives. compile_module returns the new module's id.
@@ -3038,10 +2887,7 @@ impl Core {
3038
2887
  // Reclaim THIS op's resolver error and restore the outer op's pair.
3039
2888
  let (_, resolver_err) = self.swap_instantiate(saved_resolve, saved_err);
3040
2889
  if let Some(exc) = resolver_err {
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)));
2890
+ return Err(Error::from(*exc));
3045
2891
  }
3046
2892
  self.reply_value(ruby, reply?)
3047
2893
  }
@@ -3266,7 +3112,7 @@ impl Drop for Core {
3266
3112
  so it cannot be disposed and is leaked.\n\
3267
3113
  The usual cause is a host function that yielded out of a Fiber \
3268
3114
  which was never resumed;\n\
3269
- `Fiber#kill` unwinds one cleanly if you still hold it. See\n\
3115
+ `Fiber#kill` recovers one if you still hold it. See\n\
3270
3116
  https://github.com/ursm/rusty_racer#fibers\n"
3271
3117
  );
3272
3118
  LEAKED_ISOLATES.fetch_add(1, Ordering::Relaxed);
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RustyRacer
4
- VERSION = "0.2.2"
4
+ VERSION = "0.2.3"
5
5
  end
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rusty_racer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keita Urashima
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
11
  date: 2026-08-26 00:00:00.000000000 Z
@@ -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: []