rusty_racer 0.1.16 → 0.2.0

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: f26a45e0afbed589acb2749fc1817e7d8508622c81a70fc378dc4af4a0f54714
4
+ data.tar.gz: 718074b8d9f468b9ab3da144391ecbeff1d00db347812e5209cd11d866f78f6e
5
5
  SHA512:
6
- metadata.gz: '084d1145e469c8d3422ec2402293e15d1e0fde2dd9d7436f16d5ec9655709207c809ca3e4d89f310ab5c91ae8b1449a5f4d80d2168d33f9c63217d2f14f09571'
7
- data.tar.gz: ccfa7cd5c2d2e4cb847e15079a44bac3b1757410e2bf3f450ecf44524b8c0445b14c94d45f32d74688b4daec1cebe4decc74fb8c39455fc04bf02f4791a22c60
6
+ metadata.gz: 03b227aa3eba1f597b34bca826f947c4345a8c16accebce84f267d5fcb3df2e9c72e3343e261be857387ada97000769dfca0b449130c9056bc8c6aceb569cd03
7
+ data.tar.gz: fbe970e400edf1041271954df3ba7f3be5d1f8f3775ca29d09398065b85d9b813bc9ca8b9ae8302bd32757884f8c47af5ba9953937ca6de0486af8bd7e50959b
data/README.md CHANGED
@@ -79,6 +79,12 @@ rescue RustyRacer::RuntimeError => e
79
79
  end
80
80
  ```
81
81
 
82
+ Everything the binding raises descends from `RustyRacer::Error`, so one rescue
83
+ covers the library. Under it: `ParseError`, `RuntimeError`,
84
+ `ScriptTerminatedError` and `V8OutOfMemoryError` for what the JS did;
85
+ `DisposedError` for using an isolate (or anything it handed out) after
86
+ disposing it; `WrongThreadError` for reaching an isolate from the wrong thread.
87
+
82
88
  ES modules (the embedder owns the URL→module registry):
83
89
 
84
90
  ```ruby
@@ -86,10 +92,19 @@ dep = ctx.compile_module("export const x = 21;", filename: "/dep.js")
86
92
  app = ctx.compile_module('import {x} from "./dep.js"; export const r = x * 2;',
87
93
  filename: "/app.js")
88
94
  app.instantiate { |specifier, referrer| dep if specifier == "./dep.js" }
95
+ app.graph_async? # => false
89
96
  app.evaluate
90
97
  app.namespace["r"] # => 42
91
98
  ```
92
99
 
100
+ `graph_async?` asks whether top-level `await` appears anywhere in the *linked*
101
+ graph, so an `await` in a dependency counts — which is why it needs an
102
+ instantiated module and raises before then. `false` is the answer that carries a
103
+ guarantee: `evaluate` ran the whole graph to completion. Since there is no event
104
+ loop here (see below), a `true` graph whose `await` never settles returns from
105
+ `evaluate` with the module body still suspended, and `status` reports
106
+ `:evaluated` either way — so `graph_async?` is what distinguishes the two.
107
+
93
108
  Classic `<script>`s work the same way: `ctx.compile("1 + 1").run` # => 2.
94
109
 
95
110
  ### 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.0"
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
  }
@@ -2104,7 +2185,7 @@ impl Core {
2104
2185
  }
2105
2186
  if self.shared.lock().unwrap().disposed {
2106
2187
  return Err(Error::new(
2107
- ruby.exception_runtime_error(),
2188
+ err_class(ruby, "DisposedError"),
2108
2189
  "disposed context",
2109
2190
  ));
2110
2191
  }
@@ -2280,7 +2361,7 @@ impl Core {
2280
2361
  // isolate (every later op refuses) rather than risk using it.
2281
2362
  self.shared.lock().unwrap().disposed = true;
2282
2363
  Err(Error::new(
2283
- ruby.exception_runtime_error(),
2364
+ err_class(ruby, "InternalError"),
2284
2365
  "internal error: operation panicked; the isolate has been disposed",
2285
2366
  ))
2286
2367
  }
@@ -2302,7 +2383,7 @@ impl Core {
2302
2383
  }
2303
2384
  VmReply::Done(Err(e)) => Err(vm_err(ruby, e)),
2304
2385
  _ => Err(Error::new(
2305
- ruby.exception_runtime_error(),
2386
+ err_class(ruby, "InternalError"),
2306
2387
  "internal: unexpected reply kind",
2307
2388
  )),
2308
2389
  }
@@ -2402,7 +2483,7 @@ impl Core {
2402
2483
  let reply = self.run(ruby, Request::HeapStatistics)?;
2403
2484
  let VmReply::Heap(s) = reply else {
2404
2485
  return Err(Error::new(
2405
- ruby.exception_runtime_error(),
2486
+ err_class(ruby, "InternalError"),
2406
2487
  "internal: unexpected heap reply",
2407
2488
  ));
2408
2489
  };
@@ -2576,7 +2657,7 @@ impl Core {
2576
2657
  VmReply::ModuleCompiled(Ok(cm)) => Ok(cm),
2577
2658
  VmReply::ModuleCompiled(Err(e)) => Err(vm_err(ruby, e)),
2578
2659
  _ => Err(Error::new(
2579
- ruby.exception_runtime_error(),
2660
+ err_class(ruby, "InternalError"),
2580
2661
  "internal: unexpected compile reply",
2581
2662
  )),
2582
2663
  }
@@ -2649,6 +2730,11 @@ impl Core {
2649
2730
  self.reply_value(ruby, reply)
2650
2731
  }
2651
2732
 
2733
+ fn module_graph_async(&self, ruby: &Ruby, module_id: i32) -> Result<Value, Error> {
2734
+ let reply = self.run(ruby, Request::ModuleGraphAsync { module_id })?;
2735
+ self.reply_value(ruby, reply)
2736
+ }
2737
+
2652
2738
  fn dispose_module(&self, ruby: &Ruby, module_id: i32) -> Result<(), Error> {
2653
2739
  let reply = self.run(ruby, Request::DisposeModule { module_id })?;
2654
2740
  self.reply_value(ruby, reply).map(|_| ())
@@ -2681,7 +2767,7 @@ impl Core {
2681
2767
  VmReply::ScriptCompiled(Ok(cs)) => Ok(cs),
2682
2768
  VmReply::ScriptCompiled(Err(e)) => Err(vm_err(ruby, e)),
2683
2769
  _ => Err(Error::new(
2684
- ruby.exception_runtime_error(),
2770
+ err_class(ruby, "InternalError"),
2685
2771
  "internal: unexpected compile reply",
2686
2772
  )),
2687
2773
  }
@@ -2794,7 +2880,7 @@ impl Core {
2794
2880
  // and SEGV — refuse it, leaving the isolate usable.
2795
2881
  if self.depth.load(Ordering::SeqCst) != 0 {
2796
2882
  return Err(Error::new(
2797
- ruby.exception_runtime_error(),
2883
+ err_class(ruby, "Error"),
2798
2884
  "RustyRacer: cannot dispose an isolate from within a running op or host callback",
2799
2885
  ));
2800
2886
  }
@@ -2915,7 +3001,7 @@ impl Context {
2915
3001
  // id 0's lifetime is the isolate's; extras also track their own dispose.
2916
3002
  if self.disposed.load(Ordering::SeqCst) || self.core.is_disposed() {
2917
3003
  return Err(Error::new(
2918
- ruby.exception_runtime_error(),
3004
+ err_class(ruby, "DisposedError"),
2919
3005
  "disposed context",
2920
3006
  ));
2921
3007
  }
@@ -3112,7 +3198,7 @@ impl JsModule {
3112
3198
  // iso.dispose is a use-after-free.
3113
3199
  if self.disposed.load(Ordering::SeqCst) || self.core.is_disposed() {
3114
3200
  return Err(Error::new(
3115
- ruby.exception_runtime_error(),
3201
+ err_class(ruby, "DisposedError"),
3116
3202
  "disposed module",
3117
3203
  ));
3118
3204
  }
@@ -3140,6 +3226,19 @@ impl JsModule {
3140
3226
  rb_self.check_live(ruby)?;
3141
3227
  rb_self.core.module_status(ruby, rb_self.module_id)
3142
3228
  }
3229
+ // True when top-level await appears anywhere in the module's LINKED import
3230
+ // graph (v8::Module::IsGraphAsync) — a dependency's await counts, which is
3231
+ // why no check on the source text alone can stand in for this. FALSE is the
3232
+ // load-bearing answer: V8 guarantees the evaluation promise is settled, so
3233
+ // #evaluate returning means the whole graph ran. True and it may have
3234
+ // returned with the body still suspended, while #status reads :evaluated
3235
+ // regardless (V8 folds its internal kEvaluatingAsync into kEvaluated).
3236
+ // Raises RustyRacer::RuntimeError unless the module is instantiated: before
3237
+ // linking there is no graph to walk.
3238
+ fn graph_async(ruby: &Ruby, rb_self: &Self) -> Result<Value, Error> {
3239
+ rb_self.check_live(ruby)?;
3240
+ rb_self.core.module_graph_async(ruby, rb_self.module_id)
3241
+ }
3143
3242
  // The bytecode cache produced at compile (produce_cache: true), as a binary
3144
3243
  // String, or nil. Persist it cross-process and pass back via cached_data:.
3145
3244
  fn cached_data(ruby: &Ruby, rb_self: &Self) -> Value {
@@ -3176,7 +3275,7 @@ impl Script {
3176
3275
  // Also refuse once the isolate is disposed (see JsModule::check_live).
3177
3276
  if self.disposed.load(Ordering::SeqCst) || self.core.is_disposed() {
3178
3277
  return Err(Error::new(
3179
- ruby.exception_runtime_error(),
3278
+ err_class(ruby, "DisposedError"),
3180
3279
  "disposed script",
3181
3280
  ));
3182
3281
  }
@@ -3231,7 +3330,7 @@ fn code_cache_from_reply(ruby: &Ruby, reply: VmReply) -> Result<Option<Vec<u8>>,
3231
3330
  VmReply::CodeCache(Ok(bytes)) => Ok(bytes),
3232
3331
  VmReply::CodeCache(Err(e)) => Err(vm_err(ruby, e)),
3233
3332
  _ => Err(Error::new(
3234
- ruby.exception_runtime_error(),
3333
+ err_class(ruby, "InternalError"),
3235
3334
  "internal: unexpected code-cache reply",
3236
3335
  )),
3237
3336
  }
@@ -3367,7 +3466,7 @@ fn resolve_module_via_ruby(
3367
3466
  })?;
3368
3467
  if !std::ptr::eq(Arc::as_ptr(&obj.core), core as *const Core) {
3369
3468
  return Err(Error::new(
3370
- ruby.exception_runtime_error(),
3469
+ err_class(&ruby, "Error"),
3371
3470
  "module resolver returned a Module from a different Context",
3372
3471
  ));
3373
3472
  }
@@ -3444,6 +3543,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
3444
3543
  jsmodule.define_method("evaluate", method!(JsModule::evaluate, 0))?;
3445
3544
  jsmodule.define_method("namespace", method!(JsModule::namespace, 0))?;
3446
3545
  jsmodule.define_method("_status", method!(JsModule::status, 0))?;
3546
+ jsmodule.define_method("graph_async?", method!(JsModule::graph_async, 0))?;
3447
3547
  jsmodule.define_method("cached_data", method!(JsModule::cached_data, 0))?;
3448
3548
  jsmodule.define_method("cache_rejected?", method!(JsModule::cache_rejected, 0))?;
3449
3549
  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),
@@ -104,6 +127,11 @@ pub(crate) enum Request {
104
127
  ModuleStatus {
105
128
  module_id: i32,
106
129
  },
130
+ // Whether top-level await appears anywhere in the module's linked import
131
+ // graph (v8::Module::IsGraphAsync), as a bool.
132
+ ModuleGraphAsync {
133
+ module_id: i32,
134
+ },
107
135
  DisposeModule {
108
136
  module_id: i32,
109
137
  },
@@ -224,7 +252,7 @@ pub(crate) fn run_source(
224
252
  }
225
253
  };
226
254
  match script.run(tc) {
227
- Some(value) => Ok(js_to_jsval(tc, value)),
255
+ Some(value) => marshalled!(tc, value),
228
256
  None if tc.has_terminated() => Err(VmError::Terminated),
229
257
  None => {
230
258
  let exc = tc.exception();
@@ -279,7 +307,7 @@ fn call_function(
279
307
  match func.call(tc, recv, &argv) {
280
308
  // void: skip marshalling the return so a huge/cyclic result is never walked.
281
309
  Some(_) if void => Ok(JsVal::Undefined),
282
- Some(value) => Ok(js_to_jsval(tc, value)),
310
+ Some(value) => marshalled!(tc, value),
283
311
  None if tc.has_terminated() => Err(VmError::Terminated),
284
312
  None => {
285
313
  let exc = tc.exception();
@@ -396,6 +424,7 @@ fn request_realm(state: &IsolateState, request: &Request) -> Option<i32> {
396
424
  | Request::CreateContext
397
425
  | Request::DisposeContext { .. }
398
426
  | Request::ModuleStatus { .. }
427
+ | Request::ModuleGraphAsync { .. }
399
428
  | Request::DisposeModule { .. }
400
429
  | Request::DisposeScript { .. }
401
430
  | Request::ScriptCodeCache { .. }
@@ -465,6 +494,7 @@ fn dispatch_one(
465
494
  } => op_evaluate_module(scope, module_id, timeout_ms, outermost),
466
495
  Request::ModuleNamespace { module_id } => op_module_namespace(scope, module_id),
467
496
  Request::ModuleStatus { module_id } => op_module_status(scope, module_id),
497
+ Request::ModuleGraphAsync { module_id } => op_module_graph_async(scope, module_id),
468
498
  Request::DisposeModule { module_id } => op_dispose_module(scope, module_id),
469
499
  Request::CompileScript {
470
500
  context_id,
@@ -1065,6 +1095,20 @@ fn op_evaluate_module(
1065
1095
  VmReply::Done(outcome)
1066
1096
  }
1067
1097
 
1098
+ // get_module_namespace and is_graph_async both CHECK-abort (a release check, so
1099
+ // it kills the process) below Instantiated, so both ask this first. Everything
1100
+ // from Instantiated up is admissible, Errored included: a module that fails to
1101
+ // LINK is reset to Uninstantiated by V8's ResetGraph, so Errored can only mean
1102
+ // "linked, then evaluation failed" — the graph is still there to read.
1103
+ fn require_instantiated(module: v8::Local<v8::Module>, what: &str) -> Result<(), VmError> {
1104
+ match module.get_status() {
1105
+ v8::ModuleStatus::Uninstantiated | v8::ModuleStatus::Instantiating => Err(
1106
+ VmError::Runtime(format!("module must be instantiated before {what}")),
1107
+ ),
1108
+ _ => Ok(()),
1109
+ }
1110
+ }
1111
+
1068
1112
  fn op_module_namespace(scope: &mut v8::PinScope<'_, '_, ()>, module_id: i32) -> VmReply {
1069
1113
  let handle = module_handle(istate!(scope), module_id);
1070
1114
  let outcome = match handle {
@@ -1075,15 +1119,27 @@ fn op_module_namespace(scope: &mut v8::PinScope<'_, '_, ()>, module_id: i32) ->
1075
1119
  let context = v8::Local::new(scope, &cx);
1076
1120
  let scope = &mut v8::ContextScope::new(scope, context);
1077
1121
  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))
1122
+ // An errored module's namespace is unreadable — its bindings
1123
+ // never initialized — and the useful answer is WHY it errored,
1124
+ // the same one instantiate and evaluate give.
1125
+ if module.get_status() == v8::ModuleStatus::Errored {
1126
+ Err(VmError::JsError {
1127
+ message: module.get_exception().to_rust_string_lossy(scope),
1128
+ backtrace: vec![],
1129
+ })
1130
+ } else {
1131
+ match require_instantiated(module, "namespace") {
1132
+ Err(e) => Err(e),
1133
+ Ok(()) => {
1134
+ let ns = module.get_module_namespace();
1135
+ // Reading the namespace RUNS JS: every export is an
1136
+ // accessor, and one whose binding is still
1137
+ // uninitialized throws. Unwatched, that throw made
1138
+ // enumeration fail and the caller silently got the
1139
+ // stringified object in place of their exports.
1140
+ v8::tc_scope!(let tc, scope);
1141
+ marshalled!(tc, ns)
1142
+ }
1087
1143
  }
1088
1144
  }
1089
1145
  }
@@ -1112,6 +1168,26 @@ fn op_module_status(scope: &mut v8::PinScope<'_, '_, ()>, module_id: i32) -> VmR
1112
1168
  VmReply::Done(outcome)
1113
1169
  }
1114
1170
 
1171
+ // Does top-level await appear anywhere in this module's import graph? V8 walks
1172
+ // the linked graph from here, so an `await` in a dependency counts too — which
1173
+ // is why no amount of looking at one source text can answer it, and why it needs
1174
+ // an instantiated module: before linking there is no graph to walk. What V8
1175
+ // guarantees is the false case — "if IsGraphAsync() is false, the returned
1176
+ // Promise is settled" — i.e. false means #evaluate ran the whole graph to
1177
+ // completion. No context is entered: the walk reads module slots, runs no JS.
1178
+ fn op_module_graph_async(scope: &mut v8::PinScope<'_, '_, ()>, module_id: i32) -> VmReply {
1179
+ let handle = module_handle(istate!(scope), module_id);
1180
+ let outcome = match handle {
1181
+ None => Err(VmError::Runtime("unknown module".into())),
1182
+ Some((g, _cid)) => {
1183
+ let module = v8::Local::new(scope, &g);
1184
+ require_instantiated(module, "graph_async?")
1185
+ .map(|()| JsVal::Bool(module.is_graph_async()))
1186
+ }
1187
+ };
1188
+ VmReply::Done(outcome)
1189
+ }
1190
+
1115
1191
  fn op_dispose_module(scope: &mut v8::PinScope<'_, '_, ()>, module_id: i32) -> VmReply {
1116
1192
  let m = &mut istate!(scope).modules;
1117
1193
  m.by_id.remove(&module_id);
@@ -1226,7 +1302,7 @@ fn op_run_script(
1226
1302
  let out = {
1227
1303
  v8::tc_scope!(let tc, scope);
1228
1304
  match script.run(tc) {
1229
- Some(value) => Ok(js_to_jsval(tc, value)),
1305
+ Some(value) => marshalled!(tc, value),
1230
1306
  None if tc.has_terminated() => Err(VmError::Terminated),
1231
1307
  None => {
1232
1308
  let exc = tc.exception();
@@ -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.0"
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
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.0
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