rusty_racer 0.2.4 → 0.2.5

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: bc72e8a18f16fc78197991e4e29ff01ba94afb83ae26d3ea162903654f485dae
4
- data.tar.gz: 06a00da38cb052af3a29fa772f93fa14f3e8f267f8a7c88723aad827f908a3d7
3
+ metadata.gz: 488967c6324cc118d0cf37974c23566725c7c6ce14ba48572330075d1b2dff66
4
+ data.tar.gz: 48f562d20d1217e91a9727d1fa348312c51c49b9f82607f3bd2803247e76976c
5
5
  SHA512:
6
- metadata.gz: 3e86f16c51dbe2b1096a7710ea105da2f95f68b640f9a3265f63b85ffcd6f630fb9642305a3afd88da1a559ce50f1238b4b1ff8c8e573d1c8ab20bfd684e03f0
7
- data.tar.gz: aa1020630d83aa95c83837c8d1aa14bd3e56a207d955de314892f1c8e247a3519ad7101f9b6d6f1a9dab8f3f535c92bb59ab11cd342245be8f777bbdb21a60d3
6
+ metadata.gz: faad4323b59c0eca2d69f66bb9698a41ab5837c0f417710b0f125ca39c0fad22e3d399f8fe730bd2c526a1e9cfe34ad9b8c8be5fdbc47b5c8a6e860eb23308f7
7
+ data.tar.gz: 304a7388700465fcfc86a8e7ffeba87c9bd95455075bce889277abe44193f8f407326a136cfe0be39738d53061603d249f153f031866c9570bddec424ac5b807
data/README.md CHANGED
@@ -93,6 +93,8 @@ ran to completion. When the value is incidental, say so: `#eval_void`,
93
93
  result. A statement list has a completion value whether you want one or not, so
94
94
  plumbing an object into a global (`globalThis.win = someProxy`) is enough to
95
95
  make a plain `#eval` fail after every one of its writes has landed.
96
+ `#eval_primitive` sits between the two: it returns the completion value only
97
+ where it is a primitive, and nil for an object or a symbol, which it never reads.
96
98
 
97
99
  ES modules (the embedder owns the URL→module registry):
98
100
 
@@ -114,6 +116,12 @@ loop here (see below), a `true` graph whose `await` never settles returns from
114
116
  `evaluate` with the module body still suspended, and `status` reports
115
117
  `:evaluated` either way — so `graph_async?` is what distinguishes the two.
116
118
 
119
+ A rejected evaluation — a top-level throw, or a top-level `await` that rejects —
120
+ raises from `evaluate`. Pass `report:` the name of a function on the context's
121
+ global to have it called with the reason instead (HTML's "report the
122
+ exception"), at once for a throw and as it settles for an `await`:
123
+ `app.evaluate(report: "reportError")`.
124
+
117
125
  Classic `<script>`s work the same way: `ctx.compile("1 + 1").run` # => 2.
118
126
 
119
127
  ### Resource limits
@@ -203,6 +211,12 @@ Also available:
203
211
  `microtasks: :auto` also drains at the end of each outermost eval/call/evaluate;
204
212
  `microtasks: :explicit` leaves it fully manual. There is no event loop or timers
205
213
  either way.
214
+ - **`Isolate#pump_message_loop`** — runs the tasks V8 posts instead of running
215
+ inline, most visibly `FinalizationRegistry` cleanup callbacks: a microtask
216
+ checkpoint never runs them, so without a pump a collected target's callback
217
+ never fires. It drains every pending task without blocking, then a microtask
218
+ checkpoint; with nothing pending it is a no-op. The callbacks are ordinary JS,
219
+ so the pump is capped by `timeout_ms` like an `eval`.
206
220
  - **`Isolate#terminate`**, **`Isolate#dynamic_import_resolver=`**,
207
221
  **`Context#reset`** (below), and **`Platform.set_flags!`**.
208
222
 
@@ -218,7 +232,9 @@ warm isolate — a per-visit reset that avoids rebuilding the VM. Its contract:
218
232
  - **Runtime mutations are dropped.** Anything set on the realm at runtime is gone.
219
233
  - **Host fns are dropped.** Functions `attach`/`attach_many`'d into the realm are
220
234
  released — the isolate stops holding them, so whatever they captured becomes
221
- collectable; re-attach them after a reset.
235
+ collectable; re-attach them after a reset. One the embedder kept hold of (through
236
+ another realm, say) throws `unknown host function` when called; it never runs
237
+ what was attached after it.
222
238
  - **Modules and classic scripts are dropped.** Handles compiled in the realm die
223
239
  with the old context.
224
240
  - **The realm id and the shared same-origin token are preserved** — the id keeps
@@ -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.4"
8
+ version = "0.2.5"
9
9
  edition = "2024"
10
10
  publish = false
11
11
 
@@ -59,7 +59,7 @@ use std::cell::RefCell;
59
59
  use std::collections::HashMap;
60
60
  use std::ffi::c_void;
61
61
  use std::ptr::null_mut;
62
- use std::sync::atomic::{AtomicBool, Ordering};
62
+ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
63
63
  use std::sync::{Arc, Mutex, Once, Weak};
64
64
 
65
65
  use magnus::block::Proc;
@@ -72,7 +72,7 @@ use magnus::{
72
72
  mod marshal;
73
73
  use marshal::{JsVal, js_to_jsval, jsval_to_js, jsval_to_ruby, ruby_to_jsval};
74
74
  mod ops;
75
- use ops::{Compiled, Request, VmReply, run_source, service_request};
75
+ use ops::{Compiled, Completion, Request, VmReply, run_source, service_request};
76
76
  mod stack;
77
77
  use stack::{STACK_DEBUG, StackScope, current_real_isolate, discover_scan_start_field};
78
78
  mod watchdog;
@@ -146,23 +146,58 @@ macro_rules! istate {
146
146
  pub(crate) use istate;
147
147
 
148
148
  // One attach()'d host fn: the realm it was attached into — so resetting or
149
- // disposing that realm can release it — whether it is still attached (the slot
150
- // index stays valid as a host_fn_id either way), and the name it was attached
151
- // under, kept only so a diagnostic can say which host function is involved (one
152
- // String per attach, never per call). The PROC itself lives in the roots array
153
- // at `proc_root_index(id)`, where the GC can see it without taking this lock.
149
+ // disposing that realm can release it — whether it is still attached, which
150
+ // occupant of the slot it is (`HostFnKey`), and the name it was attached under,
151
+ // kept only so a diagnostic can say which host function is involved (one String
152
+ // per attach, never per call). The PROC itself lives in the roots array at
153
+ // `proc_root_index(index)`, where the GC can see it without taking this lock.
154
154
  struct ProcSlot {
155
155
  context_id: i32,
156
156
  live: bool,
157
+ generation: u32,
157
158
  name: String,
158
159
  }
159
160
 
160
- // The isolate's host-fn registry, indexed by host_fn_id. `free` lists slots
161
+ // What a host function's V8 External carries: its slot, and the slot's generation
162
+ // when it was attached. A slot is reused once its realm is released, but a function
163
+ // of that realm can outlive the release — the embedder may still hold its document
164
+ // — so a call has to prove it is the slot's CURRENT occupant, or it would run
165
+ // whichever proc the slot was handed to next.
166
+ #[derive(Clone, Copy, Debug, PartialEq)]
167
+ pub(crate) struct HostFnKey {
168
+ index: u32,
169
+ generation: u32,
170
+ }
171
+
172
+ impl HostFnKey {
173
+ // (…both halves in one pointer-sized word: V8 builds for 64-bit targets alone.
174
+ // The word is stored as is only while V8's sandbox is off — the v8 crate's
175
+ // opt-in `v8_enable_sandbox`, which this crate leaves off; a sandboxed External
176
+ // goes through a pointer table that keeps tags in the high bits.)
177
+ pub(crate) fn to_external(self) -> *mut c_void {
178
+ const { assert!(usize::BITS >= 64) };
179
+ (((self.generation as u64) << 32) | self.index as u64) as usize as *mut c_void
180
+ }
181
+
182
+ fn from_external(value: *mut c_void) -> Self {
183
+ let value = value as usize as u64;
184
+ Self {
185
+ index: value as u32,
186
+ generation: (value >> 32) as u32,
187
+ }
188
+ }
189
+
190
+ fn index(self) -> usize {
191
+ self.index as usize
192
+ }
193
+ }
194
+
195
+ // The isolate's host-fn registry, indexed by HostFnKey::index. `free` lists slots
161
196
  // emptied by release_context_procs so a later attach reuses them instead of
162
197
  // growing `slots` forever — a long-lived process that re-navigates iframe
163
- // realms would otherwise leak one slot per attach. Reuse is safe because a slot
164
- // is only freed when its realm (and the V8 functions that carried its id) is
165
- // gone, so no stale V8 external can still call a recycled id.
198
+ // realms would otherwise leak one slot per attach. A reused slot's generation
199
+ // moves on, so a function of the released realm that is still reachable calls
200
+ // a key the table no longer answers (`current`) instead of the new occupant.
166
201
  #[derive(Default)]
167
202
  struct ProcTable {
168
203
  slots: Vec<ProcSlot>,
@@ -170,17 +205,39 @@ struct ProcTable {
170
205
  }
171
206
 
172
207
  impl ProcTable {
173
- // Take a free slot if one exists, else grow. Returns the host_fn_id.
174
- fn alloc(&mut self, slot: ProcSlot) -> usize {
175
- if let Some(id) = self.free.pop() {
176
- self.slots[id] = slot;
177
- id
178
- } else {
179
- self.slots.push(slot);
180
- self.slots.len() - 1
208
+ // Take a free slot if one exists, its generation the next, else grow.
209
+ fn alloc(&mut self, context_id: i32, name: String) -> HostFnKey {
210
+ let (index, generation) = match self.free.pop() {
211
+ Some(index) => (index, self.slots[index].generation.wrapping_add(1)),
212
+ None => {
213
+ self.slots.push(ProcSlot {
214
+ context_id,
215
+ live: false,
216
+ generation: 0,
217
+ name: String::new(),
218
+ });
219
+ (self.slots.len() - 1, 0)
220
+ }
221
+ };
222
+ self.slots[index] = ProcSlot {
223
+ context_id,
224
+ live: true,
225
+ generation,
226
+ name,
227
+ };
228
+ HostFnKey {
229
+ index: index as u32,
230
+ generation,
181
231
  }
182
232
  }
183
233
 
234
+ // Whether `key` is its slot's live occupant.
235
+ fn current(&self, key: HostFnKey) -> bool {
236
+ self.slots
237
+ .get(key.index())
238
+ .is_some_and(|slot| slot.live && slot.generation == key.generation)
239
+ }
240
+
184
241
  // Release every live proc attached into |context_id| (its realm is gone),
185
242
  // returning their ids so the caller can clear their roots-array entries (a Ruby
186
243
  // write, which this table never does: it is locked on paths a GC must not have to
@@ -202,11 +259,11 @@ impl ProcTable {
202
259
  // long as a wrapper of it is reachable, in one place the GC can be shown without
203
260
  // taking any Rust lock (a mark that had to lock `procs` could deadlock against
204
261
  // the thread whose allocation triggered the GC while holding it). Slot 0 is the
205
- // dynamic-import resolver; the attached host fns follow, one per host_fn_id.
262
+ // dynamic-import resolver; the attached host fns follow, one per proc slot.
206
263
  const ROOT_IMPORT_RESOLVER: isize = 0;
207
264
  const ROOT_FIRST_PROC: isize = 1;
208
- fn proc_root_index(host_fn_id: usize) -> isize {
209
- ROOT_FIRST_PROC + host_fn_id as isize
265
+ fn proc_root_index(index: usize) -> isize {
266
+ ROOT_FIRST_PROC + index as isize
210
267
  }
211
268
 
212
269
  // Look up a RustyRacer::<name> exception class at raise time. The classes are
@@ -221,13 +278,18 @@ fn err_class(ruby: &Ruby, name: &str) -> ExceptionClass {
221
278
 
222
279
  #[derive(Debug)]
223
280
  enum VmError {
224
- Parse(String), // compile-time failure -> RustyRacer::ParseError
281
+ // compile-time failure -> RustyRacer::ParseError; the SyntaxError V8 made of
282
+ // it kept as for a JsError (`keep_thrown`)
283
+ Parse(String, Option<u64>),
225
284
  Runtime(String), // internal failure (no JS stack) -> RustyRacer::RuntimeError
226
285
  // A thrown JS exception: its message plus the JS stack frames, which become
227
- // the Ruby exception's backtrace -> RustyRacer::RuntimeError.
286
+ // the Ruby exception's backtrace -> RustyRacer::RuntimeError — and the value
287
+ // thrown, kept (`keep_thrown`), so a host fn the error propagates out of
288
+ // throws it on.
228
289
  JsError {
229
290
  message: String,
230
291
  backtrace: Vec<String>,
292
+ thrown: Option<u64>,
231
293
  },
232
294
  Terminated, // watchdog/stop -> RustyRacer::ScriptTerminatedError
233
295
  OutOfMemory, // memory_limit hit -> RustyRacer::V8OutOfMemoryError
@@ -330,9 +392,9 @@ where
330
392
  // instead of saying "a host function". Rendered only on that path, so an
331
393
  // ordinary callback pays for a pointer and nothing else.
332
394
  enum RubyCall<'a> {
333
- // An attach()'d host function, by host_fn_id — the name it was attached
395
+ // An attach()'d host function, by its key — the name it was attached
334
396
  // under lives in the isolate's proc table.
335
- HostFn(usize),
397
+ HostFn(HostFnKey),
336
398
  // A module resolver (a static instantiate block, a dynamic auto-link, or
337
399
  // import()), named by the specifier it was handed.
338
400
  Resolver(&'a str),
@@ -341,15 +403,17 @@ enum RubyCall<'a> {
341
403
  impl RubyCall<'_> {
342
404
  fn describe(&self, core: &Core) -> String {
343
405
  match self {
344
- // try_lock, not lock: nothing holds this across a callback (the procs
345
- // themselves are read from the roots array, without this lock), but
346
- // blocking here would turn a diagnostic into a hang.
347
- Self::HostFn(id) => match core
348
- .procs
349
- .try_lock()
350
- .ok()
351
- .and_then(|procs| procs.slots.get(*id).map(|slot| slot.name.clone()))
352
- {
406
+ // try_lock, not lock: nothing holds this across a callback (`call_proc`
407
+ // holds it for one comparison, before the proc runs), but blocking here
408
+ // would turn a diagnostic into a hang. A slot handed on since names
409
+ // nobody: its name is another function's.
410
+ Self::HostFn(id) => match core.procs.try_lock().ok().and_then(|procs| {
411
+ procs
412
+ .slots
413
+ .get(id.index())
414
+ .filter(|slot| slot.generation == id.generation)
415
+ .map(|slot| slot.name.clone())
416
+ }) {
353
417
  Some(name) => format!("The host function `{name}`"),
354
418
  None => "A host function".to_string(),
355
419
  },
@@ -546,8 +610,8 @@ fn host_fn_callback(
546
610
  args: v8::FunctionCallbackArguments<'_>,
547
611
  mut rv: v8::ReturnValue<'_, v8::Value>,
548
612
  ) {
549
- let host_fn_id = match v8::Local::<v8::External>::try_from(args.data()) {
550
- Ok(e) => e.value() as usize,
613
+ let host_fn = match v8::Local::<v8::External>::try_from(args.data()) {
614
+ Ok(e) => HostFnKey::from_external(e.value()),
551
615
  Err(_) => return,
552
616
  };
553
617
  let mut js_args = Vec::with_capacity(args.length() as usize);
@@ -574,20 +638,22 @@ fn host_fn_callback(
574
638
  return;
575
639
  }
576
640
  let core = unsafe { &*core_ptr };
577
- let result: Result<JsVal, String> = with_gvl(core, RubyCall::HostFn(host_fn_id), || {
641
+ let result: Result<JsVal, HostFnFailure> = with_gvl(core, RubyCall::HostFn(host_fn), || {
578
642
  let ruby = Ruby::get().unwrap();
579
643
  // rb_protect so a Ruby exception raised during arg/return marshalling
580
644
  // (ary_new_capa / jsval_to_ruby / ruby_to_jsval can raise on OOM etc.)
581
645
  // is CAUGHT here instead of longjmp-ing through V8's C++ frames. The
582
646
  // proc's own raise is already a magnus Err; this covers the rest.
583
647
  use magnus::rb_sys::AsRawValue;
584
- let mut out: Option<Result<JsVal, String>> = None;
648
+ let mut out: Option<Result<JsVal, HostFnFailure>> = None;
585
649
  match magnus::rb_sys::protect(|| {
586
- out = Some(core.call_proc(&ruby, host_fn_id, &js_args));
650
+ out = Some(core.call_proc(&ruby, host_fn, &js_args));
587
651
  ruby.qnil().as_raw()
588
652
  }) {
589
- Ok(_) => out.unwrap_or_else(|| Err("host function did not complete".into())),
590
- Err(e) => Err(format!("{e}")),
653
+ Ok(_) => {
654
+ out.unwrap_or_else(|| Err("host function did not complete".to_string().into()))
655
+ }
656
+ Err(e) => Err(format!("{e}").into()),
591
657
  }
592
658
  });
593
659
  match result {
@@ -595,8 +661,30 @@ fn host_fn_callback(
595
661
  let v = jsval_to_js(scope, val);
596
662
  rv.set(v);
597
663
  }
598
- // The proc raised (or marshalling failed): surface as a JS exception.
599
- Err(message) => throw_js_error(scope, &message),
664
+ // The proc raised (or marshalling failed): surface as a JS exception —
665
+ // the value JS threw, where the error is one a JS throw became.
666
+ Err(failure) => match failure.thrown.and_then(|serial| take_thrown(scope, serial)) {
667
+ Some(thrown) => {
668
+ scope.throw_exception(thrown);
669
+ }
670
+ None => throw_js_error(scope, &failure.message),
671
+ },
672
+ }
673
+ }
674
+
675
+ // How a host fn failed: the Ruby error's text, and the serial of the JS value
676
+ // it carries when a JS throw is what it became (`keep_thrown`).
677
+ struct HostFnFailure {
678
+ message: String,
679
+ thrown: Option<u64>,
680
+ }
681
+
682
+ impl From<String> for HostFnFailure {
683
+ fn from(message: String) -> Self {
684
+ HostFnFailure {
685
+ message,
686
+ thrown: None,
687
+ }
600
688
  }
601
689
  }
602
690
 
@@ -696,7 +784,87 @@ fn capture_js_error(
696
784
  let backtrace = stack_str
697
785
  .map(|s| parse_js_stack(s.as_str()))
698
786
  .unwrap_or_default();
699
- VmError::JsError { message, backtrace }
787
+ let thrown = keep_thrown(scope, exception);
788
+ VmError::JsError {
789
+ message,
790
+ backtrace,
791
+ thrown,
792
+ }
793
+ }
794
+
795
+ // A value JS failed with that no TryCatch caught — an errored module's
796
+ // exception, a rejected promise's reason — as a JsError: its text, no stack
797
+ // frames, and the value kept as a thrown one is.
798
+ fn rejected_with(scope: &mut v8::PinScope<'_, '_>, value: v8::Local<v8::Value>) -> VmError {
799
+ VmError::JsError {
800
+ message: value.to_rust_string_lossy(scope),
801
+ backtrace: vec![],
802
+ thrown: keep_thrown(scope, Some(value)),
803
+ }
804
+ }
805
+
806
+ // A compile failure as a ParseError: V8's message, the location it recorded
807
+ // (always the file, the line when it has one), and the SyntaxError kept as a
808
+ // thrown value is.
809
+ fn parse_error(
810
+ scope: &mut v8::PinScope<'_, '_>,
811
+ exception: Option<v8::Local<v8::Value>>,
812
+ message: Option<v8::Local<v8::Message>>,
813
+ filename: &str,
814
+ fallback: &str,
815
+ ) -> VmError {
816
+ let msg = exception
817
+ .map(|e| e.to_rust_string_lossy(scope))
818
+ .unwrap_or_else(|| fallback.to_string());
819
+ let res = message
820
+ .and_then(|m| m.get_script_resource_name(scope))
821
+ .filter(|v| v.is_string())
822
+ .map(|v| v.to_rust_string_lossy(scope))
823
+ .unwrap_or_else(|| filename.to_string());
824
+ let loc = match message.and_then(|m| m.get_line_number(scope)) {
825
+ Some(line) => format!(" at {res}:{line}"),
826
+ None => format!(" at {res}"),
827
+ };
828
+ VmError::Parse(format!("{msg}{loc}"), keep_thrown(scope, exception))
829
+ }
830
+
831
+ // Keep the value JS threw, so a host fn that lets the Ruby error it became
832
+ // propagate throws THAT value on — its identity, class and stack — rather than a
833
+ // fresh Error made of its text: a script a host fn ran for JS fails in JS as the
834
+ // script did. One slot per isolate, since only the latest thrown value can still
835
+ // be on its way back out, and only until the outermost op returns (`Core::run`),
836
+ // since only a host fn inside it can still ask — kept longer, it would pin the
837
+ // value, and its realm, for as long as nothing else threw. The serial (carried
838
+ // by the Ruby exception, see `carrying_thrown`) tells it from a stale one, and is
839
+ // drawn process-wide, so an error another isolate's op raised never matches this
840
+ // one's slot: its value cannot cross isolates. None on an isolate with no state
841
+ // (the snapshot creator's), where no host fn can ask.
842
+ fn keep_thrown(
843
+ scope: &mut v8::PinScope<'_, '_>,
844
+ exception: Option<v8::Local<v8::Value>>,
845
+ ) -> Option<u64> {
846
+ static SERIAL: AtomicU64 = AtomicU64::new(0);
847
+ let exception = exception?;
848
+ let kept = v8::Global::new(scope, exception);
849
+ let state = scope.get_slot_mut::<IsolateState>()?;
850
+ let serial = SERIAL.fetch_add(1, Ordering::Relaxed) + 1;
851
+ state.thrown = Some((serial, kept));
852
+ Some(serial)
853
+ }
854
+
855
+ // The Ruby exception's kept thrown value (`keep_thrown`), when it still is the
856
+ // latest: taken, as it is thrown once.
857
+ fn take_thrown<'s>(
858
+ scope: &mut v8::PinScope<'s, '_>,
859
+ serial: u64,
860
+ ) -> Option<v8::Local<'s, v8::Value>> {
861
+ let state = scope.get_slot_mut::<IsolateState>()?;
862
+ let (kept_serial, _) = state.thrown.as_ref()?;
863
+ if *kept_serial != serial {
864
+ return None;
865
+ }
866
+ let (_, kept) = state.thrown.take()?;
867
+ Some(v8::Local::new(scope, kept))
700
868
  }
701
869
 
702
870
  // ---------------------------------------------------------------------------
@@ -864,6 +1032,8 @@ struct IsolateState {
864
1032
  // the ScriptTerminatedError can name what was running. Taken (cleared) when the
865
1033
  // terminated op's error is built; None for any non-timeout outcome.
866
1034
  timeout_backtrace: Option<Vec<String>>,
1035
+ // The latest value JS threw out of an op, with its serial (`keep_thrown`).
1036
+ thrown: Option<(u64, v8::Global<v8::Value>)>,
867
1037
  }
868
1038
 
869
1039
  impl IsolateState {
@@ -888,6 +1058,7 @@ impl IsolateState {
888
1058
  oom_fired: false,
889
1059
  oom_initial_limit: 0,
890
1060
  timeout_backtrace: None,
1061
+ thrown: None,
891
1062
  }
892
1063
  }
893
1064
  }
@@ -973,7 +1144,7 @@ fn transfer_registry() -> &'static Mutex<HashMap<u64, SendBackingStore>> {
973
1144
  // Monotonic token source. Starts at 1 so 0 is free as the JS-side "not
974
1145
  // transferable — fall back to a copy" sentinel. A token is f64-exact for ~2^53
975
1146
  // transfers, which a process will never reach.
976
- static NEXT_TRANSFER_TOKEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
1147
+ static NEXT_TRANSFER_TOKEN: AtomicU64 = AtomicU64::new(1);
977
1148
 
978
1149
  // Drain EVERY realm's microtask queue once. Each realm has its own queue (see
979
1150
  // V8State::queues), so a single scope.perform_microtask_checkpoint() — which only
@@ -1534,7 +1705,7 @@ struct Core {
1534
1705
  // layout. Read on the V8 side without the GVL (the import hooks), which is
1535
1706
  // sound because the entries are pinned and nothing here allocates or raises.
1536
1707
  roots: Opaque<RArray>,
1537
- // host_fn_id indexes ProcTable.slots — bookkeeping only (the proc itself is in
1708
+ // A HostFnKey indexes ProcTable.slots — bookkeeping only (the proc itself is in
1538
1709
  // `roots`). Mutex (uncontended — single owner thread) so host_fn_callback can
1539
1710
  // reach it through Core (via the slot's core_ptr) while a &Core method also
1540
1711
  // holds it. reset/dispose releases the slots and recycles them.
@@ -2253,11 +2424,11 @@ fn build_snapshot(code: &str, base: Option<Vec<u8>>, warmup: bool) -> Result<Vec
2253
2424
  // leaves behind); nothing reads its completion value, so
2254
2425
  // don't marshal it — a bundle ending in an expression must
2255
2426
  // not fail the snapshot over a value no one wants.
2256
- true,
2427
+ Completion::Void,
2257
2428
  )
2258
2429
  {
2259
2430
  err = Some(match e {
2260
- VmError::Parse(m) | VmError::Runtime(m) => m,
2431
+ VmError::Parse(m, _) | VmError::Runtime(m) => m,
2261
2432
  VmError::JsError { message, .. } => message,
2262
2433
  VmError::Terminated => "snapshot code was terminated".to_string(),
2263
2434
  // Unreachable: the snapshot-creator is a separate isolate
@@ -2641,6 +2812,9 @@ impl Core {
2641
2812
  // on an op.
2642
2813
  if reply.is_none() {
2643
2814
  self.shared.lock().unwrap().disposed = true;
2815
+ } else if depth == 0 {
2816
+ // No host fn is left to throw a kept value on (`keep_thrown`).
2817
+ istate!(unsafe { &mut *iso }).thrown = None;
2644
2818
  }
2645
2819
  self.depth.fetch_sub(1, Ordering::SeqCst);
2646
2820
  reply
@@ -2741,15 +2915,24 @@ impl Core {
2741
2915
  }
2742
2916
  }
2743
2917
 
2744
- fn call_proc(&self, ruby: &Ruby, host_fn_id: usize, args: &[JsVal]) -> Result<JsVal, String> {
2745
- // Straight from the roots array — a released (or never-attached) id reads
2746
- // back nil, which is the same "unknown host function" the slot table would
2747
- // have reported, so the hot path takes no lock at all. `root_proc` and not
2748
- // `RArray::entry`, for the reason given there: a nil slot must answer None,
2749
- // never dispatch `to_proc` and raise.
2918
+ fn call_proc(
2919
+ &self,
2920
+ ruby: &Ruby,
2921
+ host_fn: HostFnKey,
2922
+ args: &[JsVal],
2923
+ ) -> Result<JsVal, HostFnFailure> {
2924
+ // A function of a released realm is refused here, before its slot's roots
2925
+ // entry is read: that entry may by now hold another realm's proc. (The lock
2926
+ // is uncontended and held for the comparison alone — nothing here allocates,
2927
+ // so no GC can need it meanwhile.)
2928
+ if !self.procs.lock().unwrap().current(host_fn) {
2929
+ return Err("unknown host function".to_string().into());
2930
+ }
2931
+ // `root_proc` and not `RArray::entry`, for the reason given there: a nil
2932
+ // slot must answer None, never dispatch `to_proc` and raise.
2750
2933
  let proc = self
2751
- .root_proc(proc_root_index(host_fn_id))
2752
- .ok_or("unknown host function")?;
2934
+ .root_proc(proc_root_index(host_fn.index()))
2935
+ .ok_or_else(|| "unknown host function".to_string())?;
2753
2936
  // Marshal into a Ruby Array, NOT a Vec<Value>: bare Values in a heap Vec
2754
2937
  // are hidden from Ruby's GC mark phase (magnus's own RArray::to_vec doc
2755
2938
  // spells this out). With several args, once arg N is parked in the Vec
@@ -2769,8 +2952,11 @@ impl Core {
2769
2952
  // op the proc issues re-enters Core::run (depth > 0) directly — no nested
2770
2953
  // frame bookkeeping is needed any more (the call stack IS the nesting).
2771
2954
  let result: Result<Value, Error> = proc.call(unsafe { ruby_args.as_slice() });
2772
- let value = result.map_err(|e| e.to_string())?;
2773
- ruby_to_jsval(value).map_err(|e| e.to_string())
2955
+ let value = result.map_err(|e| HostFnFailure {
2956
+ message: e.to_string(),
2957
+ thrown: thrown_serial_of(&e),
2958
+ })?;
2959
+ ruby_to_jsval(value).map_err(|e| e.to_string().into())
2774
2960
  }
2775
2961
 
2776
2962
  // Context#call (and call_void). Resolves a dotted function path
@@ -2880,7 +3066,7 @@ impl Core {
2880
3066
  source: String,
2881
3067
  filename: String,
2882
3068
  timeout_ms: u64,
2883
- void: bool,
3069
+ completion: Completion,
2884
3070
  ) -> Result<Value, Error> {
2885
3071
  let reply = self.run(
2886
3072
  ruby,
@@ -2889,7 +3075,7 @@ impl Core {
2889
3075
  source,
2890
3076
  filename,
2891
3077
  timeout_ms,
2892
- void,
3078
+ completion,
2893
3079
  },
2894
3080
  )?;
2895
3081
  self.reply_value(ruby, reply)
@@ -2906,18 +3092,14 @@ impl Core {
2906
3092
  // array's element storage, which the import hooks read with the GVL released —
2907
3093
  // and a refused attach must not consume a slot or leave its proc rooted either.
2908
3094
  self.ensure_owner_and_live(ruby)?;
2909
- let host_fn_id = self.procs.lock().unwrap().alloc(ProcSlot {
2910
- context_id,
2911
- live: true,
2912
- name: name.clone(),
2913
- });
2914
- self.root_store(ruby, proc_root_index(host_fn_id), proc)?;
3095
+ let host_fn = self.procs.lock().unwrap().alloc(context_id, name.clone());
3096
+ self.root_store(ruby, proc_root_index(host_fn.index()), proc)?;
2915
3097
  let reply = self.run(
2916
3098
  ruby,
2917
3099
  Request::Attach {
2918
3100
  context_id,
2919
3101
  name,
2920
- host_fn_id,
3102
+ host_fn,
2921
3103
  timeout_ms: self.default_timeout_ms,
2922
3104
  },
2923
3105
  )?;
@@ -2926,7 +3108,7 @@ impl Core {
2926
3108
 
2927
3109
  // attach_many: install several host fns in ONE round-trip to the V8 thread
2928
3110
  // (a fresh realm needs ~dozens; one rendezvous instead of one per fn). Slots
2929
- // are allocated up front so each carries a stable host_fn_id; entries are
3111
+ // are allocated up front so each carries a stable key; entries are
2930
3112
  // applied IN ORDER and a build/attach failure aborts the batch with that
2931
3113
  // (name-tagged) error WITHOUT rolling back earlier entries (see the
2932
3114
  // AttachMany arm). On that error path the unused slots are reclaimed at the
@@ -2941,18 +3123,11 @@ impl Core {
2941
3123
  return Ok(ruby.qnil().as_value()); // nothing to install, skip the round-trip
2942
3124
  }
2943
3125
  self.ensure_owner_and_live(ruby)?; // before the roots writes — see `attach`
2944
- let allocated: Vec<(String, usize, Proc)> = {
3126
+ let allocated: Vec<(String, HostFnKey, Proc)> = {
2945
3127
  let mut procs = self.procs.lock().unwrap();
2946
3128
  entries
2947
3129
  .into_iter()
2948
- .map(|(name, proc)| {
2949
- let id = procs.alloc(ProcSlot {
2950
- context_id,
2951
- live: true,
2952
- name: name.clone(),
2953
- });
2954
- (name, id, proc)
2955
- })
3130
+ .map(|(name, proc)| (name.clone(), procs.alloc(context_id, name), proc))
2956
3131
  .collect()
2957
3132
  };
2958
3133
  // Stored OUTSIDE the table's lock: storing into the array can allocate (the
@@ -2960,16 +3135,16 @@ impl Core {
2960
3135
  // wrappers, a path that must never wait on `procs`. The Procs parked in the
2961
3136
  // Vec meanwhile are invisible to the GC (see call_proc's note), but the caller's
2962
3137
  // `table: RHash` argument is live in its own frame and marks every one of them.
2963
- let mut named_ids: Vec<(String, usize)> = Vec::with_capacity(allocated.len());
2964
- for (name, id, proc) in allocated {
2965
- self.root_store(ruby, proc_root_index(id), proc)?;
2966
- named_ids.push((name, id));
3138
+ let mut named_keys: Vec<(String, HostFnKey)> = Vec::with_capacity(allocated.len());
3139
+ for (name, key, proc) in allocated {
3140
+ self.root_store(ruby, proc_root_index(key.index()), proc)?;
3141
+ named_keys.push((name, key));
2967
3142
  }
2968
3143
  let reply = self.run(
2969
3144
  ruby,
2970
3145
  Request::AttachMany {
2971
3146
  context_id,
2972
- entries: named_ids,
3147
+ entries: named_keys,
2973
3148
  timeout_ms: self.default_timeout_ms,
2974
3149
  },
2975
3150
  )?;
@@ -2977,11 +3152,12 @@ impl Core {
2977
3152
  }
2978
3153
 
2979
3154
  // Drop the procs attached into |context_id| out of the roots array — its
2980
- // realm is gone (reset or disposed), so the V8-side functions that referenced
2981
- // them are unreachable, and holding them would keep the embedder's world alive
2982
- // for the rest of the isolate's life. Runs on a Ruby thread: it writes nil
2983
- // into the Array. The slots stay: host_fn_ids of other realms are indices into
2984
- // the same Vec.
3155
+ // realm is gone (reset or disposed), and holding them would keep the embedder's
3156
+ // world alive for the rest of the isolate's life. A V8 function of that realm
3157
+ // may still be reachable (the embedder can hold the old global), but its key
3158
+ // stops answering (`ProcTable::current`), so it never calls a proc again.
3159
+ // Runs on a Ruby thread: it writes nil into the Array. The slots stay: the
3160
+ // keys of other realms are indices into the same Vec.
2985
3161
  fn release_context_procs(&self, ruby: &Ruby, context_id: i32) {
2986
3162
  // Nil the entries BEFORE the ids go back on the free list, so no attach can
2987
3163
  // ever be handed an id whose old proc is still in the array.
@@ -3094,12 +3270,18 @@ impl Core {
3094
3270
  self.reply_value(ruby, reply?)
3095
3271
  }
3096
3272
 
3097
- fn evaluate_module(&self, ruby: &Ruby, module_id: i32) -> Result<Value, Error> {
3273
+ fn evaluate_module(
3274
+ &self,
3275
+ ruby: &Ruby,
3276
+ module_id: i32,
3277
+ report: Option<String>,
3278
+ ) -> Result<Value, Error> {
3098
3279
  let reply = self.run(
3099
3280
  ruby,
3100
3281
  Request::EvaluateModule {
3101
3282
  module_id,
3102
3283
  timeout_ms: self.default_timeout_ms,
3284
+ report,
3103
3285
  },
3104
3286
  )?;
3105
3287
  self.reply_value(ruby, reply)
@@ -3443,14 +3625,14 @@ impl Context {
3443
3625
  Ok(())
3444
3626
  }
3445
3627
  // timeout_ms 0 = use the isolate's default; an explicit value overrides it.
3446
- // |void| discards the completion value instead of marshalling it.
3628
+ // |completion| 0 marshals the completion value, 1 discards it, 2 keeps a primitive alone (`Completion`).
3447
3629
  fn eval(
3448
3630
  ruby: &Ruby,
3449
3631
  rb_self: &Self,
3450
3632
  source: String,
3451
3633
  timeout_ms: u64,
3452
3634
  filename: String,
3453
- void: bool,
3635
+ completion: u8,
3454
3636
  ) -> Result<Value, Error> {
3455
3637
  rb_self.check_live(ruby)?;
3456
3638
  let timeout = if timeout_ms == 0 {
@@ -3458,9 +3640,14 @@ impl Context {
3458
3640
  } else {
3459
3641
  timeout_ms
3460
3642
  };
3643
+ let completion = match completion {
3644
+ 1 => Completion::Void,
3645
+ 2 => Completion::Primitive,
3646
+ _ => Completion::Value,
3647
+ };
3461
3648
  rb_self
3462
3649
  .core
3463
- .eval_t(ruby, rb_self.id, source, filename, timeout, void)
3650
+ .eval_t(ruby, rb_self.id, source, filename, timeout, completion)
3464
3651
  }
3465
3652
  fn call(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result<Value, Error> {
3466
3653
  rb_self.check_live(ruby)?;
@@ -3649,9 +3836,12 @@ impl JsModule {
3649
3836
  .core
3650
3837
  .instantiate_module(ruby, rb_self.module_id, resolver)
3651
3838
  }
3652
- fn evaluate(ruby: &Ruby, rb_self: &Self) -> Result<Value, Error> {
3839
+ // |report|: nil, or the name of the context global that reports the evaluation's rejection (`Request::EvaluateModule`).
3840
+ fn evaluate(ruby: &Ruby, rb_self: &Self, report: Option<String>) -> Result<Value, Error> {
3653
3841
  rb_self.check_live(ruby)?;
3654
- rb_self.core.evaluate_module(ruby, rb_self.module_id)
3842
+ rb_self
3843
+ .core
3844
+ .evaluate_module(ruby, rb_self.module_id, report)
3655
3845
  }
3656
3846
  fn namespace(ruby: &Ruby, rb_self: &Self) -> Result<Value, Error> {
3657
3847
  rb_self.check_live(ruby)?;
@@ -3808,9 +3998,15 @@ fn binary_bytes(
3808
3998
 
3809
3999
  fn vm_err(ruby: &Ruby, e: VmError) -> Error {
3810
4000
  match e {
3811
- VmError::Parse(m) => Error::new(err_class(ruby, "ParseError"), m),
4001
+ VmError::Parse(m, thrown) => {
4002
+ carrying_thrown(Error::new(err_class(ruby, "ParseError"), m), thrown)
4003
+ }
3812
4004
  VmError::Runtime(m) => Error::new(err_class(ruby, "RuntimeError"), m),
3813
- VmError::JsError { message, backtrace } => js_runtime_error(ruby, message, backtrace),
4005
+ VmError::JsError {
4006
+ message,
4007
+ backtrace,
4008
+ thrown,
4009
+ } => carrying_thrown(js_runtime_error(ruby, message, backtrace), thrown),
3814
4010
  VmError::Terminated => Error::new(
3815
4011
  err_class(ruby, "ScriptTerminatedError"),
3816
4012
  "JavaScript was terminated (timeout or stop)",
@@ -3852,6 +4048,42 @@ fn terminated_error(ruby: &Ruby, js_backtrace: Option<Vec<String>>) -> Error {
3852
4048
  }
3853
4049
  }
3854
4050
 
4051
+ // The ivar a Ruby exception carries its thrown value's serial in (`keep_thrown`),
4052
+ // beside the message it was raised with.
4053
+ const THROWN_IVAR: &str = "@__rusty_racer_thrown";
4054
+
4055
+ // |error| as an instance carrying |thrown|'s serial, for a host fn it propagates
4056
+ // out of to throw the kept value on (`take_thrown`).
4057
+ fn carrying_thrown(error: Error, thrown: Option<u64>) -> Error {
4058
+ let Some(serial) = thrown else {
4059
+ return error;
4060
+ };
4061
+ match error_to_exception(&error) {
4062
+ Some(exc) => {
4063
+ let Ok(message) = exc.funcall::<_, _, Value>("message", ()) else {
4064
+ return Error::from(exc);
4065
+ };
4066
+ let _ = exc
4067
+ .funcall::<_, _, Value>("instance_variable_set", (THROWN_IVAR, (serial, message)));
4068
+ Error::from(exc)
4069
+ }
4070
+ None => error,
4071
+ }
4072
+ }
4073
+
4074
+ // The serial of the thrown value a host fn's raised |error| carries, if any — not
4075
+ // when Ruby re-raised it with a message of its own (`raise e, 'other'` copies
4076
+ // the ivar): that error says something the value does not.
4077
+ fn thrown_serial_of(error: &Error) -> Option<u64> {
4078
+ let exc = error_to_exception(error)?;
4079
+ let (serial, message): (u64, Value) = exc
4080
+ .funcall::<_, _, Option<(u64, Value)>>("instance_variable_get", (THROWN_IVAR,))
4081
+ .ok()
4082
+ .flatten()?;
4083
+ let now: Value = exc.funcall("message", ()).ok()?;
4084
+ now.eql(message).ok()?.then_some(serial)
4085
+ }
4086
+
3855
4087
  // Build a RustyRacer::RuntimeError carrying the JS stack as its Ruby backtrace.
3856
4088
  // Constructs the exception instance so we can set_backtrace before raising;
3857
4089
  // falls back to a plain Error if any of that fails.
@@ -3961,7 +4193,7 @@ pub fn install_classes(ruby: &Ruby) -> Result<(), Error> {
3961
4193
  // A v8::Context (realm): eval/call/attach/compile_module.
3962
4194
  let context = module.define_class("Context", ruby.class_object())?;
3963
4195
  // Backs both keyword-arg wrappers in lib: Context#eval(source, timeout_ms:,
3964
- // filename:) and #eval_void, which differ only in the 4th arg (the void flag).
4196
+ // filename:), #eval_void and #eval_primitive, which differ only in the 4th arg (what they keep of the completion value).
3965
4197
  context.define_method("_eval", method!(Context::eval, 4))?;
3966
4198
  context.define_method("call", method!(Context::call, -1))?;
3967
4199
  context.define_method("call_void", method!(Context::call_void, -1))?;
@@ -3996,7 +4228,7 @@ pub fn install_classes(ruby: &Ruby) -> Result<(), Error> {
3996
4228
  // Thin ES-module handle: Context#compile_module -> instantiate/evaluate.
3997
4229
  let jsmodule = module.define_class("Module", ruby.class_object())?;
3998
4230
  jsmodule.define_method("_instantiate", method!(JsModule::instantiate, 1))?;
3999
- jsmodule.define_method("evaluate", method!(JsModule::evaluate, 0))?;
4231
+ jsmodule.define_method("_evaluate", method!(JsModule::evaluate, 1))?;
4000
4232
  jsmodule.define_method("namespace", method!(JsModule::namespace, 0))?;
4001
4233
  jsmodule.define_method("_status", method!(JsModule::status, 0))?;
4002
4234
  jsmodule.define_method("graph_async?", method!(JsModule::graph_async, 0))?;
@@ -41,6 +41,17 @@ macro_rules! marshalled {
41
41
  }};
42
42
  }
43
43
 
44
+ // What an eval hands back of its completion value: all of it, marshalled; nothing (`Void`: a statement list run for its
45
+ // EFFECT still has one — the last statement's value — and marshalling it can throw, an accessor or a Proxy trap, or walk
46
+ // a huge graph, for a value the caller discards); or a primitive alone (`Primitive`: an object's never walked, undefined
47
+ // in its place — for a caller that wants a string or a number of it, as a javascript: URL's evaluation does).
48
+ #[derive(Clone, Copy)]
49
+ pub(crate) enum Completion {
50
+ Value,
51
+ Void,
52
+ Primitive,
53
+ }
54
+
44
55
  // One VM operation, built by a magnus method and run inline by Core::run ->
45
56
  // service_request -> dispatch_one. |context_id| selects which realm the op runs
46
57
  // in: 0 = the main realm (Context's own globalThis, swappable by reset_realm),
@@ -51,11 +62,7 @@ pub(crate) enum Request {
51
62
  source: String,
52
63
  filename: String,
53
64
  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,
65
+ completion: Completion,
59
66
  },
60
67
  // Resolve a dotted function path on globalThis and invoke it with marshalled
61
68
  // args (v8::Function::call), preserving the holder as `this`. Distinct from
@@ -76,14 +83,14 @@ pub(crate) enum Request {
76
83
  Attach {
77
84
  context_id: i32,
78
85
  name: String,
79
- host_fn_id: usize,
86
+ host_fn: HostFnKey,
80
87
  timeout_ms: u64,
81
88
  },
82
- // Batch attach: install many (name, host_fn_id) host fns in one round-trip
89
+ // Batch attach: install many (name, key) host fns in one round-trip
83
90
  // (a fresh realm needs ~dozens). Same semantics as Attach, applied in order.
84
91
  AttachMany {
85
92
  context_id: i32,
86
- entries: Vec<(String, usize)>,
93
+ entries: Vec<(String, HostFnKey)>,
87
94
  timeout_ms: u64,
88
95
  },
89
96
  // reset: swap globalThis for a fresh v8::Context, reusing the same warm
@@ -123,6 +130,10 @@ pub(crate) enum Request {
123
130
  EvaluateModule {
124
131
  module_id: i32,
125
132
  timeout_ms: u64,
133
+ // The name of a function on the module's context's global that reports its evaluation's rejection — HTML "run
134
+ // a module script": upon rejection, report the exception — now or as a top-level-await module settles; none:
135
+ // the rejection is the op's error, as far as the drain gets.
136
+ report: Option<String>,
126
137
  },
127
138
  ModuleNamespace {
128
139
  module_id: i32,
@@ -234,41 +245,30 @@ pub(crate) fn run_source(
234
245
  scope: &mut v8::PinScope<'_, '_>,
235
246
  source: &str,
236
247
  filename: &str,
237
- void: bool,
248
+ completion: Completion,
238
249
  ) -> Result<JsVal, VmError> {
239
250
  v8::tc_scope!(let tc, scope);
240
251
  // Compile and run as distinct phases so a compile failure maps to
241
252
  // ParseError and a thrown exception to RuntimeError (csim rescues both).
242
253
  let Some(code) = v8::String::new(tc, source) else {
243
- return Err(VmError::Parse("source too large".into()));
254
+ return Err(VmError::Parse("source too large".into(), None));
244
255
  };
245
256
  let origin = script_origin(tc, filename);
246
257
  let script = match v8::Script::compile(tc, code, Some(&origin)) {
247
258
  Some(script) => script,
248
259
  None if tc.has_terminated() => return Err(VmError::Terminated),
249
260
  None => {
250
- let msg = tc
251
- .exception()
252
- .map(|e| e.to_rust_string_lossy(tc))
253
- .unwrap_or_else(|| "parse error".to_string());
254
- // Append the location V8 recorded; always name the file, add the
255
- // line when V8 reports one.
256
- let message = tc.message();
257
- let res = message
258
- .and_then(|m| m.get_script_resource_name(tc))
259
- .filter(|v| v.is_string())
260
- .map(|v| v.to_rust_string_lossy(tc))
261
- .unwrap_or_else(|| filename.to_string());
262
- let loc = match message.and_then(|m| m.get_line_number(tc)) {
263
- Some(line) => format!(" at {res}:{line}"),
264
- None => format!(" at {res}"),
265
- };
266
- return Err(VmError::Parse(format!("{msg}{loc}")));
261
+ let (exc, message) = (tc.exception(), tc.message());
262
+ return Err(parse_error(tc, exc, message, filename, "parse error"));
267
263
  }
268
264
  };
269
265
  match script.run(tc) {
270
- Some(_) if void => Ok(JsVal::Undefined),
271
- Some(value) => marshalled!(tc, value),
266
+ Some(value) => match completion {
267
+ Completion::Value => marshalled!(tc, value),
268
+ Completion::Primitive if value.is_object() || value.is_symbol() => Ok(JsVal::Undefined),
269
+ Completion::Primitive => marshalled!(tc, value),
270
+ Completion::Void => Ok(JsVal::Undefined),
271
+ },
272
272
  None if tc.has_terminated() => Err(VmError::Terminated),
273
273
  None => {
274
274
  let exc = tc.exception();
@@ -465,9 +465,9 @@ fn dispatch_one(
465
465
  source,
466
466
  filename,
467
467
  timeout_ms,
468
- void,
468
+ completion,
469
469
  } => op_eval(
470
- scope, context_id, source, filename, timeout_ms, void, outermost,
470
+ scope, context_id, source, filename, timeout_ms, completion, outermost,
471
471
  ),
472
472
  Request::Call {
473
473
  context_id,
@@ -480,9 +480,9 @@ fn dispatch_one(
480
480
  Request::Attach {
481
481
  context_id,
482
482
  name,
483
- host_fn_id,
483
+ host_fn,
484
484
  timeout_ms,
485
- } => op_attach(scope, context_id, name, host_fn_id, timeout_ms, outermost),
485
+ } => op_attach(scope, context_id, name, host_fn, timeout_ms, outermost),
486
486
  Request::AttachMany {
487
487
  context_id,
488
488
  entries,
@@ -511,7 +511,8 @@ fn dispatch_one(
511
511
  Request::EvaluateModule {
512
512
  module_id,
513
513
  timeout_ms,
514
- } => op_evaluate_module(scope, module_id, timeout_ms, outermost),
514
+ report,
515
+ } => op_evaluate_module(scope, module_id, timeout_ms, report, outermost),
515
516
  Request::ModuleNamespace { module_id } => op_module_namespace(scope, module_id),
516
517
  Request::ModuleStatus { module_id } => op_module_status(scope, module_id),
517
518
  Request::ModuleGraphAsync { module_id } => op_module_graph_async(scope, module_id),
@@ -619,7 +620,7 @@ fn op_eval(
619
620
  source: String,
620
621
  filename: String,
621
622
  timeout_ms: u64,
622
- void: bool,
623
+ completion: Completion,
623
624
  outermost: bool,
624
625
  ) -> VmReply {
625
626
  let outcome = run_js_bracketed(scope, outermost, timeout_ms, "eval", |scope, outermost| {
@@ -628,7 +629,7 @@ fn op_eval(
628
629
  Some(ctx) => {
629
630
  let context = v8::Local::new(scope, &ctx);
630
631
  let scope = &mut v8::ContextScope::new(scope, context);
631
- let out = run_source(scope, &source, &filename, void);
632
+ let out = run_source(scope, &source, &filename, completion);
632
633
  auto_drain(scope, outermost);
633
634
  (true, out)
634
635
  }
@@ -699,7 +700,7 @@ fn op_attach(
699
700
  scope: &mut v8::PinScope<'_, '_, ()>,
700
701
  context_id: i32,
701
702
  name: String,
702
- host_fn_id: usize,
703
+ host_fn: HostFnKey,
703
704
  timeout_ms: u64,
704
705
  outermost: bool,
705
706
  ) -> VmReply {
@@ -719,7 +720,7 @@ fn op_attach(
719
720
  Some(ctx) => {
720
721
  let context = v8::Local::new(scope, &ctx);
721
722
  let scope = &mut v8::ContextScope::new(scope, context);
722
- let external = v8::External::new(scope, host_fn_id as *mut c_void);
723
+ let external = v8::External::new(scope, host_fn.to_external());
723
724
  let out = match v8::Function::builder(host_fn_callback)
724
725
  .data(external.into())
725
726
  .build(scope)
@@ -747,7 +748,7 @@ fn op_attach(
747
748
  fn op_attach_many(
748
749
  scope: &mut v8::PinScope<'_, '_, ()>,
749
750
  context_id: i32,
750
- entries: Vec<(String, usize)>,
751
+ entries: Vec<(String, HostFnKey)>,
751
752
  timeout_ms: u64,
752
753
  outermost: bool,
753
754
  ) -> VmReply {
@@ -769,8 +770,8 @@ fn op_attach_many(
769
770
  let context = v8::Local::new(scope, &ctx);
770
771
  let scope = &mut v8::ContextScope::new(scope, context);
771
772
  let mut out = Ok(JsVal::Undefined);
772
- for (name, host_fn_id) in &entries {
773
- let external = v8::External::new(scope, *host_fn_id as *mut c_void);
773
+ for (name, host_fn) in &entries {
774
+ let external = v8::External::new(scope, host_fn.to_external());
774
775
  out = match v8::Function::builder(host_fn_callback)
775
776
  .data(external.into())
776
777
  .build(scope)
@@ -957,21 +958,14 @@ fn op_compile_module(
957
958
  // A module compile failure is a parse error
958
959
  // (compile-time), not a thrown exception.
959
960
  None => {
960
- let msg = tc
961
- .exception()
962
- .map(|e| e.to_rust_string_lossy(tc))
963
- .unwrap_or_else(|| "module parse error".to_string());
964
- let message = tc.message();
965
- let res = message
966
- .and_then(|m| m.get_script_resource_name(tc))
967
- .filter(|v| v.is_string())
968
- .map(|v| v.to_rust_string_lossy(tc))
969
- .unwrap_or_else(|| filename.clone());
970
- let loc = match message.and_then(|m| m.get_line_number(tc)) {
971
- Some(line) => format!(" at {res}:{line}"),
972
- None => format!(" at {res}"),
973
- };
974
- Err(VmError::Parse(format!("{msg}{loc}")))
961
+ let (exc, message) = (tc.exception(), tc.message());
962
+ Err(parse_error(
963
+ tc,
964
+ exc,
965
+ message,
966
+ &filename,
967
+ "module parse error",
968
+ ))
975
969
  }
976
970
  }
977
971
  }
@@ -1013,10 +1007,9 @@ fn op_instantiate_module(scope: &mut v8::PinScope<'_, '_, ()>, module_id: i32) -
1013
1007
  | v8::ModuleStatus::Evaluated => Ok(JsVal::Undefined),
1014
1008
  // V8 CHECK-aborts on instantiating an errored
1015
1009
  // module; surface its exception instead.
1016
- v8::ModuleStatus::Errored => Err(VmError::JsError {
1017
- message: module.get_exception().to_rust_string_lossy(scope),
1018
- backtrace: vec![],
1019
- }),
1010
+ v8::ModuleStatus::Errored => {
1011
+ Err(rejected_with(scope, module.get_exception()))
1012
+ }
1020
1013
  _ => {
1021
1014
  v8::tc_scope!(let tc, scope);
1022
1015
  match module.instantiate_module(tc, resolve_imported) {
@@ -1047,6 +1040,7 @@ fn op_evaluate_module(
1047
1040
  scope: &mut v8::PinScope<'_, '_, ()>,
1048
1041
  module_id: i32,
1049
1042
  timeout_ms: u64,
1043
+ report: Option<String>,
1050
1044
  outermost: bool,
1051
1045
  ) -> VmReply {
1052
1046
  // Top-level module code (and, under :auto, the microtasks its
@@ -1081,17 +1075,25 @@ fn op_evaluate_module(
1081
1075
  // must not override their real outcome to Terminated.
1082
1076
  let mut did_eval = false;
1083
1077
  // V8 CHECK-aborts the process if evaluate runs on a
1084
- // module that isn't exactly Instantiated, so guard
1085
- // status explicitly rather than crash.
1078
+ // module that is not instantiated yet (Uninstantiated /
1079
+ // Instantiating), so guard status explicitly rather than
1080
+ // crash.
1086
1081
  let out = match module.get_status() {
1087
- v8::ModuleStatus::Errored => Err(VmError::JsError {
1088
- message: module.get_exception().to_rust_string_lossy(scope),
1089
- backtrace: vec![],
1090
- }),
1091
- v8::ModuleStatus::Evaluated => Ok(JsVal::Undefined),
1092
- v8::ModuleStatus::Instantiated => {
1082
+ // (…one evaluated already, with no reporter: nothing more to do, or its error again)
1083
+ v8::ModuleStatus::Errored if report.is_none() => {
1084
+ Err(rejected_with(scope, module.get_exception()))
1085
+ }
1086
+ v8::ModuleStatus::Evaluated if report.is_none() => Ok(JsVal::Undefined),
1087
+ // (…and with one, Evaluate again, as HTML runs every script element's record — V8 hands back
1088
+ // the same evaluation promise, rejected with its error or still pending its top-level
1089
+ // await — so each run reports)
1090
+ v8::ModuleStatus::Instantiated
1091
+ | v8::ModuleStatus::Evaluated
1092
+ | v8::ModuleStatus::Errored => {
1093
1093
  did_eval = true;
1094
1094
  v8::tc_scope!(let tc, scope);
1095
+ let reporter =
1096
+ report.as_deref().and_then(|name| global_function(tc, name));
1095
1097
  match module.evaluate(tc) {
1096
1098
  // A synchronous top-level throw yields a
1097
1099
  // *rejected* promise (not None); a pending
@@ -1099,12 +1101,27 @@ fn op_evaluate_module(
1099
1101
  // re-checked after the drain.
1100
1102
  Some(value) => {
1101
1103
  match v8::Local::<v8::Promise>::try_from(value) {
1104
+ // (…its rejection the reporter's: a synchronous throw's at once, before
1105
+ // whatever its script's runner does next — its element's `load`, Chrome —
1106
+ // the top-level await's as it settles, a microtask)
1107
+ Ok(p) if reporter.is_some() => {
1108
+ if let Some(reporter) = reporter {
1109
+ if p.state() == v8::PromiseState::Rejected {
1110
+ p.mark_as_handled();
1111
+ let (receiver, reason) = (
1112
+ v8::undefined(tc).into(),
1113
+ p.result(tc),
1114
+ );
1115
+ reporter.call(tc, receiver, &[reason]);
1116
+ } else {
1117
+ p.catch(tc, reporter);
1118
+ }
1119
+ }
1120
+ Ok(JsVal::Undefined)
1121
+ }
1102
1122
  Ok(p) if p.state() == v8::PromiseState::Rejected => {
1103
1123
  let reason = p.result(tc);
1104
- Err(VmError::JsError {
1105
- message: reason.to_rust_string_lossy(tc),
1106
- backtrace: vec![],
1107
- })
1124
+ Err(rejected_with(tc, reason))
1108
1125
  }
1109
1126
  Ok(p) => {
1110
1127
  eval_promise = Some(v8::Global::new(tc, p));
@@ -1132,10 +1149,7 @@ fn op_evaluate_module(
1132
1149
  let p = v8::Local::new(scope, &g);
1133
1150
  if p.state() == v8::PromiseState::Rejected {
1134
1151
  let reason = p.result(scope);
1135
- Err(VmError::JsError {
1136
- message: reason.to_rust_string_lossy(scope),
1137
- backtrace: vec![],
1138
- })
1152
+ Err(rejected_with(scope, reason))
1139
1153
  } else {
1140
1154
  out
1141
1155
  }
@@ -1151,6 +1165,16 @@ fn op_evaluate_module(
1151
1165
  VmReply::Done(outcome)
1152
1166
  }
1153
1167
 
1168
+ // The function the current context's global has under `name`, or None.
1169
+ fn global_function<'s>(
1170
+ scope: &mut v8::PinScope<'s, '_>,
1171
+ name: &str,
1172
+ ) -> Option<v8::Local<'s, v8::Function>> {
1173
+ let key = v8::String::new(scope, name)?;
1174
+ let global = scope.get_current_context().global(scope);
1175
+ v8::Local::<v8::Function>::try_from(global.get(scope, key.into())?).ok()
1176
+ }
1177
+
1154
1178
  // get_module_namespace and is_graph_async both CHECK-abort (a release check, so
1155
1179
  // it kills the process) below Instantiated, so both ask this first. Everything
1156
1180
  // from Instantiated up is admissible, Errored included: a module that fails to
@@ -1179,10 +1203,7 @@ fn op_module_namespace(scope: &mut v8::PinScope<'_, '_, ()>, module_id: i32) ->
1179
1203
  // never initialized — and the useful answer is WHY it errored,
1180
1204
  // the same one instantiate and evaluate give.
1181
1205
  if module.get_status() == v8::ModuleStatus::Errored {
1182
- Err(VmError::JsError {
1183
- message: module.get_exception().to_rust_string_lossy(scope),
1184
- backtrace: vec![],
1185
- })
1206
+ Err(rejected_with(scope, module.get_exception()))
1186
1207
  } else {
1187
1208
  match require_instantiated(module, "namespace") {
1188
1209
  Err(e) => Err(e),
@@ -1306,21 +1327,14 @@ fn op_compile_script(
1306
1327
  None if tc.has_terminated() => Err(VmError::Terminated),
1307
1328
  // Compile failure = a parse error (with location).
1308
1329
  None => {
1309
- let msg = tc
1310
- .exception()
1311
- .map(|e| e.to_rust_string_lossy(tc))
1312
- .unwrap_or_else(|| "script parse error".to_string());
1313
- let message = tc.message();
1314
- let res = message
1315
- .and_then(|m| m.get_script_resource_name(tc))
1316
- .filter(|v| v.is_string())
1317
- .map(|v| v.to_rust_string_lossy(tc))
1318
- .unwrap_or_else(|| filename.clone());
1319
- let loc = match message.and_then(|m| m.get_line_number(tc)) {
1320
- Some(line) => format!(" at {res}:{line}"),
1321
- None => format!(" at {res}"),
1322
- };
1323
- Err(VmError::Parse(format!("{msg}{loc}")))
1330
+ let (exc, message) = (tc.exception(), tc.message());
1331
+ Err(parse_error(
1332
+ tc,
1333
+ exc,
1334
+ message,
1335
+ &filename,
1336
+ "script parse error",
1337
+ ))
1324
1338
  }
1325
1339
  }
1326
1340
  }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RustyRacer
4
- VERSION = "0.2.4"
4
+ VERSION = "0.2.5"
5
5
  end
data/lib/rusty_racer.rb CHANGED
@@ -121,7 +121,7 @@ module RustyRacer
121
121
  # `timeout_ms` (0 = the isolate default) caps this eval; `filename` names the
122
122
  # script in stack traces and parse-error locations.
123
123
  def eval(source, timeout_ms: 0, filename: '<eval>')
124
- _eval(source, timeout_ms, filename, false)
124
+ _eval(source, timeout_ms, filename, 0)
125
125
  end
126
126
 
127
127
  # Run `source` for its EFFECT and return nil, without marshalling its
@@ -132,7 +132,14 @@ module RustyRacer
132
132
  # whose writes all landed. `globalThis.win = someProxy` is the shape that
133
133
  # bites.
134
134
  def eval_void(source, timeout_ms: 0, filename: '<eval>')
135
- _eval(source, timeout_ms, filename, true)
135
+ _eval(source, timeout_ms, filename, 1)
136
+ end
137
+
138
+ # Run `source` and return its completion value only where it is a primitive — a string, a number, a boolean, a
139
+ # BigInt, null — and nil for an object or a symbol, which is never walked: for a caller that wants a string of it
140
+ # (a javascript: URL's evaluation), whatever object the source happens to end with.
141
+ def eval_primitive(source, timeout_ms: 0, filename: '<eval>')
142
+ _eval(source, timeout_ms, filename, 2)
136
143
  end
137
144
 
138
145
  # Compile a classic <script>; returns a RustyRacer::Script to #run.
@@ -171,6 +178,15 @@ module RustyRacer
171
178
  self
172
179
  end
173
180
 
181
+ # Evaluate the instantiated module. Its evaluation's rejection — a top-level throw, a top-level await's — raises
182
+ # (RustyRacer::RuntimeError, as far as the drain after it settles), or, `report:` naming a function of its context's
183
+ # global, is that function's to report: called with the rejection's reason — a top-level throw's before this
184
+ # returns, a top-level await's as it settles — and nothing raised (HTML "run a module script": upon rejection,
185
+ # report the exception).
186
+ def evaluate(report: nil)
187
+ _evaluate(report&.to_s)
188
+ end
189
+
174
190
  # The V8 module status: :uninstantiated, :instantiating, :instantiated,
175
191
  # :evaluating, :evaluated or :errored.
176
192
  def status
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.4
4
+ version: 0.2.5
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-09-28 00:00:00.000000000 Z
11
+ date: 2026-10-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys