rusty_racer 0.2.4 → 0.2.6

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.
@@ -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;
@@ -93,8 +93,8 @@ use watchdog::{
93
93
  // SAFETY of the manual Send: the !Send contents (and BoxValue's drop, which
94
94
  // calls rb_gc_unregister_address) only run under the GVL. Two conventions
95
95
  // keep that true — breaking either is silent UB, so don't:
96
- // - Arc<Core> lives ONLY in the four TypedData wrappers (Isolate, Context,
97
- // Module, Script). Never clone it into the V8/watchdog threads, or the
96
+ // - Arc<Core> lives ONLY in the five TypedData wrappers (Isolate, Context,
97
+ // Module, Script, Function). Never clone it into the V8/watchdog threads, or the
98
98
  // last drop could run off a Ruby thread.
99
99
  // - the wrappers must not set free_immediately: their dfree (and so Core's
100
100
  // drop) has to run outside the GC sweep, where Ruby APIs are forbidden.
@@ -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,16 +278,23 @@ 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
296
+ // A handle whose realm was reset or disposed under it -> RustyRacer::DisposedError
297
+ Disposed(String),
234
298
  }
235
299
 
236
300
  // V8's near-heap-limit callback (registered on EVERY isolate). V8 calls this,
@@ -330,9 +394,9 @@ where
330
394
  // instead of saying "a host function". Rendered only on that path, so an
331
395
  // ordinary callback pays for a pointer and nothing else.
332
396
  enum RubyCall<'a> {
333
- // An attach()'d host function, by host_fn_id — the name it was attached
397
+ // An attach()'d host function, by its key — the name it was attached
334
398
  // under lives in the isolate's proc table.
335
- HostFn(usize),
399
+ HostFn(HostFnKey),
336
400
  // A module resolver (a static instantiate block, a dynamic auto-link, or
337
401
  // import()), named by the specifier it was handed.
338
402
  Resolver(&'a str),
@@ -341,15 +405,17 @@ enum RubyCall<'a> {
341
405
  impl RubyCall<'_> {
342
406
  fn describe(&self, core: &Core) -> String {
343
407
  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
- {
408
+ // try_lock, not lock: nothing holds this across a callback (`call_proc`
409
+ // holds it for one comparison, before the proc runs), but blocking here
410
+ // would turn a diagnostic into a hang. A slot handed on since names
411
+ // nobody: its name is another function's.
412
+ Self::HostFn(id) => match core.procs.try_lock().ok().and_then(|procs| {
413
+ procs
414
+ .slots
415
+ .get(id.index())
416
+ .filter(|slot| slot.generation == id.generation)
417
+ .map(|slot| slot.name.clone())
418
+ }) {
353
419
  Some(name) => format!("The host function `{name}`"),
354
420
  None => "A host function".to_string(),
355
421
  },
@@ -546,8 +612,8 @@ fn host_fn_callback(
546
612
  args: v8::FunctionCallbackArguments<'_>,
547
613
  mut rv: v8::ReturnValue<'_, v8::Value>,
548
614
  ) {
549
- let host_fn_id = match v8::Local::<v8::External>::try_from(args.data()) {
550
- Ok(e) => e.value() as usize,
615
+ let host_fn = match v8::Local::<v8::External>::try_from(args.data()) {
616
+ Ok(e) => HostFnKey::from_external(e.value()),
551
617
  Err(_) => return,
552
618
  };
553
619
  let mut js_args = Vec::with_capacity(args.length() as usize);
@@ -574,20 +640,22 @@ fn host_fn_callback(
574
640
  return;
575
641
  }
576
642
  let core = unsafe { &*core_ptr };
577
- let result: Result<JsVal, String> = with_gvl(core, RubyCall::HostFn(host_fn_id), || {
643
+ let result: Result<JsVal, HostFnFailure> = with_gvl(core, RubyCall::HostFn(host_fn), || {
578
644
  let ruby = Ruby::get().unwrap();
579
645
  // rb_protect so a Ruby exception raised during arg/return marshalling
580
646
  // (ary_new_capa / jsval_to_ruby / ruby_to_jsval can raise on OOM etc.)
581
647
  // is CAUGHT here instead of longjmp-ing through V8's C++ frames. The
582
648
  // proc's own raise is already a magnus Err; this covers the rest.
583
649
  use magnus::rb_sys::AsRawValue;
584
- let mut out: Option<Result<JsVal, String>> = None;
650
+ let mut out: Option<Result<JsVal, HostFnFailure>> = None;
585
651
  match magnus::rb_sys::protect(|| {
586
- out = Some(core.call_proc(&ruby, host_fn_id, &js_args));
652
+ out = Some(core.call_proc(&ruby, host_fn, &js_args));
587
653
  ruby.qnil().as_raw()
588
654
  }) {
589
- Ok(_) => out.unwrap_or_else(|| Err("host function did not complete".into())),
590
- Err(e) => Err(format!("{e}")),
655
+ Ok(_) => {
656
+ out.unwrap_or_else(|| Err("host function did not complete".to_string().into()))
657
+ }
658
+ Err(e) => Err(format!("{e}").into()),
591
659
  }
592
660
  });
593
661
  match result {
@@ -595,8 +663,30 @@ fn host_fn_callback(
595
663
  let v = jsval_to_js(scope, val);
596
664
  rv.set(v);
597
665
  }
598
- // The proc raised (or marshalling failed): surface as a JS exception.
599
- Err(message) => throw_js_error(scope, &message),
666
+ // The proc raised (or marshalling failed): surface as a JS exception —
667
+ // the value JS threw, where the error is one a JS throw became.
668
+ Err(failure) => match failure.thrown.and_then(|serial| take_thrown(scope, serial)) {
669
+ Some(thrown) => {
670
+ scope.throw_exception(thrown);
671
+ }
672
+ None => throw_js_error(scope, &failure.message),
673
+ },
674
+ }
675
+ }
676
+
677
+ // How a host fn failed: the Ruby error's text, and the serial of the JS value
678
+ // it carries when a JS throw is what it became (`keep_thrown`).
679
+ struct HostFnFailure {
680
+ message: String,
681
+ thrown: Option<u64>,
682
+ }
683
+
684
+ impl From<String> for HostFnFailure {
685
+ fn from(message: String) -> Self {
686
+ HostFnFailure {
687
+ message,
688
+ thrown: None,
689
+ }
600
690
  }
601
691
  }
602
692
 
@@ -696,12 +786,93 @@ fn capture_js_error(
696
786
  let backtrace = stack_str
697
787
  .map(|s| parse_js_stack(s.as_str()))
698
788
  .unwrap_or_default();
699
- VmError::JsError { message, backtrace }
789
+ let thrown = keep_thrown(scope, exception);
790
+ VmError::JsError {
791
+ message,
792
+ backtrace,
793
+ thrown,
794
+ }
795
+ }
796
+
797
+ // A value JS failed with that no TryCatch caught — an errored module's
798
+ // exception, a rejected promise's reason — as a JsError: its text, no stack
799
+ // frames, and the value kept as a thrown one is.
800
+ fn rejected_with(scope: &mut v8::PinScope<'_, '_>, value: v8::Local<v8::Value>) -> VmError {
801
+ VmError::JsError {
802
+ message: value.to_rust_string_lossy(scope),
803
+ backtrace: vec![],
804
+ thrown: keep_thrown(scope, Some(value)),
805
+ }
806
+ }
807
+
808
+ // A compile failure as a ParseError: V8's message, the location it recorded
809
+ // (always the file, the line when it has one), and the SyntaxError kept as a
810
+ // thrown value is.
811
+ fn parse_error(
812
+ scope: &mut v8::PinScope<'_, '_>,
813
+ exception: Option<v8::Local<v8::Value>>,
814
+ message: Option<v8::Local<v8::Message>>,
815
+ filename: &str,
816
+ fallback: &str,
817
+ ) -> VmError {
818
+ let msg = exception
819
+ .map(|e| e.to_rust_string_lossy(scope))
820
+ .unwrap_or_else(|| fallback.to_string());
821
+ let res = message
822
+ .and_then(|m| m.get_script_resource_name(scope))
823
+ .filter(|v| v.is_string())
824
+ .map(|v| v.to_rust_string_lossy(scope))
825
+ .unwrap_or_else(|| filename.to_string());
826
+ let loc = match message.and_then(|m| m.get_line_number(scope)) {
827
+ Some(line) => format!(" at {res}:{line}"),
828
+ None => format!(" at {res}"),
829
+ };
830
+ VmError::Parse(format!("{msg}{loc}"), keep_thrown(scope, exception))
831
+ }
832
+
833
+ // Keep the value JS threw, so a host fn that lets the Ruby error it became
834
+ // propagate throws THAT value on — its identity, class and stack — rather than a
835
+ // fresh Error made of its text: a script a host fn ran for JS fails in JS as the
836
+ // script did. One slot per isolate, since only the latest thrown value can still
837
+ // be on its way back out, and only until the outermost op returns (`Core::run`),
838
+ // since only a host fn inside it can still ask — kept longer, it would pin the
839
+ // value, and its realm, for as long as nothing else threw. The serial (carried
840
+ // by the Ruby exception, see `carrying_thrown`) tells it from a stale one, and is
841
+ // drawn process-wide, so an error another isolate's op raised never matches this
842
+ // one's slot: its value cannot cross isolates. None on an isolate with no state
843
+ // (the snapshot creator's), where no host fn can ask.
844
+ fn keep_thrown(
845
+ scope: &mut v8::PinScope<'_, '_>,
846
+ exception: Option<v8::Local<v8::Value>>,
847
+ ) -> Option<u64> {
848
+ static SERIAL: AtomicU64 = AtomicU64::new(0);
849
+ let exception = exception?;
850
+ let kept = v8::Global::new(scope, exception);
851
+ let state = scope.get_slot_mut::<IsolateState>()?;
852
+ let serial = SERIAL.fetch_add(1, Ordering::Relaxed) + 1;
853
+ state.thrown = Some((serial, kept));
854
+ Some(serial)
855
+ }
856
+
857
+ // The Ruby exception's kept thrown value (`keep_thrown`), when it still is the
858
+ // latest: taken, as it is thrown once.
859
+ fn take_thrown<'s>(
860
+ scope: &mut v8::PinScope<'s, '_>,
861
+ serial: u64,
862
+ ) -> Option<v8::Local<'s, v8::Value>> {
863
+ let state = scope.get_slot_mut::<IsolateState>()?;
864
+ let (kept_serial, _) = state.thrown.as_ref()?;
865
+ if *kept_serial != serial {
866
+ return None;
867
+ }
868
+ let (_, kept) = state.thrown.take()?;
869
+ Some(v8::Local::new(scope, kept))
700
870
  }
701
871
 
702
872
  // ---------------------------------------------------------------------------
703
873
  // ES modules: V8's raw compile/instantiate/evaluate steps, with the embedder
704
- // owning the url->Module registry (MODULES) and the resolve policy.
874
+ // owning the url->Module registry and the resolve policy (ModuleReg is only the
875
+ // binding's id and url bookkeeping).
705
876
  // ---------------------------------------------------------------------------
706
877
  fn module_origin<'s>(scope: &v8::PinScope<'s, '_>, url: &str) -> v8::ScriptOrigin<'s> {
707
878
  let name = v8::String::new(scope, url).unwrap();
@@ -720,18 +891,47 @@ fn module_origin<'s>(scope: &v8::PinScope<'s, '_>, url: &str) -> v8::ScriptOrigi
720
891
  )
721
892
  }
722
893
 
723
- // Registry for the thin compile_module/instantiate API: each compiled module is
724
- // addressed by an id, with its url kept for the resolve round-trip and a
725
- // hash bucket to map a referrer Local<Module> back to its id.
894
+ // Registry for the thin compile_module/instantiate API.
726
895
  #[derive(Default)]
727
896
  struct ModuleReg {
728
- // id -> (module, url, owning context id). The context id is needed because
729
- // a module is bound to the v8::Context it was compiled in.
730
- by_id: HashMap<i32, (v8::Global<v8::Module>, String, i32)>,
731
- by_hash: HashMap<i32, Vec<(v8::Global<v8::Module>, i32)>>,
897
+ // id -> (module, owning context id): what a Module wrapper's ops address.
898
+ // The context id is needed because a module is bound to the v8::Context it
899
+ // was compiled in. Strong, and gone once the wrapper is disposed or collected
900
+ // (Core.released).
901
+ by_id: HashMap<i32, (v8::Global<v8::Module>, i32)>,
902
+ // identity hash -> (module, url): a Local<Module> V8 hands a callback
903
+ // (import.meta, a referrer being linked) back to the url it was compiled
904
+ // with. WEAK, so a module V8 still keeps — a dependency linked into a live
905
+ // graph, a function of a disposed realm's module another realm holds —
906
+ // answers whether its wrapper (or its realm) is still around or not, and
907
+ // one V8 collected is pruned instead of pinned (`add_url`).
908
+ urls: HashMap<i32, Vec<(v8::Weak<v8::Module>, String)>>,
909
+ // `add_url`'s amortization: entries added since the last prune, and the
910
+ // entries that prune left.
911
+ added_since_prune: usize,
912
+ kept_at_prune: usize,
732
913
  next_id: i32,
733
914
  }
734
915
 
916
+ impl ModuleReg {
917
+ // Index |module|'s url, first pruning the entries of collected modules once
918
+ // as many have been added since the last prune as it left — a scan
919
+ // amortized O(1) per compile, keeping the index within about twice the
920
+ // modules alive.
921
+ fn add_url(&mut self, hash: i32, module: v8::Weak<v8::Module>, url: String) {
922
+ if self.added_since_prune > self.kept_at_prune {
923
+ self.urls.retain(|_, bucket| {
924
+ bucket.retain(|(module, _)| !module.is_empty());
925
+ !bucket.is_empty()
926
+ });
927
+ self.kept_at_prune = self.urls.values().map(Vec::len).sum();
928
+ self.added_since_prune = 0;
929
+ }
930
+ self.added_since_prune += 1;
931
+ self.urls.entry(hash).or_default().push((module, url));
932
+ }
933
+ }
934
+
735
935
  // Classic compiled scripts: id -> (unbound script, owning context id). An
736
936
  // UnboundScript is context-independent, but we run it in the context it was
737
937
  // compiled in (and reset/dispose of that context drops it).
@@ -741,6 +941,39 @@ struct ScriptReg {
741
941
  next_id: i32,
742
942
  }
743
943
 
944
+ // Context#function's handles: id -> the function its path resolved to, the
945
+ // holder it was read off (its `this`), and the realm it was resolved in. The
946
+ // Globals live HERE, not in the Ruby wrapper, so a reset or disposed realm drops
947
+ // them (drop_context_artifacts) whatever wrappers Ruby still holds — a handle
948
+ // must not keep its realm's v8::Context alive — and so they are only ever
949
+ // dropped on the owner thread with the isolate entered, which a wrapper's GC
950
+ // free can't promise. A wrapper that is disposed or collected queues its id on
951
+ // Core.released instead, and the next outermost request boundary removes it
952
+ // here. Ids are never reused, so a stale id can only miss.
953
+ //
954
+ // A path may lead into ANOTHER realm (`frames[0].fn`): the entry belongs to the
955
+ // realm it was resolved in, so it holds that other realm's function until this
956
+ // one is reset or the handle is released — as any reference this realm keeps
957
+ // to it would.
958
+ #[derive(Default)]
959
+ struct FunctionReg {
960
+ by_id: HashMap<u64, FunctionEntry>,
961
+ next_id: u64,
962
+ }
963
+
964
+ struct FunctionEntry {
965
+ function: v8::Global<v8::Function>,
966
+ receiver: v8::Global<v8::Value>,
967
+ context_id: i32,
968
+ }
969
+
970
+ // A handle whose Ruby wrapper let go of it, queued on Core.released.
971
+ enum Released {
972
+ Function(u64),
973
+ Module(i32),
974
+ Script(i32),
975
+ }
976
+
744
977
  // The V8 thread's realm registry: the main context (id 0, swappable by reset),
745
978
  // the extra realms from create_context, and the host-namespace name to
746
979
  // re-install on fresh realms. Thread-local (like MODULES/SCRIPTS) so
@@ -825,6 +1058,7 @@ struct IsolateState {
825
1058
  realms: V8State,
826
1059
  modules: ModuleReg,
827
1060
  scripts: ScriptReg,
1061
+ functions: FunctionReg,
828
1062
  active_realms: Vec<i32>,
829
1063
  instantiating: bool,
830
1064
  watchdog_fired: bool,
@@ -864,6 +1098,8 @@ struct IsolateState {
864
1098
  // the ScriptTerminatedError can name what was running. Taken (cleared) when the
865
1099
  // terminated op's error is built; None for any non-timeout outcome.
866
1100
  timeout_backtrace: Option<Vec<String>>,
1101
+ // The latest value JS threw out of an op, with its serial (`keep_thrown`).
1102
+ thrown: Option<(u64, v8::Global<v8::Value>)>,
867
1103
  }
868
1104
 
869
1105
  impl IsolateState {
@@ -876,6 +1112,7 @@ impl IsolateState {
876
1112
  },
877
1113
  modules: ModuleReg::default(),
878
1114
  scripts: ScriptReg::default(),
1115
+ functions: FunctionReg::default(),
879
1116
  active_realms: Vec::new(),
880
1117
  instantiating: false,
881
1118
  watchdog_fired: false,
@@ -888,6 +1125,7 @@ impl IsolateState {
888
1125
  oom_fired: false,
889
1126
  oom_initial_limit: 0,
890
1127
  timeout_backtrace: None,
1128
+ thrown: None,
891
1129
  }
892
1130
  }
893
1131
  }
@@ -973,7 +1211,7 @@ fn transfer_registry() -> &'static Mutex<HashMap<u64, SendBackingStore>> {
973
1211
  // Monotonic token source. Starts at 1 so 0 is free as the JS-side "not
974
1212
  // transferable — fall back to a copy" sentinel. A token is f64-exact for ~2^53
975
1213
  // transfers, which a process will never reach.
976
- static NEXT_TRANSFER_TOKEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
1214
+ static NEXT_TRANSFER_TOKEN: AtomicU64 = AtomicU64::new(1);
977
1215
 
978
1216
  // Drain EVERY realm's microtask queue once. Each realm has its own queue (see
979
1217
  // V8State::queues), so a single scope.perform_microtask_checkpoint() — which only
@@ -1033,23 +1271,19 @@ fn auto_drain(scope: &mut v8::PinScope<'_, '_>, outermost: bool) {
1033
1271
  }
1034
1272
  }
1035
1273
 
1036
- // Drop every module AND script compiled in `context_id` (its v8::Context is
1037
- // going away — on reset or dispose — so those handles are now dead).
1274
+ // Drop every module, script and function handle of `context_id` (its
1275
+ // v8::Context is going away — on reset or dispose — so those handles are now
1276
+ // dead, and a function's Globals would otherwise keep that context alive).
1038
1277
  fn drop_context_artifacts(state: &mut IsolateState, context_id: i32) {
1039
1278
  let m = &mut state.modules;
1040
- let dead: Vec<i32> = m
1041
- .by_id
1042
- .iter()
1043
- .filter(|(_, (_, _, cid))| *cid == context_id)
1044
- .map(|(id, _)| *id)
1045
- .collect();
1046
- for id in dead {
1047
- m.by_id.remove(&id);
1048
- for bucket in m.by_hash.values_mut() {
1049
- bucket.retain(|(_, mid)| *mid != id);
1050
- }
1051
- }
1279
+ // (Not its url index: weak, it pins nothing, and a module of this realm that
1280
+ // another realm still reaches keeps its import.meta.url.)
1281
+ m.by_id.retain(|_, (_, cid)| *cid != context_id);
1052
1282
  state.scripts.by_id.retain(|_, (_, cid)| *cid != context_id);
1283
+ state
1284
+ .functions
1285
+ .by_id
1286
+ .retain(|_, entry| entry.context_id != context_id);
1053
1287
  // A promise-reject recorder created in this context is unusable now.
1054
1288
  if state
1055
1289
  .realms
@@ -1073,24 +1307,21 @@ fn script_handle(
1073
1307
  .map(|(g, cid)| (g.clone(), *cid))
1074
1308
  }
1075
1309
 
1076
- // A registered module's url (the filename it was compiled with), by identity
1077
- // against MODULES — the reverse of the id lookup. Used to give a referrer its
1078
- // url for the resolve round-trip and to fill import.meta.url.
1310
+ // A compiled module's url (the filename it was compiled with), by identity in
1311
+ // the weak url index (ModuleReg.urls) — for as long as V8 keeps the module. Used
1312
+ // to give a referrer its url for the resolve round-trip and to fill
1313
+ // import.meta.url.
1079
1314
  fn module_url(scope: &mut v8::PinScope<'_, '_>, module: v8::Local<v8::Module>) -> Option<String> {
1080
1315
  let hash = module.get_identity_hash().get();
1081
- // Snapshot the hash bucket (cloned globals) so the scope is free for the
1082
- // Local comparison below: istate! borrows the scope and v8::Local::new
1083
- // needs it too, so they can't overlap in one expression.
1084
- let bucket = istate!(scope).modules.by_hash.get(&hash)?.clone();
1085
- let id = bucket
1086
- .iter()
1087
- .find(|(g, _)| v8::Local::new(&*scope, g) == module)
1088
- .map(|(_, id)| *id)?;
1089
- istate!(scope)
1316
+ // A shared borrow of the slot beside the shared one to_local takes.
1317
+ scope
1318
+ .get_slot::<IsolateState>()?
1090
1319
  .modules
1091
- .by_id
1092
- .get(&id)
1093
- .map(|(_, u, _)| u.clone())
1320
+ .urls
1321
+ .get(&hash)?
1322
+ .iter()
1323
+ .find(|(weak, _)| weak.to_local(scope) == Some(module))
1324
+ .map(|(_, url)| url.clone())
1094
1325
  }
1095
1326
 
1096
1327
  // V8 calls this the first time a module reads import.meta. Fills in
@@ -1214,7 +1445,7 @@ fn resolve_imported<'s>(
1214
1445
  .modules
1215
1446
  .by_id
1216
1447
  .get(&dep_id)
1217
- .map(|(g, _, cid)| (g.clone(), *cid));
1448
+ .map(|(g, cid)| (g.clone(), *cid));
1218
1449
  let g = match (here, found) {
1219
1450
  (Some(here), Some((g, cid))) if cid == here => g,
1220
1451
  (Some(_), Some(_)) => {
@@ -1291,8 +1522,8 @@ fn dynamic_import_cb<'s>(
1291
1522
  .modules
1292
1523
  .by_id
1293
1524
  .get(&id)
1294
- .filter(|(_, _, cid)| Some(*cid) == here)
1295
- .map(|(g, _, _)| g.clone());
1525
+ .filter(|(_, cid)| Some(*cid) == here)
1526
+ .map(|(g, _)| g.clone());
1296
1527
  match g {
1297
1528
  Some(g) => {
1298
1529
  let module = v8::Local::new(scope, &g);
@@ -1473,7 +1704,7 @@ fn module_handle(state: &IsolateState, module_id: i32) -> Option<(v8::Global<v8:
1473
1704
  .modules
1474
1705
  .by_id
1475
1706
  .get(&module_id)
1476
- .map(|(g, _, cid)| (g.clone(), *cid))
1707
+ .map(|(g, cid)| (g.clone(), *cid))
1477
1708
  }
1478
1709
 
1479
1710
  // ---------------------------------------------------------------------------
@@ -1534,11 +1765,19 @@ struct Core {
1534
1765
  // layout. Read on the V8 side without the GVL (the import hooks), which is
1535
1766
  // sound because the entries are pinned and nothing here allocates or raises.
1536
1767
  roots: Opaque<RArray>,
1537
- // host_fn_id indexes ProcTable.slots — bookkeeping only (the proc itself is in
1768
+ // A HostFnKey indexes ProcTable.slots — bookkeeping only (the proc itself is in
1538
1769
  // `roots`). Mutex (uncontended — single owner thread) so host_fn_callback can
1539
1770
  // reach it through Core (via the slot's core_ptr) while a &Core method also
1540
1771
  // holds it. reset/dispose releases the slots and recycles them.
1541
1772
  procs: Mutex<ProcTable>,
1773
+ // The handles (Function, Module, Script) whose Ruby wrappers were disposed or
1774
+ // collected, for the next outermost request boundary to remove from their
1775
+ // registries (`forget_released_handles`). A wrapper's free may run on any
1776
+ // Ruby thread, outside any op, so all it can do is queue its id here.
1777
+ released: Mutex<Vec<Released>>,
1778
+ // Set with each push there, so the request boundary looks at the lock only
1779
+ // when there is something under it.
1780
+ handles_released: AtomicBool,
1542
1781
  // Default per-eval/call timeout (ms); 0 = none. eval(timeout_ms:)'s explicit
1543
1782
  // value overrides it. Guards against an in-V8 infinite loop without a watchdog.
1544
1783
  default_timeout_ms: u64,
@@ -1608,10 +1847,22 @@ struct Script {
1608
1847
  cache_rejected: bool,
1609
1848
  }
1610
1849
 
1611
- // The four wrappers that hold an Arc<Core> all mark the isolate's roots, because any
1850
+ // Context#function result: a JS function resolved once, with its `this`, kept in
1851
+ // FunctionReg by id — the realm it belongs to rides along so a call enters it
1852
+ // without a lookup.
1853
+ #[derive(TypedData)]
1854
+ #[magnus(class = "RustyRacer::Function", mark)]
1855
+ struct JsFunction {
1856
+ core: Arc<Core>,
1857
+ context_id: i32,
1858
+ function_id: u64,
1859
+ disposed: AtomicBool,
1860
+ }
1861
+
1862
+ // The five wrappers that hold an Arc<Core> all mark the isolate's roots, because any
1612
1863
  // one of them may be the last one reachable: Ruby code is free to keep only the
1613
1864
  // Context a `Isolate#context` handed it, or only a Module, and the host fns must
1614
- // stay callable through it. Marking the same array four times costs nothing (the GC
1865
+ // stay callable through it. Marking the same array five times costs nothing (the GC
1615
1866
  // stops at an already-marked object); missing it once would free a live proc.
1616
1867
  macro_rules! marks_core_roots {
1617
1868
  ($t:ty) => {
@@ -1626,6 +1877,7 @@ marks_core_roots!(Isolate);
1626
1877
  marks_core_roots!(Context);
1627
1878
  marks_core_roots!(JsModule);
1628
1879
  marks_core_roots!(Script);
1880
+ marks_core_roots!(JsFunction);
1629
1881
 
1630
1882
  // Set true once V8 is initialized; Platform.set_flags! refuses after that
1631
1883
  // (flags must be set before V8::initialize), like mini_racer's
@@ -2152,12 +2404,12 @@ fn install_host_namespace(
2152
2404
  ];
2153
2405
  for (member, function) in members {
2154
2406
  if let (Some(f), Some(k)) = (function, v8::String::new(scope, member)) {
2155
- ns.set(scope, k.into(), f.into());
2407
+ ns.create_data_property(scope, k.into(), f.into());
2156
2408
  }
2157
2409
  }
2158
2410
  if let Some(key) = v8::String::new(scope, name) {
2159
2411
  let global = context.global(scope);
2160
- global.set(scope, key.into(), ns.into());
2412
+ global.create_data_property(scope, key.into(), ns.into());
2161
2413
  }
2162
2414
  }
2163
2415
 
@@ -2253,11 +2505,11 @@ fn build_snapshot(code: &str, base: Option<Vec<u8>>, warmup: bool) -> Result<Vec
2253
2505
  // leaves behind); nothing reads its completion value, so
2254
2506
  // don't marshal it — a bundle ending in an expression must
2255
2507
  // not fail the snapshot over a value no one wants.
2256
- true,
2508
+ Completion::Void,
2257
2509
  )
2258
2510
  {
2259
2511
  err = Some(match e {
2260
- VmError::Parse(m) | VmError::Runtime(m) => m,
2512
+ VmError::Parse(m, _) | VmError::Runtime(m) | VmError::Disposed(m) => m,
2261
2513
  VmError::JsError { message, .. } => message,
2262
2514
  VmError::Terminated => "snapshot code was terminated".to_string(),
2263
2515
  // Unreachable: the snapshot-creator is a separate isolate
@@ -2394,6 +2646,8 @@ impl Isolate {
2394
2646
  depth: std::sync::atomic::AtomicU32::new(0),
2395
2647
  roots: Opaque::from(roots),
2396
2648
  procs: Mutex::new(ProcTable::default()),
2649
+ released: Mutex::new(Vec::new()),
2650
+ handles_released: AtomicBool::new(false),
2397
2651
  default_timeout_ms: timeout_ms,
2398
2652
  memory_limit,
2399
2653
  watchdog,
@@ -2641,6 +2895,9 @@ impl Core {
2641
2895
  // on an op.
2642
2896
  if reply.is_none() {
2643
2897
  self.shared.lock().unwrap().disposed = true;
2898
+ } else if depth == 0 {
2899
+ // No host fn is left to throw a kept value on (`keep_thrown`).
2900
+ istate!(unsafe { &mut *iso }).thrown = None;
2644
2901
  }
2645
2902
  self.depth.fetch_sub(1, Ordering::SeqCst);
2646
2903
  reply
@@ -2716,7 +2973,7 @@ impl Core {
2716
2973
  }
2717
2974
 
2718
2975
  // Show the GC everything this isolate holds. Called from EVERY wrapper's mark
2719
- // (Isolate, Context, Module, Script all keep an Arc<Core>, and any one of them
2976
+ // (Isolate, Context, Module, Script, Function all keep an Arc<Core>, and any one of them
2720
2977
  // may be the last reachable): a wrapper that forgot to would leave a live
2721
2978
  // isolate's host fns unmarked. `mark` PINS — an Array marks its own elements
2722
2979
  // MOVABLE, and the copies Rust takes out of it (call_proc, root_proc) would go
@@ -2741,15 +2998,24 @@ impl Core {
2741
2998
  }
2742
2999
  }
2743
3000
 
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.
3001
+ fn call_proc(
3002
+ &self,
3003
+ ruby: &Ruby,
3004
+ host_fn: HostFnKey,
3005
+ args: &[JsVal],
3006
+ ) -> Result<JsVal, HostFnFailure> {
3007
+ // A function of a released realm is refused here, before its slot's roots
3008
+ // entry is read: that entry may by now hold another realm's proc. (The lock
3009
+ // is uncontended and held for the comparison alone — nothing here allocates,
3010
+ // so no GC can need it meanwhile.)
3011
+ if !self.procs.lock().unwrap().current(host_fn) {
3012
+ return Err("unknown host function".to_string().into());
3013
+ }
3014
+ // `root_proc` and not `RArray::entry`, for the reason given there: a nil
3015
+ // slot must answer None, never dispatch `to_proc` and raise.
2750
3016
  let proc = self
2751
- .root_proc(proc_root_index(host_fn_id))
2752
- .ok_or("unknown host function")?;
3017
+ .root_proc(proc_root_index(host_fn.index()))
3018
+ .ok_or_else(|| "unknown host function".to_string())?;
2753
3019
  // Marshal into a Ruby Array, NOT a Vec<Value>: bare Values in a heap Vec
2754
3020
  // are hidden from Ruby's GC mark phase (magnus's own RArray::to_vec doc
2755
3021
  // spells this out). With several args, once arg N is parked in the Vec
@@ -2769,8 +3035,11 @@ impl Core {
2769
3035
  // op the proc issues re-enters Core::run (depth > 0) directly — no nested
2770
3036
  // frame bookkeeping is needed any more (the call stack IS the nesting).
2771
3037
  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())
3038
+ let value = result.map_err(|e| HostFnFailure {
3039
+ message: e.to_string(),
3040
+ thrown: thrown_serial_of(&e),
3041
+ })?;
3042
+ ruby_to_jsval(value).map_err(|e| e.to_string().into())
2774
3043
  }
2775
3044
 
2776
3045
  // Context#call (and call_void). Resolves a dotted function path
@@ -2789,18 +3058,47 @@ impl Core {
2789
3058
  "call requires a function name",
2790
3059
  ));
2791
3060
  };
2792
- let name = String::try_convert(*name)?;
2793
- let jsargs: Vec<JsVal> = call_args
2794
- .iter()
2795
- .map(|v| ruby_to_jsval(*v))
2796
- .collect::<Result<_, _>>()?;
2797
-
2798
3061
  let reply = self.run(
2799
3062
  ruby,
2800
3063
  Request::Call {
2801
3064
  context_id,
2802
- name,
2803
- args: jsargs,
3065
+ name: String::try_convert(*name)?,
3066
+ args: js_args(call_args)?,
3067
+ void,
3068
+ timeout_ms: self.default_timeout_ms,
3069
+ },
3070
+ )?;
3071
+ self.reply_value(ruby, reply)
3072
+ }
3073
+
3074
+ // Context#function: resolve |path| in the realm once; the handle's id.
3075
+ fn resolve_function(&self, ruby: &Ruby, context_id: i32, path: String) -> Result<u64, Error> {
3076
+ let reply = self.run(
3077
+ ruby,
3078
+ Request::ResolveFunction {
3079
+ context_id,
3080
+ path,
3081
+ timeout_ms: self.default_timeout_ms,
3082
+ },
3083
+ )?;
3084
+ u64::try_convert(self.reply_value(ruby, reply)?)
3085
+ }
3086
+
3087
+ // Function#call (and call_void): Context#call's invocation, of a resolved handle.
3088
+ fn call_function(
3089
+ &self,
3090
+ ruby: &Ruby,
3091
+ context_id: i32,
3092
+ function_id: u64,
3093
+ args: &[Value],
3094
+ void: bool,
3095
+ ) -> Result<Value, Error> {
3096
+ let reply = self.run(
3097
+ ruby,
3098
+ Request::CallFunction {
3099
+ context_id,
3100
+ function_id,
3101
+ args: js_args(args)?,
2804
3102
  void,
2805
3103
  timeout_ms: self.default_timeout_ms,
2806
3104
  },
@@ -2808,6 +3106,13 @@ impl Core {
2808
3106
  self.reply_value(ruby, reply)
2809
3107
  }
2810
3108
 
3109
+ // A wrapper let go of its handle (see `released`). Takes only this lock,
3110
+ // briefly, and allocates no Ruby object — it runs from a GC free.
3111
+ fn release(&self, handle: Released) {
3112
+ self.released.lock().unwrap().push(handle);
3113
+ self.handles_released.store(true, Ordering::Relaxed);
3114
+ }
3115
+
2811
3116
  fn drain_microtasks(&self, ruby: &Ruby) -> Result<Value, Error> {
2812
3117
  let reply = self.run(
2813
3118
  ruby,
@@ -2880,7 +3185,7 @@ impl Core {
2880
3185
  source: String,
2881
3186
  filename: String,
2882
3187
  timeout_ms: u64,
2883
- void: bool,
3188
+ completion: Completion,
2884
3189
  ) -> Result<Value, Error> {
2885
3190
  let reply = self.run(
2886
3191
  ruby,
@@ -2889,7 +3194,7 @@ impl Core {
2889
3194
  source,
2890
3195
  filename,
2891
3196
  timeout_ms,
2892
- void,
3197
+ completion,
2893
3198
  },
2894
3199
  )?;
2895
3200
  self.reply_value(ruby, reply)
@@ -2906,18 +3211,14 @@ impl Core {
2906
3211
  // array's element storage, which the import hooks read with the GVL released —
2907
3212
  // and a refused attach must not consume a slot or leave its proc rooted either.
2908
3213
  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)?;
3214
+ let host_fn = self.procs.lock().unwrap().alloc(context_id, name.clone());
3215
+ self.root_store(ruby, proc_root_index(host_fn.index()), proc)?;
2915
3216
  let reply = self.run(
2916
3217
  ruby,
2917
3218
  Request::Attach {
2918
3219
  context_id,
2919
3220
  name,
2920
- host_fn_id,
3221
+ host_fn,
2921
3222
  timeout_ms: self.default_timeout_ms,
2922
3223
  },
2923
3224
  )?;
@@ -2926,7 +3227,7 @@ impl Core {
2926
3227
 
2927
3228
  // attach_many: install several host fns in ONE round-trip to the V8 thread
2928
3229
  // (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
3230
+ // are allocated up front so each carries a stable key; entries are
2930
3231
  // applied IN ORDER and a build/attach failure aborts the batch with that
2931
3232
  // (name-tagged) error WITHOUT rolling back earlier entries (see the
2932
3233
  // AttachMany arm). On that error path the unused slots are reclaimed at the
@@ -2941,18 +3242,11 @@ impl Core {
2941
3242
  return Ok(ruby.qnil().as_value()); // nothing to install, skip the round-trip
2942
3243
  }
2943
3244
  self.ensure_owner_and_live(ruby)?; // before the roots writes — see `attach`
2944
- let allocated: Vec<(String, usize, Proc)> = {
3245
+ let allocated: Vec<(String, HostFnKey, Proc)> = {
2945
3246
  let mut procs = self.procs.lock().unwrap();
2946
3247
  entries
2947
3248
  .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
- })
3249
+ .map(|(name, proc)| (name.clone(), procs.alloc(context_id, name), proc))
2956
3250
  .collect()
2957
3251
  };
2958
3252
  // Stored OUTSIDE the table's lock: storing into the array can allocate (the
@@ -2960,16 +3254,16 @@ impl Core {
2960
3254
  // wrappers, a path that must never wait on `procs`. The Procs parked in the
2961
3255
  // Vec meanwhile are invisible to the GC (see call_proc's note), but the caller's
2962
3256
  // `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));
3257
+ let mut named_keys: Vec<(String, HostFnKey)> = Vec::with_capacity(allocated.len());
3258
+ for (name, key, proc) in allocated {
3259
+ self.root_store(ruby, proc_root_index(key.index()), proc)?;
3260
+ named_keys.push((name, key));
2967
3261
  }
2968
3262
  let reply = self.run(
2969
3263
  ruby,
2970
3264
  Request::AttachMany {
2971
3265
  context_id,
2972
- entries: named_ids,
3266
+ entries: named_keys,
2973
3267
  timeout_ms: self.default_timeout_ms,
2974
3268
  },
2975
3269
  )?;
@@ -2977,11 +3271,12 @@ impl Core {
2977
3271
  }
2978
3272
 
2979
3273
  // 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.
3274
+ // realm is gone (reset or disposed), and holding them would keep the embedder's
3275
+ // world alive for the rest of the isolate's life. A V8 function of that realm
3276
+ // may still be reachable (the embedder can hold the old global), but its key
3277
+ // stops answering (`ProcTable::current`), so it never calls a proc again.
3278
+ // Runs on a Ruby thread: it writes nil into the Array. The slots stay: the
3279
+ // keys of other realms are indices into the same Vec.
2985
3280
  fn release_context_procs(&self, ruby: &Ruby, context_id: i32) {
2986
3281
  // Nil the entries BEFORE the ids go back on the free list, so no attach can
2987
3282
  // ever be handed an id whose old proc is still in the array.
@@ -3094,12 +3389,18 @@ impl Core {
3094
3389
  self.reply_value(ruby, reply?)
3095
3390
  }
3096
3391
 
3097
- fn evaluate_module(&self, ruby: &Ruby, module_id: i32) -> Result<Value, Error> {
3392
+ fn evaluate_module(
3393
+ &self,
3394
+ ruby: &Ruby,
3395
+ module_id: i32,
3396
+ report: Option<String>,
3397
+ ) -> Result<Value, Error> {
3098
3398
  let reply = self.run(
3099
3399
  ruby,
3100
3400
  Request::EvaluateModule {
3101
3401
  module_id,
3102
3402
  timeout_ms: self.default_timeout_ms,
3403
+ report,
3103
3404
  },
3104
3405
  )?;
3105
3406
  self.reply_value(ruby, reply)
@@ -3120,11 +3421,6 @@ impl Core {
3120
3421
  self.reply_value(ruby, reply)
3121
3422
  }
3122
3423
 
3123
- fn dispose_module(&self, ruby: &Ruby, module_id: i32) -> Result<(), Error> {
3124
- let reply = self.run(ruby, Request::DisposeModule { module_id })?;
3125
- self.reply_value(ruby, reply).map(|_| ())
3126
- }
3127
-
3128
3424
  // Classic script: compile, run, dispose.
3129
3425
  #[allow(clippy::too_many_arguments)]
3130
3426
  fn compile_script(
@@ -3170,11 +3466,6 @@ impl Core {
3170
3466
  self.reply_value(ruby, reply)
3171
3467
  }
3172
3468
 
3173
- fn dispose_script(&self, ruby: &Ruby, script_id: i32) -> Result<(), Error> {
3174
- let reply = self.run(ruby, Request::DisposeScript { script_id })?;
3175
- self.reply_value(ruby, reply).map(|_| ())
3176
- }
3177
-
3178
3469
  // Serialize a fresh bytecode cache from a compiled handle's current state
3179
3470
  // (Script#/Module#create_code_cache). None = V8 couldn't produce one (or the
3180
3471
  // realm is gone).
@@ -3245,6 +3536,7 @@ impl Core {
3245
3536
  st.realms = V8State::default();
3246
3537
  st.modules = ModuleReg::default();
3247
3538
  st.scripts = ScriptReg::default();
3539
+ st.functions = FunctionReg::default();
3248
3540
  st.instantiate_resolve = None;
3249
3541
  }
3250
3542
  // Unregister the near-heap-limit callback before disposal: dropping the box
@@ -3369,6 +3661,11 @@ fn pending_transfer_count() -> usize {
3369
3661
  transfer_registry().lock().unwrap().len()
3370
3662
  }
3371
3663
 
3664
+ // A call's Ruby arguments, marshalled for JS.
3665
+ fn js_args(args: &[Value]) -> Result<Vec<JsVal>, Error> {
3666
+ args.iter().map(|v| ruby_to_jsval(*v)).collect()
3667
+ }
3668
+
3372
3669
  // Thin magnus-method wrappers.
3373
3670
  // Isolate = the VM and its isolate-level operations; it hands out Contexts.
3374
3671
  impl Isolate {
@@ -3443,14 +3740,14 @@ impl Context {
3443
3740
  Ok(())
3444
3741
  }
3445
3742
  // timeout_ms 0 = use the isolate's default; an explicit value overrides it.
3446
- // |void| discards the completion value instead of marshalling it.
3743
+ // |completion| 0 marshals the completion value, 1 discards it, 2 keeps a primitive alone (`Completion`).
3447
3744
  fn eval(
3448
3745
  ruby: &Ruby,
3449
3746
  rb_self: &Self,
3450
3747
  source: String,
3451
3748
  timeout_ms: u64,
3452
3749
  filename: String,
3453
- void: bool,
3750
+ completion: u8,
3454
3751
  ) -> Result<Value, Error> {
3455
3752
  rb_self.check_live(ruby)?;
3456
3753
  let timeout = if timeout_ms == 0 {
@@ -3458,9 +3755,14 @@ impl Context {
3458
3755
  } else {
3459
3756
  timeout_ms
3460
3757
  };
3758
+ let completion = match completion {
3759
+ 1 => Completion::Void,
3760
+ 2 => Completion::Primitive,
3761
+ _ => Completion::Value,
3762
+ };
3461
3763
  rb_self
3462
3764
  .core
3463
- .eval_t(ruby, rb_self.id, source, filename, timeout, void)
3765
+ .eval_t(ruby, rb_self.id, source, filename, timeout, completion)
3464
3766
  }
3465
3767
  fn call(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result<Value, Error> {
3466
3768
  rb_self.check_live(ruby)?;
@@ -3470,6 +3772,18 @@ impl Context {
3470
3772
  rb_self.check_live(ruby)?;
3471
3773
  rb_self.core.call(ruby, rb_self.id, args, true)
3472
3774
  }
3775
+ // function(path) -> Function: what `call(path, ...)` would call, resolved
3776
+ // now, once — its holder kept as its `this` — so each call skips the path walk.
3777
+ fn function(ruby: &Ruby, rb_self: &Self, path: String) -> Result<JsFunction, Error> {
3778
+ rb_self.check_live(ruby)?;
3779
+ let function_id = rb_self.core.resolve_function(ruby, rb_self.id, path)?;
3780
+ Ok(JsFunction {
3781
+ core: rb_self.core.clone(),
3782
+ context_id: rb_self.id,
3783
+ function_id,
3784
+ disposed: AtomicBool::new(false),
3785
+ })
3786
+ }
3473
3787
  fn attach(ruby: &Ruby, rb_self: &Self, name: String, proc: Proc) -> Result<Value, Error> {
3474
3788
  rb_self.check_live(ruby)?;
3475
3789
  rb_self.core.attach(ruby, rb_self.id, name, proc)
@@ -3649,9 +3963,12 @@ impl JsModule {
3649
3963
  .core
3650
3964
  .instantiate_module(ruby, rb_self.module_id, resolver)
3651
3965
  }
3652
- fn evaluate(ruby: &Ruby, rb_self: &Self) -> Result<Value, Error> {
3966
+ // |report|: nil, or the name of the context global that reports the evaluation's rejection (`Request::EvaluateModule`).
3967
+ fn evaluate(ruby: &Ruby, rb_self: &Self, report: Option<String>) -> Result<Value, Error> {
3653
3968
  rb_self.check_live(ruby)?;
3654
- rb_self.core.evaluate_module(ruby, rb_self.module_id)
3969
+ rb_self
3970
+ .core
3971
+ .evaluate_module(ruby, rb_self.module_id, report)
3655
3972
  }
3656
3973
  fn namespace(ruby: &Ruby, rb_self: &Self) -> Result<Value, Error> {
3657
3974
  rb_self.check_live(ruby)?;
@@ -3695,12 +4012,12 @@ impl JsModule {
3695
4012
  let bytes = rb_self.core.module_code_cache(ruby, rb_self.module_id)?;
3696
4013
  Ok(code_cache_value(ruby, bytes.as_ref()))
3697
4014
  }
3698
- fn dispose(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
3699
- if rb_self.disposed.swap(true, Ordering::SeqCst) {
3700
- return Ok(());
4015
+ // Let go of the module now rather than when this wrapper is collected (see
4016
+ // JsFunction::dispose). Its url still answers while V8 keeps the module.
4017
+ fn dispose(&self) {
4018
+ if !self.disposed.swap(true, Ordering::SeqCst) {
4019
+ self.core.release(Released::Module(self.module_id));
3701
4020
  }
3702
- let _ = rb_self.core.dispose_module(ruby, rb_self.module_id);
3703
- Ok(())
3704
4021
  }
3705
4022
  fn disposed(&self) -> bool {
3706
4023
  self.disposed.load(Ordering::SeqCst)
@@ -3746,18 +4063,72 @@ impl Script {
3746
4063
  let bytes = rb_self.core.script_code_cache(ruby, rb_self.script_id)?;
3747
4064
  Ok(code_cache_value(ruby, bytes.as_ref()))
3748
4065
  }
3749
- fn dispose(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
3750
- if rb_self.disposed.swap(true, Ordering::SeqCst) {
3751
- return Ok(());
4066
+ // Let go of the script now rather than when this wrapper is collected (see
4067
+ // JsFunction::dispose).
4068
+ fn dispose(&self) {
4069
+ if !self.disposed.swap(true, Ordering::SeqCst) {
4070
+ self.core.release(Released::Script(self.script_id));
3752
4071
  }
3753
- let _ = rb_self.core.dispose_script(ruby, rb_self.script_id);
3754
- Ok(())
3755
4072
  }
3756
4073
  fn disposed(&self) -> bool {
3757
4074
  self.disposed.load(Ordering::SeqCst)
3758
4075
  }
3759
4076
  }
3760
4077
 
4078
+ impl JsFunction {
4079
+ fn check_live(&self, ruby: &Ruby) -> Result<(), Error> {
4080
+ // Its realm's reset or dispose is found by the call itself (the handle is
4081
+ // gone from FunctionReg): that is not something this wrapper can see.
4082
+ if self.disposed.load(Ordering::SeqCst) || self.core.is_disposed() {
4083
+ return Err(Error::new(
4084
+ err_class(ruby, "DisposedError"),
4085
+ "disposed function",
4086
+ ));
4087
+ }
4088
+ Ok(())
4089
+ }
4090
+ fn call(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result<Value, Error> {
4091
+ rb_self.check_live(ruby)?;
4092
+ rb_self
4093
+ .core
4094
+ .call_function(ruby, rb_self.context_id, rb_self.function_id, args, false)
4095
+ }
4096
+ fn call_void(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result<Value, Error> {
4097
+ rb_self.check_live(ruby)?;
4098
+ rb_self
4099
+ .core
4100
+ .call_function(ruby, rb_self.context_id, rb_self.function_id, args, true)
4101
+ }
4102
+ // Let go of the function now rather than when this wrapper is collected. Any
4103
+ // thread, any time: the Globals go at the end of the isolate's next op.
4104
+ fn dispose(&self) {
4105
+ if !self.disposed.swap(true, Ordering::SeqCst) {
4106
+ self.core.release(Released::Function(self.function_id));
4107
+ }
4108
+ }
4109
+ fn disposed(&self) -> bool {
4110
+ self.disposed.load(Ordering::SeqCst) || self.core.is_disposed()
4111
+ }
4112
+ }
4113
+
4114
+ impl Drop for JsFunction {
4115
+ fn drop(&mut self) {
4116
+ self.dispose();
4117
+ }
4118
+ }
4119
+
4120
+ impl Drop for JsModule {
4121
+ fn drop(&mut self) {
4122
+ self.dispose();
4123
+ }
4124
+ }
4125
+
4126
+ impl Drop for Script {
4127
+ fn drop(&mut self) {
4128
+ self.dispose();
4129
+ }
4130
+ }
4131
+
3761
4132
  // A bytecode cache as a binary (ASCII-8BIT) Ruby String, or nil when there's
3762
4133
  // none — the shared return shape of #cached_data and #create_code_cache.
3763
4134
  fn code_cache_value(ruby: &Ruby, bytes: Option<&Vec<u8>>) -> Value {
@@ -3808,9 +4179,15 @@ fn binary_bytes(
3808
4179
 
3809
4180
  fn vm_err(ruby: &Ruby, e: VmError) -> Error {
3810
4181
  match e {
3811
- VmError::Parse(m) => Error::new(err_class(ruby, "ParseError"), m),
4182
+ VmError::Parse(m, thrown) => {
4183
+ carrying_thrown(Error::new(err_class(ruby, "ParseError"), m), thrown)
4184
+ }
3812
4185
  VmError::Runtime(m) => Error::new(err_class(ruby, "RuntimeError"), m),
3813
- VmError::JsError { message, backtrace } => js_runtime_error(ruby, message, backtrace),
4186
+ VmError::JsError {
4187
+ message,
4188
+ backtrace,
4189
+ thrown,
4190
+ } => carrying_thrown(js_runtime_error(ruby, message, backtrace), thrown),
3814
4191
  VmError::Terminated => Error::new(
3815
4192
  err_class(ruby, "ScriptTerminatedError"),
3816
4193
  "JavaScript was terminated (timeout or stop)",
@@ -3819,6 +4196,7 @@ fn vm_err(ruby: &Ruby, e: VmError) -> Error {
3819
4196
  err_class(ruby, "V8OutOfMemoryError"),
3820
4197
  "JavaScript exceeded the isolate memory_limit",
3821
4198
  ),
4199
+ VmError::Disposed(m) => Error::new(err_class(ruby, "DisposedError"), m),
3822
4200
  }
3823
4201
  }
3824
4202
 
@@ -3852,6 +4230,42 @@ fn terminated_error(ruby: &Ruby, js_backtrace: Option<Vec<String>>) -> Error {
3852
4230
  }
3853
4231
  }
3854
4232
 
4233
+ // The ivar a Ruby exception carries its thrown value's serial in (`keep_thrown`),
4234
+ // beside the message it was raised with.
4235
+ const THROWN_IVAR: &str = "@__rusty_racer_thrown";
4236
+
4237
+ // |error| as an instance carrying |thrown|'s serial, for a host fn it propagates
4238
+ // out of to throw the kept value on (`take_thrown`).
4239
+ fn carrying_thrown(error: Error, thrown: Option<u64>) -> Error {
4240
+ let Some(serial) = thrown else {
4241
+ return error;
4242
+ };
4243
+ match error_to_exception(&error) {
4244
+ Some(exc) => {
4245
+ let Ok(message) = exc.funcall::<_, _, Value>("message", ()) else {
4246
+ return Error::from(exc);
4247
+ };
4248
+ let _ = exc
4249
+ .funcall::<_, _, Value>("instance_variable_set", (THROWN_IVAR, (serial, message)));
4250
+ Error::from(exc)
4251
+ }
4252
+ None => error,
4253
+ }
4254
+ }
4255
+
4256
+ // The serial of the thrown value a host fn's raised |error| carries, if any — not
4257
+ // when Ruby re-raised it with a message of its own (`raise e, 'other'` copies
4258
+ // the ivar): that error says something the value does not.
4259
+ fn thrown_serial_of(error: &Error) -> Option<u64> {
4260
+ let exc = error_to_exception(error)?;
4261
+ let (serial, message): (u64, Value) = exc
4262
+ .funcall::<_, _, Option<(u64, Value)>>("instance_variable_get", (THROWN_IVAR,))
4263
+ .ok()
4264
+ .flatten()?;
4265
+ let now: Value = exc.funcall("message", ()).ok()?;
4266
+ now.eql(message).ok()?.then_some(serial)
4267
+ }
4268
+
3855
4269
  // Build a RustyRacer::RuntimeError carrying the JS stack as its Ruby backtrace.
3856
4270
  // Constructs the exception instance so we can set_backtrace before raising;
3857
4271
  // falls back to a plain Error if any of that fails.
@@ -3961,10 +4375,11 @@ pub fn install_classes(ruby: &Ruby) -> Result<(), Error> {
3961
4375
  // A v8::Context (realm): eval/call/attach/compile_module.
3962
4376
  let context = module.define_class("Context", ruby.class_object())?;
3963
4377
  // 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).
4378
+ // filename:), #eval_void and #eval_primitive, which differ only in the 4th arg (what they keep of the completion value).
3965
4379
  context.define_method("_eval", method!(Context::eval, 4))?;
3966
4380
  context.define_method("call", method!(Context::call, -1))?;
3967
4381
  context.define_method("call_void", method!(Context::call_void, -1))?;
4382
+ context.define_method("function", method!(Context::function, 1))?;
3968
4383
  context.define_method("attach", method!(Context::attach, 2))?;
3969
4384
  context.define_method("attach_many", method!(Context::attach_many, 1))?;
3970
4385
  context.define_method("reset", method!(Context::reset, 0))?;
@@ -3985,6 +4400,13 @@ pub fn install_classes(ruby: &Ruby) -> Result<(), Error> {
3985
4400
  script.define_method("dispose", method!(Script::dispose, 0))?;
3986
4401
  script.define_method("disposed?", method!(Script::disposed, 0))?;
3987
4402
 
4403
+ // A resolved function: Context#function -> #call / #call_void.
4404
+ let function = module.define_class("Function", ruby.class_object())?;
4405
+ function.define_method("call", method!(JsFunction::call, -1))?;
4406
+ function.define_method("call_void", method!(JsFunction::call_void, -1))?;
4407
+ function.define_method("dispose", method!(JsFunction::dispose, 0))?;
4408
+ function.define_method("disposed?", method!(JsFunction::disposed, 0))?;
4409
+
3988
4410
  // V8 startup blob: Snapshot.new(code) -> Isolate.new(snapshot:).
3989
4411
  let snapshot = module.define_class("Snapshot", ruby.class_object())?;
3990
4412
  snapshot.define_singleton_method("new", function!(Snapshot::new, -1))?;
@@ -3996,7 +4418,7 @@ pub fn install_classes(ruby: &Ruby) -> Result<(), Error> {
3996
4418
  // Thin ES-module handle: Context#compile_module -> instantiate/evaluate.
3997
4419
  let jsmodule = module.define_class("Module", ruby.class_object())?;
3998
4420
  jsmodule.define_method("_instantiate", method!(JsModule::instantiate, 1))?;
3999
- jsmodule.define_method("evaluate", method!(JsModule::evaluate, 0))?;
4421
+ jsmodule.define_method("_evaluate", method!(JsModule::evaluate, 1))?;
4000
4422
  jsmodule.define_method("namespace", method!(JsModule::namespace, 0))?;
4001
4423
  jsmodule.define_method("_status", method!(JsModule::status, 0))?;
4002
4424
  jsmodule.define_method("graph_async?", method!(JsModule::graph_async, 0))?;