rusty_racer 0.1.16 → 0.2.1

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: 65667fe42e623b8ca26d70810029cc49de133a7c127e2a829c897359c510ef79
4
- data.tar.gz: 4b82947a2404a0cea7ee14dc9a80f88a3abbd28c79c788d24b6f8b5f7524af65
3
+ metadata.gz: 074b269481c924fc8e99f129e361d29fb4847c9c91ac4afee96378721fe9eebd
4
+ data.tar.gz: 68b312cba649752c393c0736e10efd8280f87ae0406ef7826b8d9fe3ec5505ce
5
5
  SHA512:
6
- metadata.gz: '084d1145e469c8d3422ec2402293e15d1e0fde2dd9d7436f16d5ec9655709207c809ca3e4d89f310ab5c91ae8b1449a5f4d80d2168d33f9c63217d2f14f09571'
7
- data.tar.gz: ccfa7cd5c2d2e4cb847e15079a44bac3b1757410e2bf3f450ecf44524b8c0445b14c94d45f32d74688b4daec1cebe4decc74fb8c39455fc04bf02f4791a22c60
6
+ metadata.gz: c3ff8f690a9ad98912f2328d7be4bc10c3d1f38499365f628fbe4dac5e782832323bfc86976ac05fbe9406f288b75191fac11f1eb4f920d1caae056561d0eceb
7
+ data.tar.gz: a13dad93b256b9f15eef3baa27e79a97e2d7da61ec20c29aefc66907cd1cef4916fb0c84d65742233d182d48fed1fa369b48e86105bba7fede8a6afdaa008133
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 })
@@ -79,6 +80,20 @@ rescue RustyRacer::RuntimeError => e
79
80
  end
80
81
  ```
81
82
 
83
+ Everything the binding raises descends from `RustyRacer::Error`, so one rescue
84
+ covers the library. Under it: `ParseError`, `RuntimeError`,
85
+ `ScriptTerminatedError` and `V8OutOfMemoryError` for what the JS did;
86
+ `DisposedError` for using an isolate (or anything it handed out) after
87
+ disposing it; `WrongThreadError` for reaching an isolate from the wrong thread.
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
+
82
97
  ES modules (the embedder owns the URL→module registry):
83
98
 
84
99
  ```ruby
@@ -86,10 +101,19 @@ dep = ctx.compile_module("export const x = 21;", filename: "/dep.js")
86
101
  app = ctx.compile_module('import {x} from "./dep.js"; export const r = x * 2;',
87
102
  filename: "/app.js")
88
103
  app.instantiate { |specifier, referrer| dep if specifier == "./dep.js" }
104
+ app.graph_async? # => false
89
105
  app.evaluate
90
106
  app.namespace["r"] # => 42
91
107
  ```
92
108
 
109
+ `graph_async?` asks whether top-level `await` appears anywhere in the *linked*
110
+ graph, so an `await` in a dependency counts — which is why it needs an
111
+ instantiated module and raises before then. `false` is the answer that carries a
112
+ guarantee: `evaluate` ran the whole graph to completion. Since there is no event
113
+ loop here (see below), a `true` graph whose `await` never settles returns from
114
+ `evaluate` with the module body still suspended, and `status` reports
115
+ `:evaluated` either way — so `graph_async?` is what distinguishes the two.
116
+
93
117
  Classic `<script>`s work the same way: `ctx.compile("1 + 1").run` # => 2.
94
118
 
95
119
  ### Resource limits
@@ -5,7 +5,7 @@
5
5
  # links and loads with no custom archive.
6
6
  [package]
7
7
  name = "rusty_racer"
8
- version = "0.1.16"
8
+ version = "0.2.1"
9
9
  edition = "2024"
10
10
  publish = false
11
11
 
@@ -326,13 +326,21 @@ fn current_ruby_thread() -> usize {
326
326
  // that class carrying the message. Must be called with the GVL held.
327
327
  fn error_to_exception(e: &Error) -> Option<Exception> {
328
328
  let v = e.value()?;
329
- if let Ok(exc) = Exception::try_convert(v) {
330
- return Some(exc);
331
- }
329
+ // ExceptionClass FIRST. Error::new(class, msg) stores the CLASS, and
330
+ // Exception::try_convert would happily accept it — it falls back to calling
331
+ // #exception on the value, which hands back a fresh instance carrying only
332
+ // the class name, silently dropping our message.
332
333
  if let Ok(class) = ExceptionClass::try_convert(v) {
333
- return class.new_instance((e.to_string(),)).ok();
334
+ // magnus renders such an Error as "Class: message"; the instance carries
335
+ // the class already, so strip it rather than say it twice.
336
+ let text = e.to_string();
337
+ let message = text
338
+ .strip_prefix(&format!("{class}: "))
339
+ .unwrap_or(&text)
340
+ .to_owned();
341
+ return class.new_instance((message,)).ok();
334
342
  }
335
- None
343
+ Exception::try_convert(v).ok()
336
344
  }
337
345
 
338
346
  // JS called a host function. We are on the owner thread with the GVL RELEASED
@@ -351,8 +359,20 @@ fn host_fn_callback(
351
359
  Err(_) => return,
352
360
  };
353
361
  let mut js_args = Vec::with_capacity(args.length() as usize);
354
- for i in 0..args.length() {
355
- js_args.push(js_to_jsval(scope, args.get(i)));
362
+ {
363
+ // Marshalling an argument RUNS JS — accessors, Proxy traps — and a throw
364
+ // there has to fail the host call, not hand the Ruby proc a value that
365
+ // was never read. Without a TryCatch of our own the exception simply
366
+ // rides along until something happens to clear it, so whether the caller
367
+ // ever saw it came down to whether the proc re-entered V8.
368
+ v8::tc_scope!(let tc, scope);
369
+ for i in 0..args.length() {
370
+ js_args.push(js_to_jsval(tc, args.get(i)));
371
+ }
372
+ if tc.has_caught() {
373
+ tc.rethrow();
374
+ return;
375
+ }
356
376
  }
357
377
  // Reach the owning Core through the slot back-pointer (the callback holds
358
378
  // only a scope). Null only before wiring, which is before any JS can run.
@@ -924,19 +944,22 @@ fn resolve_imported<'s>(
924
944
  // import's auto-link (None -> dynamic_import_resolver, with the initiating
925
945
  // realm so it can resolve per-realm).
926
946
  let instantiate = istate!(scope).instantiate_resolve.as_ref().map(|r| r.get());
947
+ let mut dynamic_err = None;
927
948
  let dep_id = match instantiate {
928
949
  Some(resolve) => {
950
+ // The error conversion happens INSIDE with_gvl: error_to_exception
951
+ // calls into Ruby (so it can allocate, so it can GC) and BoxValue::new
952
+ // registers a GC root — neither is safe with the GVL released.
929
953
  match with_gvl(|| {
930
954
  resolve_module_via_ruby(unsafe { &*core_ptr }, resolve, &spec, &ref_url, None)
955
+ .map_err(|e| error_to_exception(&e).map(BoxValue::new))
931
956
  }) {
932
957
  Ok(id) => id,
933
958
  // Stash the resolver's own raised exception (GC-rooted) so the
934
959
  // InstantiateModule op can re-raise it with its original class
935
960
  // instead of a generic "failed to link".
936
- Err(e) => {
937
- if let Some(exc) = error_to_exception(&e) {
938
- istate!(scope).instantiate_resolve_err = Some(BoxValue::new(exc));
939
- }
961
+ Err(exc) => {
962
+ istate!(scope).instantiate_resolve_err = exc;
940
963
  None
941
964
  }
942
965
  }
@@ -949,7 +972,7 @@ fn resolve_imported<'s>(
949
972
  .as_ref()
950
973
  .map(|r| r.get());
951
974
  match resolver {
952
- Some(p) => with_gvl(|| {
975
+ Some(p) => match with_gvl(|| {
953
976
  resolve_module_via_ruby(
954
977
  unsafe { &*core_ptr },
955
978
  p,
@@ -957,23 +980,81 @@ fn resolve_imported<'s>(
957
980
  &ref_url,
958
981
  Some(here.unwrap_or(0)),
959
982
  )
960
- })
961
- .unwrap_or(None),
983
+ // Rendering the message reads the Ruby exception, so it too
984
+ // stays inside with_gvl.
985
+ .map_err(|e| e.to_string())
986
+ }) {
987
+ Ok(id) => id,
988
+ // Unlike the static branch there is no Ruby frame waiting to
989
+ // re-raise into — this failure becomes the import()'s promise
990
+ // rejection — so the resolver's own message has to travel out
991
+ // as the JS error or it is lost.
992
+ Err(why) => {
993
+ dynamic_err = Some(why);
994
+ None
995
+ }
996
+ },
962
997
  None => None,
963
998
  }
964
999
  }
965
1000
  };
966
- let dep_id = dep_id?;
1001
+ // From here on, every exit that isn't a module throws first. V8 has no
1002
+ // channel for "why" — it just sees an empty return, installs no error of its
1003
+ // own and resets the graph — so without a thrown exception the failure
1004
+ // reaches Ruby as a bare "unexpected failure" naming neither the import nor
1005
+ // where it came from, which is the single most common module wiring mistake.
1006
+ // (The two bails above stay silent: both are broken internal state, not
1007
+ // anything the caller did.) The one path that must also stay silent is a
1008
+ // STATIC resolver that raised — its own Ruby exception is already stashed
1009
+ // for instantiate_module to re-raise, and ours would mask it. The dynamic
1010
+ // branch has no such frame, so it carries its message in dynamic_err.
1011
+ let raised = istate!(scope).instantiate_resolve_err.is_some();
1012
+ let fail = |scope: &mut v8::PinScope<'_, '_>, message: String| {
1013
+ if !raised {
1014
+ throw_js_error(scope, &message);
1015
+ }
1016
+ None
1017
+ };
1018
+ let import = format!("{spec:?} imported from {ref_url}");
1019
+ let Some(dep_id) = dep_id else {
1020
+ return match dynamic_err {
1021
+ Some(why) => fail(scope, format!("{why} (resolving {import})")),
1022
+ None => fail(
1023
+ scope,
1024
+ format!("failed to resolve module specifier {import}"),
1025
+ ),
1026
+ };
1027
+ };
967
1028
  // The dep must live in the context actually being linked — the auto-link of
968
1029
  // a dynamic import runs in whatever realm import() fired in, which kAuto can
969
1030
  // detach from the request that started it. A foreign-context module would
970
1031
  // V8-CHECK-abort.
971
- let g = {
972
- let (g, _, cid) = istate!(scope).modules.by_id.get(&dep_id)?;
973
- if Some(*cid) != here {
974
- return None;
1032
+ let found = istate!(scope)
1033
+ .modules
1034
+ .by_id
1035
+ .get(&dep_id)
1036
+ .map(|(g, _, cid)| (g.clone(), *cid));
1037
+ let g = match (here, found) {
1038
+ (Some(here), Some((g, cid))) if cid == here => g,
1039
+ (Some(_), Some(_)) => {
1040
+ return fail(
1041
+ scope,
1042
+ format!("resolver returned a module from another realm for {import}"),
1043
+ );
1044
+ }
1045
+ // The realm being linked is gone. Not reachable while a module is
1046
+ // linking (see |here| above), but reporting it as a realm mismatch
1047
+ // would name the wrong cause.
1048
+ (None, _) => return fail(scope, format!("the realm being linked is gone ({import})")),
1049
+ // Only reachable if the module went away between the resolver handing it
1050
+ // over and this lookup; to the caller that reads the same as never
1051
+ // having resolved it.
1052
+ (_, None) => {
1053
+ return fail(
1054
+ scope,
1055
+ format!("failed to resolve module specifier {import}"),
1056
+ );
975
1057
  }
976
- g.clone()
977
1058
  };
978
1059
  Some(v8::Local::new(scope, &g))
979
1060
  }
@@ -1916,8 +1997,16 @@ fn build_snapshot(code: &str, base: Option<Vec<u8>>, warmup: bool) -> Result<Vec
1916
1997
  {
1917
1998
  let cscope = &mut v8::ContextScope::new(scope, context);
1918
1999
  if !code.is_empty()
1919
- && let Err(e) =
1920
- run_source(cscope, code, if warmup { "<warmup>" } else { "<snapshot>" })
2000
+ && let Err(e) = run_source(
2001
+ cscope,
2002
+ code,
2003
+ if warmup { "<warmup>" } else { "<snapshot>" },
2004
+ // Snapshot/warmup code runs for its EFFECT (the heap it
2005
+ // leaves behind); nothing reads its completion value, so
2006
+ // don't marshal it — a bundle ending in an expression must
2007
+ // not fail the snapshot over a value no one wants.
2008
+ true,
2009
+ )
1921
2010
  {
1922
2011
  err = Some(match e {
1923
2012
  VmError::Parse(m) | VmError::Runtime(m) => m,
@@ -2104,7 +2193,7 @@ impl Core {
2104
2193
  }
2105
2194
  if self.shared.lock().unwrap().disposed {
2106
2195
  return Err(Error::new(
2107
- ruby.exception_runtime_error(),
2196
+ err_class(ruby, "DisposedError"),
2108
2197
  "disposed context",
2109
2198
  ));
2110
2199
  }
@@ -2280,7 +2369,7 @@ impl Core {
2280
2369
  // isolate (every later op refuses) rather than risk using it.
2281
2370
  self.shared.lock().unwrap().disposed = true;
2282
2371
  Err(Error::new(
2283
- ruby.exception_runtime_error(),
2372
+ err_class(ruby, "InternalError"),
2284
2373
  "internal error: operation panicked; the isolate has been disposed",
2285
2374
  ))
2286
2375
  }
@@ -2302,7 +2391,7 @@ impl Core {
2302
2391
  }
2303
2392
  VmReply::Done(Err(e)) => Err(vm_err(ruby, e)),
2304
2393
  _ => Err(Error::new(
2305
- ruby.exception_runtime_error(),
2394
+ err_class(ruby, "InternalError"),
2306
2395
  "internal: unexpected reply kind",
2307
2396
  )),
2308
2397
  }
@@ -2402,7 +2491,7 @@ impl Core {
2402
2491
  let reply = self.run(ruby, Request::HeapStatistics)?;
2403
2492
  let VmReply::Heap(s) = reply else {
2404
2493
  return Err(Error::new(
2405
- ruby.exception_runtime_error(),
2494
+ err_class(ruby, "InternalError"),
2406
2495
  "internal: unexpected heap reply",
2407
2496
  ));
2408
2497
  };
@@ -2441,6 +2530,7 @@ impl Core {
2441
2530
  source: String,
2442
2531
  filename: String,
2443
2532
  timeout_ms: u64,
2533
+ void: bool,
2444
2534
  ) -> Result<Value, Error> {
2445
2535
  let reply = self.run(
2446
2536
  ruby,
@@ -2449,6 +2539,7 @@ impl Core {
2449
2539
  source,
2450
2540
  filename,
2451
2541
  timeout_ms,
2542
+ void,
2452
2543
  },
2453
2544
  )?;
2454
2545
  self.reply_value(ruby, reply)
@@ -2576,7 +2667,7 @@ impl Core {
2576
2667
  VmReply::ModuleCompiled(Ok(cm)) => Ok(cm),
2577
2668
  VmReply::ModuleCompiled(Err(e)) => Err(vm_err(ruby, e)),
2578
2669
  _ => Err(Error::new(
2579
- ruby.exception_runtime_error(),
2670
+ err_class(ruby, "InternalError"),
2580
2671
  "internal: unexpected compile reply",
2581
2672
  )),
2582
2673
  }
@@ -2649,6 +2740,11 @@ impl Core {
2649
2740
  self.reply_value(ruby, reply)
2650
2741
  }
2651
2742
 
2743
+ fn module_graph_async(&self, ruby: &Ruby, module_id: i32) -> Result<Value, Error> {
2744
+ let reply = self.run(ruby, Request::ModuleGraphAsync { module_id })?;
2745
+ self.reply_value(ruby, reply)
2746
+ }
2747
+
2652
2748
  fn dispose_module(&self, ruby: &Ruby, module_id: i32) -> Result<(), Error> {
2653
2749
  let reply = self.run(ruby, Request::DisposeModule { module_id })?;
2654
2750
  self.reply_value(ruby, reply).map(|_| ())
@@ -2681,18 +2777,19 @@ impl Core {
2681
2777
  VmReply::ScriptCompiled(Ok(cs)) => Ok(cs),
2682
2778
  VmReply::ScriptCompiled(Err(e)) => Err(vm_err(ruby, e)),
2683
2779
  _ => Err(Error::new(
2684
- ruby.exception_runtime_error(),
2780
+ err_class(ruby, "InternalError"),
2685
2781
  "internal: unexpected compile reply",
2686
2782
  )),
2687
2783
  }
2688
2784
  }
2689
2785
 
2690
- fn run_script(&self, ruby: &Ruby, script_id: i32) -> Result<Value, Error> {
2786
+ fn run_script(&self, ruby: &Ruby, script_id: i32, void: bool) -> Result<Value, Error> {
2691
2787
  let reply = self.run(
2692
2788
  ruby,
2693
2789
  Request::RunScript {
2694
2790
  script_id,
2695
2791
  timeout_ms: self.default_timeout_ms,
2792
+ void,
2696
2793
  },
2697
2794
  )?;
2698
2795
  self.reply_value(ruby, reply)
@@ -2794,7 +2891,7 @@ impl Core {
2794
2891
  // and SEGV — refuse it, leaving the isolate usable.
2795
2892
  if self.depth.load(Ordering::SeqCst) != 0 {
2796
2893
  return Err(Error::new(
2797
- ruby.exception_runtime_error(),
2894
+ err_class(ruby, "Error"),
2798
2895
  "RustyRacer: cannot dispose an isolate from within a running op or host callback",
2799
2896
  ));
2800
2897
  }
@@ -2915,19 +3012,21 @@ impl Context {
2915
3012
  // id 0's lifetime is the isolate's; extras also track their own dispose.
2916
3013
  if self.disposed.load(Ordering::SeqCst) || self.core.is_disposed() {
2917
3014
  return Err(Error::new(
2918
- ruby.exception_runtime_error(),
3015
+ err_class(ruby, "DisposedError"),
2919
3016
  "disposed context",
2920
3017
  ));
2921
3018
  }
2922
3019
  Ok(())
2923
3020
  }
2924
3021
  // timeout_ms 0 = use the isolate's default; an explicit value overrides it.
3022
+ // |void| discards the completion value instead of marshalling it.
2925
3023
  fn eval(
2926
3024
  ruby: &Ruby,
2927
3025
  rb_self: &Self,
2928
3026
  source: String,
2929
3027
  timeout_ms: u64,
2930
3028
  filename: String,
3029
+ void: bool,
2931
3030
  ) -> Result<Value, Error> {
2932
3031
  rb_self.check_live(ruby)?;
2933
3032
  let timeout = if timeout_ms == 0 {
@@ -2937,7 +3036,7 @@ impl Context {
2937
3036
  };
2938
3037
  rb_self
2939
3038
  .core
2940
- .eval_t(ruby, rb_self.id, source, filename, timeout)
3039
+ .eval_t(ruby, rb_self.id, source, filename, timeout, void)
2941
3040
  }
2942
3041
  fn call(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result<Value, Error> {
2943
3042
  rb_self.check_live(ruby)?;
@@ -3112,7 +3211,7 @@ impl JsModule {
3112
3211
  // iso.dispose is a use-after-free.
3113
3212
  if self.disposed.load(Ordering::SeqCst) || self.core.is_disposed() {
3114
3213
  return Err(Error::new(
3115
- ruby.exception_runtime_error(),
3214
+ err_class(ruby, "DisposedError"),
3116
3215
  "disposed module",
3117
3216
  ));
3118
3217
  }
@@ -3140,6 +3239,19 @@ impl JsModule {
3140
3239
  rb_self.check_live(ruby)?;
3141
3240
  rb_self.core.module_status(ruby, rb_self.module_id)
3142
3241
  }
3242
+ // True when top-level await appears anywhere in the module's LINKED import
3243
+ // graph (v8::Module::IsGraphAsync) — a dependency's await counts, which is
3244
+ // why no check on the source text alone can stand in for this. FALSE is the
3245
+ // load-bearing answer: V8 guarantees the evaluation promise is settled, so
3246
+ // #evaluate returning means the whole graph ran. True and it may have
3247
+ // returned with the body still suspended, while #status reads :evaluated
3248
+ // regardless (V8 folds its internal kEvaluatingAsync into kEvaluated).
3249
+ // Raises RustyRacer::RuntimeError unless the module is instantiated: before
3250
+ // linking there is no graph to walk.
3251
+ fn graph_async(ruby: &Ruby, rb_self: &Self) -> Result<Value, Error> {
3252
+ rb_self.check_live(ruby)?;
3253
+ rb_self.core.module_graph_async(ruby, rb_self.module_id)
3254
+ }
3143
3255
  // The bytecode cache produced at compile (produce_cache: true), as a binary
3144
3256
  // String, or nil. Persist it cross-process and pass back via cached_data:.
3145
3257
  fn cached_data(ruby: &Ruby, rb_self: &Self) -> Value {
@@ -3176,7 +3288,7 @@ impl Script {
3176
3288
  // Also refuse once the isolate is disposed (see JsModule::check_live).
3177
3289
  if self.disposed.load(Ordering::SeqCst) || self.core.is_disposed() {
3178
3290
  return Err(Error::new(
3179
- ruby.exception_runtime_error(),
3291
+ err_class(ruby, "DisposedError"),
3180
3292
  "disposed script",
3181
3293
  ));
3182
3294
  }
@@ -3186,7 +3298,13 @@ impl Script {
3186
3298
  // thrown exception is a RuntimeError; a timeout/stop a ScriptTerminatedError.
3187
3299
  fn run(ruby: &Ruby, rb_self: &Self) -> Result<Value, Error> {
3188
3300
  rb_self.check_live(ruby)?;
3189
- rb_self.core.run_script(ruby, rb_self.script_id)
3301
+ rb_self.core.run_script(ruby, rb_self.script_id, false)
3302
+ }
3303
+ // Run it for its effect only, discarding the completion value (see
3304
+ // Context#eval_void) — what a <script> tag does.
3305
+ fn run_void(ruby: &Ruby, rb_self: &Self) -> Result<Value, Error> {
3306
+ rb_self.check_live(ruby)?;
3307
+ rb_self.core.run_script(ruby, rb_self.script_id, true)
3190
3308
  }
3191
3309
  fn cached_data(ruby: &Ruby, rb_self: &Self) -> Value {
3192
3310
  code_cache_value(ruby, rb_self.cached_data.as_ref())
@@ -3231,7 +3349,7 @@ fn code_cache_from_reply(ruby: &Ruby, reply: VmReply) -> Result<Option<Vec<u8>>,
3231
3349
  VmReply::CodeCache(Ok(bytes)) => Ok(bytes),
3232
3350
  VmReply::CodeCache(Err(e)) => Err(vm_err(ruby, e)),
3233
3351
  _ => Err(Error::new(
3234
- ruby.exception_runtime_error(),
3352
+ err_class(ruby, "InternalError"),
3235
3353
  "internal: unexpected code-cache reply",
3236
3354
  )),
3237
3355
  }
@@ -3367,7 +3485,7 @@ fn resolve_module_via_ruby(
3367
3485
  })?;
3368
3486
  if !std::ptr::eq(Arc::as_ptr(&obj.core), core as *const Core) {
3369
3487
  return Err(Error::new(
3370
- ruby.exception_runtime_error(),
3488
+ err_class(&ruby, "Error"),
3371
3489
  "module resolver returned a Module from a different Context",
3372
3490
  ));
3373
3491
  }
@@ -3407,8 +3525,9 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
3407
3525
 
3408
3526
  // A v8::Context (realm): eval/call/attach/compile_module.
3409
3527
  let context = module.define_class("Context", ruby.class_object())?;
3410
- // keyword-arg wrapper Context#eval(source, timeout_ms:, filename:) in lib.
3411
- context.define_method("_eval", method!(Context::eval, 3))?;
3528
+ // Backs both keyword-arg wrappers in lib: Context#eval(source, timeout_ms:,
3529
+ // filename:) and #eval_void, which differ only in the 4th arg (the void flag).
3530
+ context.define_method("_eval", method!(Context::eval, 4))?;
3412
3531
  context.define_method("call", method!(Context::call, -1))?;
3413
3532
  context.define_method("call_void", method!(Context::call_void, -1))?;
3414
3533
  context.define_method("attach", method!(Context::attach, 2))?;
@@ -3424,6 +3543,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
3424
3543
  // Classic compiled script: Context#compile -> #run / #cached_data.
3425
3544
  let script = module.define_class("Script", ruby.class_object())?;
3426
3545
  script.define_method("run", method!(Script::run, 0))?;
3546
+ script.define_method("run_void", method!(Script::run_void, 0))?;
3427
3547
  script.define_method("cached_data", method!(Script::cached_data, 0))?;
3428
3548
  script.define_method("cache_rejected?", method!(Script::cache_rejected, 0))?;
3429
3549
  script.define_method("create_code_cache", method!(Script::create_code_cache, 0))?;
@@ -3444,6 +3564,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
3444
3564
  jsmodule.define_method("evaluate", method!(JsModule::evaluate, 0))?;
3445
3565
  jsmodule.define_method("namespace", method!(JsModule::namespace, 0))?;
3446
3566
  jsmodule.define_method("_status", method!(JsModule::status, 0))?;
3567
+ jsmodule.define_method("graph_async?", method!(JsModule::graph_async, 0))?;
3447
3568
  jsmodule.define_method("cached_data", method!(JsModule::cached_data, 0))?;
3448
3569
  jsmodule.define_method("cache_rejected?", method!(JsModule::cache_rejected, 0))?;
3449
3570
  jsmodule.define_method("create_code_cache", method!(JsModule::create_code_cache, 0))?;
@@ -149,7 +149,7 @@ fn js_container_id(
149
149
  // First sighting but too deep: truncate WITHOUT registering, so no later
150
150
  // Ref can target a container that was never emitted.
151
151
  if depth >= MAX_MARSHAL_DEPTH {
152
- return Err(JsVal::Str(value.to_rust_string_lossy(scope)));
152
+ return Err(JsVal::Str(stringify(scope, value)));
153
153
  }
154
154
  let id = seen.next_id;
155
155
  seen.next_id += 1;
@@ -342,7 +342,7 @@ fn js_to_jsval_d(
342
342
  let Some(key) = names.get_index(scope, i) else {
343
343
  continue;
344
344
  };
345
- let key_str = key.to_rust_string_lossy(scope);
345
+ let key_str = stringify(scope, key);
346
346
  let val = obj
347
347
  .get(scope, key)
348
348
  .unwrap_or_else(|| v8::undefined(scope).into());
@@ -350,8 +350,43 @@ fn js_to_jsval_d(
350
350
  }
351
351
  return JsVal::Obj { id, entries };
352
352
  }
353
+ // Enumeration THREW — a module namespace whose bindings aren't
354
+ // initialized yet, a Proxy with a hostile ownKeys trap. Falling through
355
+ // to the stringify below would hand the caller a plausible-looking
356
+ // "[object Object]" for an object we never actually read. Leave the
357
+ // pending exception alone instead and let the op's TryCatch report it —
358
+ // and give back the id, or a sibling Ref to this same object would point
359
+ // at a container that is never emitted.
360
+ unregister_container(seen, obj, id);
361
+ return JsVal::Undefined;
362
+ }
363
+ JsVal::Str(stringify(scope, value))
364
+ }
365
+
366
+ // The escape hatch for values with no structural representation — Function,
367
+ // Date, RegExp, Symbol. to_rust_string_lossy is Value::ToString, which THROWS on
368
+ // a Symbol, and a Symbol sitting anywhere in a graph (every React element has
369
+ // one) would then leave a pending exception behind and fail the whole op over a
370
+ // value that marshalled fine. to_detail_string is V8's no-side-effects renderer
371
+ // and cannot throw, so Symbols go through it.
372
+ fn stringify(scope: &mut v8::PinScope<'_, '_>, value: v8::Local<v8::Value>) -> String {
373
+ if value.is_symbol() {
374
+ return value
375
+ .to_detail_string(scope)
376
+ .map(|s| s.to_rust_string_lossy(scope))
377
+ .unwrap_or_default();
378
+ }
379
+ value.to_rust_string_lossy(scope)
380
+ }
381
+
382
+ // Undo js_container_id's registration for a container we end up NOT emitting.
383
+ // The id is always the last one pushed to its bucket, since nothing else can
384
+ // register while we are inside this object.
385
+ fn unregister_container(seen: &mut JsSeen, obj: v8::Local<v8::Object>, id: u32) {
386
+ let hash = obj.get_identity_hash().get();
387
+ if let Some(bucket) = seen.map.get_mut(&hash) {
388
+ bucket.retain(|(_, existing)| *existing != id);
353
389
  }
354
- JsVal::Str(value.to_rust_string_lossy(scope))
355
390
  }
356
391
 
357
392
  // Owned-by-value (not &JsVal): a JsVal::Bytes hands its Vec straight to V8's
@@ -18,6 +18,29 @@ use crate::istate;
18
18
  use crate::marshal::{JsVal, js_to_jsval, jsval_to_js};
19
19
  use crate::*;
20
20
 
21
+ // Marshal an op's result, treating a throw on the way out as the op's outcome.
22
+ // Marshalling RUNS JS — accessors, Proxy traps, toString — and an exception
23
+ // raised there is as real as one from the script itself, but it lands after the
24
+ // call has already succeeded, so nothing else looks at it: the TryCatch drops it
25
+ // on unwind and the caller keeps a value that no longer means anything (an
26
+ // object whose enumeration threw marshals to undefined). A macro rather than a
27
+ // fn because the tc_scope! binding has no nameable type here.
28
+ macro_rules! marshalled {
29
+ ($tc:expr, $value:expr) => {{
30
+ let tc = $tc;
31
+ let out = js_to_jsval(tc, $value);
32
+ let exc = tc.exception();
33
+ if exc.is_none() {
34
+ Ok(out)
35
+ } else if tc.has_terminated() {
36
+ Err(VmError::Terminated)
37
+ } else {
38
+ let stack = tc.stack_trace();
39
+ Err(capture_js_error(tc, exc, stack))
40
+ }
41
+ }};
42
+ }
43
+
21
44
  // One VM operation, built by a magnus method and run inline by Core::run ->
22
45
  // service_request -> dispatch_one. |context_id| selects which realm the op runs
23
46
  // in: 0 = the main realm (Context's own globalThis, swappable by reset_realm),
@@ -28,6 +51,11 @@ pub(crate) enum Request {
28
51
  source: String,
29
52
  filename: String,
30
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,
31
59
  },
32
60
  // Resolve a dotted function path on globalThis and invoke it with marshalled
33
61
  // args (v8::Function::call), preserving the holder as `this`. Distinct from
@@ -104,6 +132,11 @@ pub(crate) enum Request {
104
132
  ModuleStatus {
105
133
  module_id: i32,
106
134
  },
135
+ // Whether top-level await appears anywhere in the module's linked import
136
+ // graph (v8::Module::IsGraphAsync), as a bool.
137
+ ModuleGraphAsync {
138
+ module_id: i32,
139
+ },
107
140
  DisposeModule {
108
141
  module_id: i32,
109
142
  },
@@ -118,10 +151,12 @@ pub(crate) enum Request {
118
151
  produce_cache: bool,
119
152
  eager: bool,
120
153
  },
121
- // 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).
122
156
  RunScript {
123
157
  script_id: i32,
124
158
  timeout_ms: u64,
159
+ void: bool,
125
160
  },
126
161
  DisposeScript {
127
162
  script_id: i32,
@@ -192,6 +227,7 @@ pub(crate) fn run_source(
192
227
  scope: &mut v8::PinScope<'_, '_>,
193
228
  source: &str,
194
229
  filename: &str,
230
+ void: bool,
195
231
  ) -> Result<JsVal, VmError> {
196
232
  v8::tc_scope!(let tc, scope);
197
233
  // Compile and run as distinct phases so a compile failure maps to
@@ -224,7 +260,8 @@ pub(crate) fn run_source(
224
260
  }
225
261
  };
226
262
  match script.run(tc) {
227
- Some(value) => Ok(js_to_jsval(tc, value)),
263
+ Some(_) if void => Ok(JsVal::Undefined),
264
+ Some(value) => marshalled!(tc, value),
228
265
  None if tc.has_terminated() => Err(VmError::Terminated),
229
266
  None => {
230
267
  let exc = tc.exception();
@@ -279,7 +316,7 @@ fn call_function(
279
316
  match func.call(tc, recv, &argv) {
280
317
  // void: skip marshalling the return so a huge/cyclic result is never walked.
281
318
  Some(_) if void => Ok(JsVal::Undefined),
282
- Some(value) => Ok(js_to_jsval(tc, value)),
319
+ Some(value) => marshalled!(tc, value),
283
320
  None if tc.has_terminated() => Err(VmError::Terminated),
284
321
  None => {
285
322
  let exc = tc.exception();
@@ -396,6 +433,7 @@ fn request_realm(state: &IsolateState, request: &Request) -> Option<i32> {
396
433
  | Request::CreateContext
397
434
  | Request::DisposeContext { .. }
398
435
  | Request::ModuleStatus { .. }
436
+ | Request::ModuleGraphAsync { .. }
399
437
  | Request::DisposeModule { .. }
400
438
  | Request::DisposeScript { .. }
401
439
  | Request::ScriptCodeCache { .. }
@@ -419,7 +457,10 @@ fn dispatch_one(
419
457
  source,
420
458
  filename,
421
459
  timeout_ms,
422
- } => 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
+ ),
423
464
  Request::Call {
424
465
  context_id,
425
466
  name,
@@ -465,6 +506,7 @@ fn dispatch_one(
465
506
  } => op_evaluate_module(scope, module_id, timeout_ms, outermost),
466
507
  Request::ModuleNamespace { module_id } => op_module_namespace(scope, module_id),
467
508
  Request::ModuleStatus { module_id } => op_module_status(scope, module_id),
509
+ Request::ModuleGraphAsync { module_id } => op_module_graph_async(scope, module_id),
468
510
  Request::DisposeModule { module_id } => op_dispose_module(scope, module_id),
469
511
  Request::CompileScript {
470
512
  context_id,
@@ -485,7 +527,8 @@ fn dispatch_one(
485
527
  Request::RunScript {
486
528
  script_id,
487
529
  timeout_ms,
488
- } => op_run_script(scope, script_id, timeout_ms, outermost),
530
+ void,
531
+ } => op_run_script(scope, script_id, timeout_ms, void, outermost),
489
532
  Request::DisposeScript { script_id } => op_dispose_script(scope, script_id),
490
533
  // Serialize the script's CURRENT compile state. The stored handle is
491
534
  // the UnboundScript, which V8 fills in with inner-function bytecode as
@@ -528,12 +571,14 @@ fn op_low_memory_notification(scope: &mut v8::PinScope<'_, '_, ()>) -> VmReply {
528
571
  VmReply::Done(Ok(JsVal::Undefined))
529
572
  }
530
573
 
574
+ #[allow(clippy::too_many_arguments)]
531
575
  fn op_eval(
532
576
  scope: &mut v8::PinScope<'_, '_, ()>,
533
577
  context_id: i32,
534
578
  source: String,
535
579
  filename: String,
536
580
  timeout_ms: u64,
581
+ void: bool,
537
582
  outermost: bool,
538
583
  ) -> VmReply {
539
584
  let outcome = run_js_bracketed(scope, outermost, timeout_ms, "eval", |scope, outermost| {
@@ -542,7 +587,7 @@ fn op_eval(
542
587
  Some(ctx) => {
543
588
  let context = v8::Local::new(scope, &ctx);
544
589
  let scope = &mut v8::ContextScope::new(scope, context);
545
- let out = run_source(scope, &source, &filename);
590
+ let out = run_source(scope, &source, &filename, void);
546
591
  auto_drain(scope, outermost);
547
592
  (true, out)
548
593
  }
@@ -1065,6 +1110,20 @@ fn op_evaluate_module(
1065
1110
  VmReply::Done(outcome)
1066
1111
  }
1067
1112
 
1113
+ // get_module_namespace and is_graph_async both CHECK-abort (a release check, so
1114
+ // it kills the process) below Instantiated, so both ask this first. Everything
1115
+ // from Instantiated up is admissible, Errored included: a module that fails to
1116
+ // LINK is reset to Uninstantiated by V8's ResetGraph, so Errored can only mean
1117
+ // "linked, then evaluation failed" — the graph is still there to read.
1118
+ fn require_instantiated(module: v8::Local<v8::Module>, what: &str) -> Result<(), VmError> {
1119
+ match module.get_status() {
1120
+ v8::ModuleStatus::Uninstantiated | v8::ModuleStatus::Instantiating => Err(
1121
+ VmError::Runtime(format!("module must be instantiated before {what}")),
1122
+ ),
1123
+ _ => Ok(()),
1124
+ }
1125
+ }
1126
+
1068
1127
  fn op_module_namespace(scope: &mut v8::PinScope<'_, '_, ()>, module_id: i32) -> VmReply {
1069
1128
  let handle = module_handle(istate!(scope), module_id);
1070
1129
  let outcome = match handle {
@@ -1075,15 +1134,27 @@ fn op_module_namespace(scope: &mut v8::PinScope<'_, '_, ()>, module_id: i32) ->
1075
1134
  let context = v8::Local::new(scope, &cx);
1076
1135
  let scope = &mut v8::ContextScope::new(scope, context);
1077
1136
  let module = v8::Local::new(scope, &g);
1078
- // get_module_namespace CHECK-aborts unless the module
1079
- // is at least Instantiated.
1080
- match module.get_status() {
1081
- v8::ModuleStatus::Uninstantiated | v8::ModuleStatus::Instantiating => Err(
1082
- VmError::Runtime("module must be instantiated before namespace".into()),
1083
- ),
1084
- _ => {
1085
- let ns = module.get_module_namespace();
1086
- Ok(js_to_jsval(scope, ns))
1137
+ // An errored module's namespace is unreadable — its bindings
1138
+ // never initialized — and the useful answer is WHY it errored,
1139
+ // the same one instantiate and evaluate give.
1140
+ if module.get_status() == v8::ModuleStatus::Errored {
1141
+ Err(VmError::JsError {
1142
+ message: module.get_exception().to_rust_string_lossy(scope),
1143
+ backtrace: vec![],
1144
+ })
1145
+ } else {
1146
+ match require_instantiated(module, "namespace") {
1147
+ Err(e) => Err(e),
1148
+ Ok(()) => {
1149
+ let ns = module.get_module_namespace();
1150
+ // Reading the namespace RUNS JS: every export is an
1151
+ // accessor, and one whose binding is still
1152
+ // uninitialized throws. Unwatched, that throw made
1153
+ // enumeration fail and the caller silently got the
1154
+ // stringified object in place of their exports.
1155
+ v8::tc_scope!(let tc, scope);
1156
+ marshalled!(tc, ns)
1157
+ }
1087
1158
  }
1088
1159
  }
1089
1160
  }
@@ -1112,6 +1183,26 @@ fn op_module_status(scope: &mut v8::PinScope<'_, '_, ()>, module_id: i32) -> VmR
1112
1183
  VmReply::Done(outcome)
1113
1184
  }
1114
1185
 
1186
+ // Does top-level await appear anywhere in this module's import graph? V8 walks
1187
+ // the linked graph from here, so an `await` in a dependency counts too — which
1188
+ // is why no amount of looking at one source text can answer it, and why it needs
1189
+ // an instantiated module: before linking there is no graph to walk. What V8
1190
+ // guarantees is the false case — "if IsGraphAsync() is false, the returned
1191
+ // Promise is settled" — i.e. false means #evaluate ran the whole graph to
1192
+ // completion. No context is entered: the walk reads module slots, runs no JS.
1193
+ fn op_module_graph_async(scope: &mut v8::PinScope<'_, '_, ()>, module_id: i32) -> VmReply {
1194
+ let handle = module_handle(istate!(scope), module_id);
1195
+ let outcome = match handle {
1196
+ None => Err(VmError::Runtime("unknown module".into())),
1197
+ Some((g, _cid)) => {
1198
+ let module = v8::Local::new(scope, &g);
1199
+ require_instantiated(module, "graph_async?")
1200
+ .map(|()| JsVal::Bool(module.is_graph_async()))
1201
+ }
1202
+ };
1203
+ VmReply::Done(outcome)
1204
+ }
1205
+
1115
1206
  fn op_dispose_module(scope: &mut v8::PinScope<'_, '_, ()>, module_id: i32) -> VmReply {
1116
1207
  let m = &mut istate!(scope).modules;
1117
1208
  m.by_id.remove(&module_id);
@@ -1202,6 +1293,7 @@ fn op_run_script(
1202
1293
  scope: &mut v8::PinScope<'_, '_, ()>,
1203
1294
  script_id: i32,
1204
1295
  timeout_ms: u64,
1296
+ void: bool,
1205
1297
  outermost: bool,
1206
1298
  ) -> VmReply {
1207
1299
  let outcome = run_js_bracketed(
@@ -1226,7 +1318,8 @@ fn op_run_script(
1226
1318
  let out = {
1227
1319
  v8::tc_scope!(let tc, scope);
1228
1320
  match script.run(tc) {
1229
- Some(value) => Ok(js_to_jsval(tc, value)),
1321
+ Some(_) if void => Ok(JsVal::Undefined),
1322
+ Some(value) => marshalled!(tc, value),
1230
1323
  None if tc.has_terminated() => Err(VmError::Terminated),
1231
1324
  None => {
1232
1325
  let exc = tc.exception();
@@ -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.1.16"
4
+ VERSION = "0.2.1"
5
5
  end
data/lib/rusty_racer.rb CHANGED
@@ -42,6 +42,19 @@ module RustyRacer
42
42
  class SnapshotError < Error; end
43
43
  class PlatformAlreadyInitialized < Error; end
44
44
 
45
+ # Raised when a disposed Isolate, Context, Module or Script is used — or one
46
+ # whose Isolate has been disposed, which takes everything it handed out with
47
+ # it. Disposal is deliberate, so this says "you used it after you released
48
+ # it", not "something went wrong".
49
+ class DisposedError < Error; end
50
+
51
+ # A broken invariant inside the binding rather than anything the caller did:
52
+ # an unexpected reply kind, or an operation that panicked. Reaching one is a
53
+ # bug in rusty_racer and worth reporting. It exists so that EVERY error the
54
+ # extension raises is a RustyRacer::Error, and one rescue can cover the
55
+ # library.
56
+ class InternalError < Error; end
57
+
45
58
  # Raised when an Isolate (or a Context/Module/Script it handed out) is used
46
59
  # from a thread other than the one that created it. An isolate is
47
60
  # thread-confined: every operation must run on its owner thread. The lone
@@ -99,7 +112,18 @@ module RustyRacer
99
112
  # `timeout_ms` (0 = the isolate default) caps this eval; `filename` names the
100
113
  # script in stack traces and parse-error locations.
101
114
  def eval(source, timeout_ms: 0, filename: '<eval>')
102
- _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)
103
127
  end
104
128
 
105
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.1.16
4
+ version: 0.2.1
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-07-28 00:00:00.000000000 Z
11
+ date: 2026-08-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys