rusty_racer 0.2.2 → 0.2.4
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 +4 -4
- data/README.md +33 -6
- data/ext/rusty_racer/Cargo.toml +6 -2
- data/ext/rusty_racer/src/lib.rs +331 -250
- data/ext/rusty_racer/src/ops.rs +44 -3
- data/lib/rusty_racer/version.rb +1 -1
- data/lib/rusty_racer.rb +16 -7
- metadata +5 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bc72e8a18f16fc78197991e4e29ff01ba94afb83ae26d3ea162903654f485dae
|
|
4
|
+
data.tar.gz: 06a00da38cb052af3a29fa772f93fa14f3e8f267f8a7c88723aad827f908a3d7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3e86f16c51dbe2b1096a7710ea105da2f95f68b640f9a3265f63b85ffcd6f630fb9642305a3afd88da1a559ce50f1238b4b1ff8c8e573d1c8ab20bfd684e03f0
|
|
7
|
+
data.tar.gz: aa1020630d83aa95c83837c8d1aa14bd3e56a207d955de314892f1c8e247a3519ad7101f9b6d6f1a9dab8f3f535c92bb59ab11cd342245be8f777bbdb21a60d3
|
data/README.md
CHANGED
|
@@ -217,7 +217,8 @@ warm isolate — a per-visit reset that avoids rebuilding the VM. Its contract:
|
|
|
217
217
|
(or to an empty realm, with no snapshot).
|
|
218
218
|
- **Runtime mutations are dropped.** Anything set on the realm at runtime is gone.
|
|
219
219
|
- **Host fns are dropped.** Functions `attach`/`attach_many`'d into the realm are
|
|
220
|
-
released
|
|
220
|
+
released — the isolate stops holding them, so whatever they captured becomes
|
|
221
|
+
collectable; re-attach them after a reset.
|
|
221
222
|
- **Modules and classic scripts are dropped.** Handles compiled in the realm die
|
|
222
223
|
with the old context.
|
|
223
224
|
- **The realm id and the shared same-origin token are preserved** — the id keeps
|
|
@@ -337,11 +338,37 @@ What happens next depends on what the stranded operation's Fiber does:
|
|
|
337
338
|
The isolate cannot be disposed after that; it is leaked, with a warning saying
|
|
338
339
|
why. If you still hold the Fiber, **`Fiber#kill` recovers it**: it resumes the
|
|
339
340
|
Fiber to unwind it, so the stranded operation finishes in order and the isolate
|
|
340
|
-
goes back to normal.
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
341
|
+
goes back to normal. The operation itself fails on the way out — see below for
|
|
342
|
+
why a kill reaches it as an ordinary error.
|
|
343
|
+
|
|
344
|
+
### Killing a thread or Fiber inside a host function
|
|
345
|
+
|
|
346
|
+
**A `Thread#kill` or `Fiber#kill` that lands while a host function's Ruby is
|
|
347
|
+
running does not kill it.** It surfaces as `RustyRacer::RuntimeError: Error:
|
|
348
|
+
Fatal` from the `eval`/`call` that was in flight, and the thread keeps going —
|
|
349
|
+
and CRuby has already marked that thread as killed, so **a second `Thread#kill`
|
|
350
|
+
is a no-op**. `Thread#raise` still works, and is the way out if you have to
|
|
351
|
+
reach in from outside.
|
|
352
|
+
|
|
353
|
+
A kill inside a **module resolver** is quieter still: the specifier it was
|
|
354
|
+
resolving simply fails to link, so the error names the import
|
|
355
|
+
(`failed to resolve module specifier "./dep.js" imported from /app.js`) with no
|
|
356
|
+
mention of a kill at all.
|
|
357
|
+
|
|
358
|
+
This is not a choice, it is the boundary. Ruby unwinds a kill with `TAG_FATAL`,
|
|
359
|
+
which arrives while V8's C++ frames are on the stack — and a `longjmp` through
|
|
360
|
+
those is not survivable, so the callback has to catch it like any other error.
|
|
361
|
+
Nor can it be put back afterwards: `rb_jump_tag` does not carry the unwind with
|
|
362
|
+
it, it re-enters whatever the VM still holds in `$!`, and anything protected
|
|
363
|
+
that ran in between (one more host function raising is enough) has cleared it.
|
|
364
|
+
Jumping into that is a segfault in the interpreter rather than a late kill. 0.2.2
|
|
365
|
+
shipped an attempt at this and had to be withdrawn in 0.2.3.
|
|
366
|
+
|
|
367
|
+
So: if you need to stop work that calls into an isolate, **stop it at a Ruby
|
|
368
|
+
boundary you control** — a flag the host function checks, a `Queue` it reads —
|
|
369
|
+
rather than by killing the thread from outside. `Isolate#terminate` stops the
|
|
370
|
+
*JavaScript* (at V8's next interrupt check, see above), which is the other half
|
|
371
|
+
of the same job.
|
|
345
372
|
|
|
346
373
|
## Installation
|
|
347
374
|
|
data/ext/rusty_racer/Cargo.toml
CHANGED
|
@@ -5,12 +5,16 @@
|
|
|
5
5
|
# links and loads with no custom archive.
|
|
6
6
|
[package]
|
|
7
7
|
name = "rusty_racer"
|
|
8
|
-
version = "0.2.
|
|
8
|
+
version = "0.2.4"
|
|
9
9
|
edition = "2024"
|
|
10
10
|
publish = false
|
|
11
11
|
|
|
12
|
+
# cdylib: the standalone gem extension (.so Ruby loads directly).
|
|
13
|
+
# lib (rlib): lets an embedder link rusty_racer as a Rust library and build its
|
|
14
|
+
# own cdylib — capybara-simulated links rusty_racer + a native DOM into one
|
|
15
|
+
# extension and calls rusty_racer::install_classes from its own magnus init.
|
|
12
16
|
[lib]
|
|
13
|
-
crate-type = ["cdylib"]
|
|
17
|
+
crate-type = ["lib", "cdylib"]
|
|
14
18
|
|
|
15
19
|
[dependencies]
|
|
16
20
|
# rb-sys: exposes magnus::rb_sys (protect — catch a Ruby raise from a host
|
data/ext/rusty_racer/src/lib.rs
CHANGED
|
@@ -31,10 +31,29 @@
|
|
|
31
31
|
// OUTERMOST op cancels a stale terminate so it can't poison the next op, while a
|
|
32
32
|
// nested op's cancel never erases a termination aimed at the suspended outer JS.
|
|
33
33
|
//
|
|
34
|
-
// Attached procs and the dynamic-import resolver are
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
34
|
+
// Attached procs and the dynamic-import resolver are kept alive by the ISOLATE'S
|
|
35
|
+
// OWN ROOTS ARRAY (Core.roots), which every wrapper marks — never by a GC root.
|
|
36
|
+
// A root would be a leak: a host fn closes over its embedder's world, and that
|
|
37
|
+
// world reaches back to this isolate's wrappers, so rooting the proc roots the
|
|
38
|
+
// whole cycle and mark-and-sweep can never break it (the wrapper's free would
|
|
39
|
+
// then never run, and the procs would never be released — one un-disposable
|
|
40
|
+
// isolate per embedder session). Marked from the wrappers instead, the cycle is
|
|
41
|
+
// ordinary Ruby garbage. `mark` PINS each entry (GC.compact must not move them:
|
|
42
|
+
// the copies living in Rust are invisible to the compactor and would go stale),
|
|
43
|
+
// which an RArray's own movable marking would not do.
|
|
44
|
+
//
|
|
45
|
+
// Two things stay OUTSIDE that array, deliberately:
|
|
46
|
+
// - the owner Thread (_owner_root) keeps its rb_gc_register_address, because the
|
|
47
|
+
// guarantee it buys has to outlive the last wrapper: Core::drop compares the
|
|
48
|
+
// raw owner VALUE when no wrapper is left to mark anything, and a freed Thread
|
|
49
|
+
// slot reused by a live thread would be a false owner match (see RootedThread).
|
|
50
|
+
// It is not the cycle above — a Thread reaches back to an isolate only if the
|
|
51
|
+
// embedder parks its world in that thread's thread-local storage.
|
|
52
|
+
// - an isolate whose last wrapper is collected on a thread OTHER than its owner
|
|
53
|
+
// still cannot be disposed (V8 forbids it) and is counted leaked, as it always
|
|
54
|
+
// was — see Core::drop. Collection now happens where it never used to, so that
|
|
55
|
+
// counter reports cases it used to miss entirely; disposing explicitly on the
|
|
56
|
+
// owner thread is still the only way to free a foreign-owned isolate promptly.
|
|
38
57
|
|
|
39
58
|
use std::cell::RefCell;
|
|
40
59
|
use std::collections::HashMap;
|
|
@@ -44,9 +63,10 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
44
63
|
use std::sync::{Arc, Mutex, Once, Weak};
|
|
45
64
|
|
|
46
65
|
use magnus::block::Proc;
|
|
47
|
-
use magnus::value::{BoxValue, ReprValue};
|
|
66
|
+
use magnus::value::{BoxValue, Opaque, ReprValue};
|
|
48
67
|
use magnus::{
|
|
49
|
-
Error, Exception, ExceptionClass, RHash, Ruby, TryConvert,
|
|
68
|
+
DataTypeFunctions, Error, Exception, ExceptionClass, RArray, RHash, Ruby, TryConvert,
|
|
69
|
+
TypedData, Value, function, gc, method, prelude::*,
|
|
50
70
|
};
|
|
51
71
|
|
|
52
72
|
mod marshal;
|
|
@@ -62,10 +82,14 @@ use watchdog::{
|
|
|
62
82
|
|
|
63
83
|
// A Ruby Proc rooted for as long as the Core holds it. BoxValue registers a
|
|
64
84
|
// stable heap address with rb_gc_register_address, which both MARKS the proc
|
|
65
|
-
// (the extension may hold the only reference — e.g. attach("f", -> {...}))
|
|
66
85
|
// and PINS it (GC.compact must not move it: the copies living in Rust are
|
|
67
86
|
// invisible to the compactor and would go stale).
|
|
68
87
|
//
|
|
88
|
+
// Only for a proc the isolate holds TRANSIENTLY — today just the instantiate
|
|
89
|
+
// resolver, parked in the slot for the length of one InstantiateModule op and
|
|
90
|
+
// restored after it. Anything held for the isolate's LIFE goes in the roots
|
|
91
|
+
// array instead (see the module header): a lasting root is a lasting leak.
|
|
92
|
+
//
|
|
69
93
|
// SAFETY of the manual Send: the !Send contents (and BoxValue's drop, which
|
|
70
94
|
// calls rb_gc_unregister_address) only run under the GVL. Two conventions
|
|
71
95
|
// keep that true — breaking either is silent UB, so don't:
|
|
@@ -122,13 +146,14 @@ macro_rules! istate {
|
|
|
122
146
|
pub(crate) use istate;
|
|
123
147
|
|
|
124
148
|
// One attach()'d host fn: the realm it was attached into — so resetting or
|
|
125
|
-
// disposing that realm can release
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
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.
|
|
129
154
|
struct ProcSlot {
|
|
130
155
|
context_id: i32,
|
|
131
|
-
|
|
156
|
+
live: bool,
|
|
132
157
|
name: String,
|
|
133
158
|
}
|
|
134
159
|
|
|
@@ -157,18 +182,33 @@ impl ProcTable {
|
|
|
157
182
|
}
|
|
158
183
|
|
|
159
184
|
// Release every live proc attached into |context_id| (its realm is gone),
|
|
160
|
-
// returning
|
|
161
|
-
//
|
|
162
|
-
|
|
185
|
+
// returning their ids so the caller can clear their roots-array entries (a Ruby
|
|
186
|
+
// write, which this table never does: it is locked on paths a GC must not have to
|
|
187
|
+
// wait behind) and only THEN put them on the free list. Idempotent: an
|
|
188
|
+
// already-released slot is skipped, so it can't be double-freed.
|
|
189
|
+
fn release(&mut self, context_id: i32) -> Vec<usize> {
|
|
190
|
+
let mut released = Vec::new();
|
|
163
191
|
for (id, slot) in self.slots.iter_mut().enumerate() {
|
|
164
|
-
if slot.context_id == context_id && slot.
|
|
165
|
-
slot.
|
|
166
|
-
|
|
192
|
+
if slot.context_id == context_id && slot.live {
|
|
193
|
+
slot.live = false;
|
|
194
|
+
released.push(id);
|
|
167
195
|
}
|
|
168
196
|
}
|
|
197
|
+
released
|
|
169
198
|
}
|
|
170
199
|
}
|
|
171
200
|
|
|
201
|
+
// The isolate's ROOTS ARRAY: every Ruby object the isolate must keep alive for as
|
|
202
|
+
// long as a wrapper of it is reachable, in one place the GC can be shown without
|
|
203
|
+
// taking any Rust lock (a mark that had to lock `procs` could deadlock against
|
|
204
|
+
// 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.
|
|
206
|
+
const ROOT_IMPORT_RESOLVER: isize = 0;
|
|
207
|
+
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
|
|
210
|
+
}
|
|
211
|
+
|
|
172
212
|
// Look up a RustyRacer::<name> exception class at raise time. The classes are
|
|
173
213
|
// defined in lib/rusty_racer.rb (loaded after this extension), so they exist
|
|
174
214
|
// by the time any eval can raise. Falls back to Ruby's RuntimeError.
|
|
@@ -301,9 +341,9 @@ enum RubyCall<'a> {
|
|
|
301
341
|
impl RubyCall<'_> {
|
|
302
342
|
fn describe(&self, core: &Core) -> String {
|
|
303
343
|
match self {
|
|
304
|
-
// try_lock, not lock: nothing holds this across a callback (
|
|
305
|
-
//
|
|
306
|
-
//
|
|
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.
|
|
307
347
|
Self::HostFn(id) => match core
|
|
308
348
|
.procs
|
|
309
349
|
.try_lock()
|
|
@@ -472,19 +512,6 @@ fn current_ruby_thread() -> usize {
|
|
|
472
512
|
unsafe { rb_sys::rb_thread_current() as usize }
|
|
473
513
|
}
|
|
474
514
|
|
|
475
|
-
// Identity of the calling FIBER, as its object_id — monotonic and never reused,
|
|
476
|
-
// unlike the VALUE, which is a heap address a collected Fiber hands back (and a
|
|
477
|
-
// Fiber stranded with a pending kill DOES get collected). Ids are a Fixnum on
|
|
478
|
-
// every platform this gem ships for, so the usize compared is an immediate, not
|
|
479
|
-
// a pointer that could alias in its turn. Assigning one can allocate, and this
|
|
480
|
-
// runs with V8 frames live and outside any rb_protect of ours, so it takes its
|
|
481
|
-
// own: 0 on failure, which reads as "no Fiber" at both ends and so drops a kill
|
|
482
|
-
// rather than misdelivers one. MUST be called with the GVL held.
|
|
483
|
-
fn current_fiber_id() -> usize {
|
|
484
|
-
magnus::rb_sys::protect(|| unsafe { rb_sys::rb_obj_id(rb_sys::rb_fiber_current()) })
|
|
485
|
-
.map_or(0, |id| id as usize)
|
|
486
|
-
}
|
|
487
|
-
|
|
488
515
|
// Reduce a magnus Error to a single Exception INSTANCE so it can be GC-rooted and
|
|
489
516
|
// re-raised later WITH ITS ORIGINAL CLASS. A Ruby proc's raise is already an
|
|
490
517
|
// instance; an Error::new(class, msg) from our own code becomes an instance of
|
|
@@ -560,7 +587,7 @@ fn host_fn_callback(
|
|
|
560
587
|
ruby.qnil().as_raw()
|
|
561
588
|
}) {
|
|
562
589
|
Ok(_) => out.unwrap_or_else(|| Err("host function did not complete".into())),
|
|
563
|
-
Err(e) => Err(
|
|
590
|
+
Err(e) => Err(format!("{e}")),
|
|
564
591
|
}
|
|
565
592
|
});
|
|
566
593
|
match result {
|
|
@@ -1117,10 +1144,8 @@ fn resolve_imported<'s>(
|
|
|
1117
1144
|
// calls into Ruby (so it can allocate, so it can GC) and BoxValue::new
|
|
1118
1145
|
// registers a GC root — neither is safe with the GVL released.
|
|
1119
1146
|
match with_gvl(core, RubyCall::Resolver(&spec), || {
|
|
1120
|
-
resolve_module_via_ruby(core, resolve, &spec, &ref_url, None)
|
|
1121
|
-
|
|
1122
|
-
error_to_exception(&e).map(BoxValue::new)
|
|
1123
|
-
})
|
|
1147
|
+
resolve_module_via_ruby(core, resolve, &spec, &ref_url, None)
|
|
1148
|
+
.map_err(|e| error_to_exception(&e).map(BoxValue::new))
|
|
1124
1149
|
}) {
|
|
1125
1150
|
Ok(id) => id,
|
|
1126
1151
|
// Stash the resolver's own raised exception (GC-rooted) so the
|
|
@@ -1133,18 +1158,12 @@ fn resolve_imported<'s>(
|
|
|
1133
1158
|
}
|
|
1134
1159
|
}
|
|
1135
1160
|
None => {
|
|
1136
|
-
|
|
1137
|
-
.dynamic_import_resolver
|
|
1138
|
-
.lock()
|
|
1139
|
-
.unwrap()
|
|
1140
|
-
.as_ref()
|
|
1141
|
-
.map(|r| r.get());
|
|
1142
|
-
match resolver {
|
|
1161
|
+
match core.dynamic_import_resolver() {
|
|
1143
1162
|
Some(p) => match with_gvl(core, RubyCall::Resolver(&spec), || {
|
|
1144
1163
|
resolve_module_via_ruby(core, p, &spec, &ref_url, Some(here.unwrap_or(0)))
|
|
1145
1164
|
// Rendering the message reads the Ruby exception, so it too
|
|
1146
1165
|
// stays inside with_gvl.
|
|
1147
|
-
.map_err(|e|
|
|
1166
|
+
.map_err(|e| e.to_string())
|
|
1148
1167
|
}) {
|
|
1149
1168
|
Ok(id) => id,
|
|
1150
1169
|
// Unlike the static branch there is no Ruby frame waiting to
|
|
@@ -1250,25 +1269,13 @@ fn dynamic_import_cb<'s>(
|
|
|
1250
1269
|
return Some(promise);
|
|
1251
1270
|
}
|
|
1252
1271
|
let core = unsafe { &*core_ptr };
|
|
1253
|
-
let
|
|
1254
|
-
.dynamic_import_resolver
|
|
1255
|
-
.lock()
|
|
1256
|
-
.unwrap()
|
|
1257
|
-
.as_ref()
|
|
1258
|
-
.map(|r| r.get());
|
|
1259
|
-
let id = match resolver_proc {
|
|
1272
|
+
let id = match core.dynamic_import_resolver() {
|
|
1260
1273
|
// A raising resolver only fails the import() (it rejects generically);
|
|
1261
|
-
// it must NOT abort the surrounding eval, so swallow the Err here
|
|
1262
|
-
// note_fatal first, since this is the one with_gvl site that throws its
|
|
1263
|
-
// Err away and a Thread#kill would go with it.
|
|
1274
|
+
// it must NOT abort the surrounding eval, so swallow the Err here.
|
|
1264
1275
|
Some(p) => with_gvl(core, RubyCall::Resolver(&spec), || {
|
|
1265
|
-
resolve_module_via_ruby(core, p, &spec, &referrer, Some(initiating))
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
None
|
|
1269
|
-
},
|
|
1270
|
-
)
|
|
1271
|
-
}),
|
|
1276
|
+
resolve_module_via_ruby(core, p, &spec, &referrer, Some(initiating))
|
|
1277
|
+
})
|
|
1278
|
+
.unwrap_or(None),
|
|
1272
1279
|
None => None,
|
|
1273
1280
|
};
|
|
1274
1281
|
match id {
|
|
@@ -1515,27 +1522,22 @@ struct Core {
|
|
|
1515
1522
|
// op. Owner-thread only, like the isolate itself; AtomicUsize for shared
|
|
1516
1523
|
// &Core access.
|
|
1517
1524
|
installed_stack_limit: std::sync::atomic::AtomicUsize,
|
|
1518
|
-
// Set when a callback's Ruby code unwound with TAG_FATAL — a Thread#kill —
|
|
1519
|
-
// which magnus catches like any other error because a longjmp through V8's
|
|
1520
|
-
// C++ frames is not survivable. Holds the object_id of the
|
|
1521
|
-
// Fiber that must finish the kill (never a raw VALUE: a stranded Fiber
|
|
1522
|
-
// holding a pending kill does get collected, and its address can then be
|
|
1523
|
-
// handed to a new one, whereas an object_id is monotonic and never reused);
|
|
1524
|
-
// 0 = nothing pending.
|
|
1525
|
-
// See Core::note_fatal / Core::resume_pending_fatal. Atomic for the same
|
|
1526
|
-
// reason as the rest: &Core is shared, though only the owner thread ever
|
|
1527
|
-
// touches this.
|
|
1528
|
-
pending_fatal: std::sync::atomic::AtomicUsize,
|
|
1529
1525
|
// Re-entry depth for THIS isolate, readable without a scope (the runner needs
|
|
1530
1526
|
// it to choose the scope kind before any scope exists): 0 = top-level op
|
|
1531
1527
|
// (open a fresh HandleScope from iso_ptr); >0 = a host callback is on the V8
|
|
1532
1528
|
// stack (bootstrap via callback_scope! onto the ambient scope). Bumped around
|
|
1533
1529
|
// each `run`.
|
|
1534
1530
|
depth: std::sync::atomic::AtomicU32,
|
|
1535
|
-
//
|
|
1536
|
-
//
|
|
1537
|
-
//
|
|
1538
|
-
//
|
|
1531
|
+
// Everything this isolate keeps alive on the Ruby side — the attached host fns
|
|
1532
|
+
// and the dynamic-import resolver — in one Array the wrappers MARK (and pin).
|
|
1533
|
+
// See the module header for why these must not be GC roots; ROOT_* for the
|
|
1534
|
+
// layout. Read on the V8 side without the GVL (the import hooks), which is
|
|
1535
|
+
// sound because the entries are pinned and nothing here allocates or raises.
|
|
1536
|
+
roots: Opaque<RArray>,
|
|
1537
|
+
// host_fn_id indexes ProcTable.slots — bookkeeping only (the proc itself is in
|
|
1538
|
+
// `roots`). Mutex (uncontended — single owner thread) so host_fn_callback can
|
|
1539
|
+
// reach it through Core (via the slot's core_ptr) while a &Core method also
|
|
1540
|
+
// holds it. reset/dispose releases the slots and recycles them.
|
|
1539
1541
|
procs: Mutex<ProcTable>,
|
|
1540
1542
|
// Default per-eval/call timeout (ms); 0 = none. eval(timeout_ms:)'s explicit
|
|
1541
1543
|
// value overrides it. Guards against an in-V8 infinite loop without a watchdog.
|
|
@@ -1547,9 +1549,6 @@ struct Core {
|
|
|
1547
1549
|
// the ceiling after each OOM (to this when set, else V8's captured default — see
|
|
1548
1550
|
// oom_initial_limit). Space-axis twin of default_timeout_ms.
|
|
1549
1551
|
memory_limit: usize,
|
|
1550
|
-
// Set by Context#dynamic_import_resolver=; called for a JS import() to map
|
|
1551
|
-
// (specifier, referrer) to an already-loaded Module. GC-rooted like procs.
|
|
1552
|
-
dynamic_import_resolver: Mutex<Option<RootedProc>>,
|
|
1553
1552
|
// The watchdog (armed per timed op, fires TerminateExecution via the handle)
|
|
1554
1553
|
// and its thread's join handle, held here so dispose/Drop — which run on the
|
|
1555
1554
|
// owner thread with no scope — can stop and join it before the isolate drops.
|
|
@@ -1560,7 +1559,8 @@ struct Core {
|
|
|
1560
1559
|
// The V8 isolate (one per Isolate): lifecycle + the isolate-level ops
|
|
1561
1560
|
// (terminate, microtask checkpoint, dynamic import). eval/call/etc. live on
|
|
1562
1561
|
// Context, which an Isolate hands out (a v8::Context).
|
|
1563
|
-
#[
|
|
1562
|
+
#[derive(TypedData)]
|
|
1563
|
+
#[magnus(class = "RustyRacer::Isolate", mark)]
|
|
1564
1564
|
struct Isolate {
|
|
1565
1565
|
core: Arc<Core>,
|
|
1566
1566
|
}
|
|
@@ -1569,7 +1569,8 @@ struct Isolate {
|
|
|
1569
1569
|
// extra one (id >= 1, via Isolate#create_context). eval/call/attach/
|
|
1570
1570
|
// compile_module run here. Its own `disposed` is per-context; the Core's is
|
|
1571
1571
|
// isolate-level.
|
|
1572
|
-
#[
|
|
1572
|
+
#[derive(TypedData)]
|
|
1573
|
+
#[magnus(class = "RustyRacer::Context", mark)]
|
|
1573
1574
|
struct Context {
|
|
1574
1575
|
core: Arc<Core>,
|
|
1575
1576
|
id: i32,
|
|
@@ -1584,7 +1585,8 @@ struct Snapshot {
|
|
|
1584
1585
|
}
|
|
1585
1586
|
|
|
1586
1587
|
// Context#compile_module result: a handle to a V8 module (by id).
|
|
1587
|
-
#[
|
|
1588
|
+
#[derive(TypedData)]
|
|
1589
|
+
#[magnus(class = "RustyRacer::Module", mark)]
|
|
1588
1590
|
struct JsModule {
|
|
1589
1591
|
core: Arc<Core>,
|
|
1590
1592
|
module_id: i32,
|
|
@@ -1596,7 +1598,8 @@ struct JsModule {
|
|
|
1596
1598
|
}
|
|
1597
1599
|
|
|
1598
1600
|
// Context#compile result: a handle to a classic compiled script (by id).
|
|
1599
|
-
#[
|
|
1601
|
+
#[derive(TypedData)]
|
|
1602
|
+
#[magnus(class = "RustyRacer::Script", mark)]
|
|
1600
1603
|
struct Script {
|
|
1601
1604
|
core: Arc<Core>,
|
|
1602
1605
|
script_id: i32,
|
|
@@ -1605,11 +1608,49 @@ struct Script {
|
|
|
1605
1608
|
cache_rejected: bool,
|
|
1606
1609
|
}
|
|
1607
1610
|
|
|
1611
|
+
// The four wrappers that hold an Arc<Core> all mark the isolate's roots, because any
|
|
1612
|
+
// one of them may be the last one reachable: Ruby code is free to keep only the
|
|
1613
|
+
// 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
|
|
1615
|
+
// stops at an already-marked object); missing it once would free a live proc.
|
|
1616
|
+
macro_rules! marks_core_roots {
|
|
1617
|
+
($t:ty) => {
|
|
1618
|
+
impl DataTypeFunctions for $t {
|
|
1619
|
+
fn mark(&self, marker: &gc::Marker) {
|
|
1620
|
+
self.core.mark_roots(marker);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
marks_core_roots!(Isolate);
|
|
1626
|
+
marks_core_roots!(Context);
|
|
1627
|
+
marks_core_roots!(JsModule);
|
|
1628
|
+
marks_core_roots!(Script);
|
|
1629
|
+
|
|
1608
1630
|
// Set true once V8 is initialized; Platform.set_flags! refuses after that
|
|
1609
1631
|
// (flags must be set before V8::initialize), like mini_racer's
|
|
1610
1632
|
// PlatformAlreadyInitialized.
|
|
1611
1633
|
static V8_INITED: AtomicBool = AtomicBool::new(false);
|
|
1612
1634
|
|
|
1635
|
+
// The shared default platform, kept so ops can pump its foreground task queue (see
|
|
1636
|
+
// `platform()` / op_pump_message_loop). V8 posts FinalizationRegistry cleanup — and other
|
|
1637
|
+
// deferred foreground work — as platform tasks that only run when the embedder pumps the
|
|
1638
|
+
// message loop; without a handle to the platform we could never drive them. The platform is
|
|
1639
|
+
// process-global and thread-safe by V8's own contract (it is shared across every isolate on
|
|
1640
|
+
// every owner thread), which is what makes the SharedRef sound to park in a `static`.
|
|
1641
|
+
struct SharedPlatform(v8::SharedRef<v8::Platform>);
|
|
1642
|
+
// SAFETY: v8's default platform is explicitly designed to be shared across isolates and their
|
|
1643
|
+
// owner threads; PumpMessageLoop is called per-isolate on that isolate's owner thread. The
|
|
1644
|
+
// SharedRef is a refcounted handle to that one shared object.
|
|
1645
|
+
unsafe impl Send for SharedPlatform {}
|
|
1646
|
+
unsafe impl Sync for SharedPlatform {}
|
|
1647
|
+
static PLATFORM: std::sync::OnceLock<SharedPlatform> = std::sync::OnceLock::new();
|
|
1648
|
+
|
|
1649
|
+
// The shared platform, once V8 is initialized. `None` before init (no op can run then anyway).
|
|
1650
|
+
pub(crate) fn platform() -> Option<&'static v8::SharedRef<v8::Platform>> {
|
|
1651
|
+
PLATFORM.get().map(|p| &p.0)
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1613
1654
|
fn init_v8() {
|
|
1614
1655
|
static ONCE: Once = Once::new();
|
|
1615
1656
|
ONCE.call_once(|| {
|
|
@@ -1622,6 +1663,10 @@ fn init_v8() {
|
|
|
1622
1663
|
Ordering::Relaxed,
|
|
1623
1664
|
);
|
|
1624
1665
|
let platform = v8::new_default_platform(0, false).make_shared();
|
|
1666
|
+
// Keep a handle before handing ownership to V8, so ops can pump the foreground task
|
|
1667
|
+
// queue (FinalizationRegistry cleanup etc.). make_shared yields a refcounted SharedRef;
|
|
1668
|
+
// clone one into the static and give the other to V8.
|
|
1669
|
+
let _ = PLATFORM.set(SharedPlatform(platform.clone()));
|
|
1625
1670
|
v8::V8::initialize_platform(platform);
|
|
1626
1671
|
v8::V8::initialize();
|
|
1627
1672
|
V8_INITED.store(true, Ordering::SeqCst);
|
|
@@ -1959,8 +2004,22 @@ unsafe extern "C" fn promise_reject_cb(message: v8::PromiseRejectMessage) {
|
|
|
1959
2004
|
// reset and create_context so realms can't drift apart. Returns the context
|
|
1960
2005
|
// Global AND its dedicated microtask queue; the caller owns the queue in
|
|
1961
2006
|
// V8State alongside the context (see V8State::queues for why per-realm).
|
|
2007
|
+
// An embedder-supplied hook run in every realm just after the host namespace is
|
|
2008
|
+
// installed, with the fresh realm's scope and context. Set once (OnceLock) by an
|
|
2009
|
+
// embedder that links rusty_racer as a library; None for standalone gem use. A
|
|
2010
|
+
// plain fn pointer so it stays Send + Sync with no allocation.
|
|
2011
|
+
pub type RealmInitHook = fn(&mut v8::PinScope<'_, '_, ()>, &v8::Global<v8::Context>, i32);
|
|
2012
|
+
static REALM_INIT_HOOK: std::sync::OnceLock<RealmInitHook> = std::sync::OnceLock::new();
|
|
2013
|
+
|
|
2014
|
+
// Register the per-realm init hook. Idempotent-ish: the first call wins (later
|
|
2015
|
+
// calls are ignored), which suits a single embedder wiring it once at boot.
|
|
2016
|
+
pub fn set_realm_init_hook(hook: RealmInitHook) {
|
|
2017
|
+
let _ = REALM_INIT_HOOK.set(hook);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
1962
2020
|
fn new_realm(
|
|
1963
2021
|
scope: &mut v8::PinScope<'_, '_, ()>,
|
|
2022
|
+
context_id: i32,
|
|
1964
2023
|
) -> (v8::Global<v8::Context>, v8::UniqueRef<v8::MicrotaskQueue>) {
|
|
1965
2024
|
// Explicit policy like the isolate's: rusty drives every drain by hand
|
|
1966
2025
|
// (auto_drain / NS.drainMicrotasks), so V8 must never auto-run this queue.
|
|
@@ -2012,6 +2071,15 @@ fn new_realm(
|
|
|
2012
2071
|
if let Some(name) = host_namespace {
|
|
2013
2072
|
install_host_namespace(scope, &fresh, &name);
|
|
2014
2073
|
}
|
|
2074
|
+
// Generic, DOM-agnostic extension seam: an embedder that links rusty_racer as a
|
|
2075
|
+
// library can register one hook (set_realm_init_hook) to run native setup in
|
|
2076
|
+
// every realm — including frame realms the engine creates internally. This is
|
|
2077
|
+
// how capybara-simulated installs its native DOM without rusty_racer knowing
|
|
2078
|
+
// anything about a DOM. Extension state lives in the embedder's OWN typed
|
|
2079
|
+
// isolate slot (rusty_v8 slots are keyed by TypeId), never in IsolateState.
|
|
2080
|
+
if let Some(hook) = REALM_INIT_HOOK.get() {
|
|
2081
|
+
hook(scope, &fresh, context_id);
|
|
2082
|
+
}
|
|
2015
2083
|
(fresh, queue)
|
|
2016
2084
|
}
|
|
2017
2085
|
|
|
@@ -2225,13 +2293,13 @@ impl Isolate {
|
|
|
2225
2293
|
// Core keeps its id + a stable raw ptr so `run` can open scopes on it. The
|
|
2226
2294
|
// isolate is thread-bound from here on (every op asserts the owner thread).
|
|
2227
2295
|
fn new(
|
|
2228
|
-
|
|
2296
|
+
ruby: &Ruby,
|
|
2229
2297
|
host_namespace: Option<String>,
|
|
2230
2298
|
snapshot: Option<magnus::typed_data::Obj<Snapshot>>,
|
|
2231
2299
|
timeout_ms: u64,
|
|
2232
2300
|
memory_limit: usize,
|
|
2233
2301
|
explicit_microtasks: bool,
|
|
2234
|
-
) -> Result<Self
|
|
2302
|
+
) -> Result<magnus::typed_data::Obj<Self>, Error> {
|
|
2235
2303
|
init_v8();
|
|
2236
2304
|
// A snapshot blob bakes globalThis state in: the first Context::new (in
|
|
2237
2305
|
// new_realm below) deserializes that default context for free.
|
|
@@ -2277,7 +2345,7 @@ impl Isolate {
|
|
|
2277
2345
|
// namespace from the slot (seeded above).
|
|
2278
2346
|
{
|
|
2279
2347
|
v8::scope!(let scope, &mut isolate);
|
|
2280
|
-
let (main_context, main_queue) = new_realm(scope);
|
|
2348
|
+
let (main_context, main_queue) = new_realm(scope, 0);
|
|
2281
2349
|
istate!(scope).realms.main_context = Some(main_context);
|
|
2282
2350
|
istate!(scope).realms.main_queue = Some(main_queue);
|
|
2283
2351
|
// The shared graveyard for retired realms' contexts (see V8State).
|
|
@@ -2307,6 +2375,10 @@ impl Isolate {
|
|
|
2307
2375
|
// owner check.
|
|
2308
2376
|
use magnus::rb_sys::{AsRawValue, FromRawValue};
|
|
2309
2377
|
let owner_thread = unsafe { Value::from_raw(rb_sys::rb_thread_current()) };
|
|
2378
|
+
// The roots array (see ROOT_IMPORT_RESOLVER): created empty, grown by the
|
|
2379
|
+
// first attach. Nothing roots it — the wrappers mark it, so it lives
|
|
2380
|
+
// exactly as long as this isolate is reachable from Ruby.
|
|
2381
|
+
let roots = ruby.ary_new();
|
|
2310
2382
|
let core = Arc::new_cyclic(|me| Core {
|
|
2311
2383
|
me: me.clone(),
|
|
2312
2384
|
shared: Mutex::new(Shared {
|
|
@@ -2319,12 +2391,11 @@ impl Isolate {
|
|
|
2319
2391
|
iso_ptr,
|
|
2320
2392
|
scan_start_field: std::sync::atomic::AtomicUsize::new(0),
|
|
2321
2393
|
installed_stack_limit: std::sync::atomic::AtomicUsize::new(0),
|
|
2322
|
-
pending_fatal: std::sync::atomic::AtomicUsize::new(0),
|
|
2323
2394
|
depth: std::sync::atomic::AtomicU32::new(0),
|
|
2395
|
+
roots: Opaque::from(roots),
|
|
2324
2396
|
procs: Mutex::new(ProcTable::default()),
|
|
2325
2397
|
default_timeout_ms: timeout_ms,
|
|
2326
2398
|
memory_limit,
|
|
2327
|
-
dynamic_import_resolver: Mutex::new(None),
|
|
2328
2399
|
watchdog,
|
|
2329
2400
|
watchdog_join: Mutex::new(Some(watchdog_join)),
|
|
2330
2401
|
});
|
|
@@ -2347,7 +2418,14 @@ impl Isolate {
|
|
|
2347
2418
|
// enter/exit stack (an out-of-order drop aborts). Instead each op enters
|
|
2348
2419
|
// around its run (Core::run) and teardown re-enters just before drop.
|
|
2349
2420
|
unsafe { (*core.iso_ptr.0).exit() };
|
|
2350
|
-
|
|
2421
|
+
// Wrap HERE rather than returning `Self` for magnus to wrap after this frame is
|
|
2422
|
+
// gone: wrapping allocates, and until the wrapper exists the roots array is
|
|
2423
|
+
// referenced only from the Arc<Core>'s malloc'd block, which Ruby does not scan.
|
|
2424
|
+
// `black_box` keeps the local VALUE live across that allocation — where the
|
|
2425
|
+
// conservative stack scan does reach it.
|
|
2426
|
+
let wrapped = ruby.obj_wrap(Isolate { core });
|
|
2427
|
+
std::hint::black_box(roots);
|
|
2428
|
+
Ok(wrapped)
|
|
2351
2429
|
}
|
|
2352
2430
|
}
|
|
2353
2431
|
|
|
@@ -2580,58 +2658,10 @@ impl Core {
|
|
|
2580
2658
|
}
|
|
2581
2659
|
}
|
|
2582
2660
|
|
|
2583
|
-
// Finish a Thread#kill that a callback had to swallow (see note_fatal). This
|
|
2584
|
-
// is a longjmp — the same rb_jump_tag magnus performs for an Error::Jump
|
|
2585
|
-
// handed back to it — so nothing between here and Ruby runs Drop, and it may
|
|
2586
|
-
// only be called where no frame in between still owns anything that matters.
|
|
2587
|
-
// That rules out the end of `run`: instantiate_module parks the op's resolver
|
|
2588
|
-
// across the call and restores it afterwards, and skipping THAT would leave a
|
|
2589
|
-
// dead op's resolver parked for a later dynamic import to call. The end of
|
|
2590
|
-
// reply_value is past every such restore, and reaching it is what every
|
|
2591
|
-
// operation that can run Ruby at all does.
|
|
2592
|
-
//
|
|
2593
|
-
// An operation that returns Err from `run` — only a panic, which poisons the
|
|
2594
|
-
// isolate — doesn't reach it, and the kill is then dropped by whichever
|
|
2595
|
-
// operation gets here next (see the Fiber check). Frames above still leak
|
|
2596
|
-
// whatever they hold (an argument String, say), which is why every caller with
|
|
2597
|
-
// cleanup of its own runs that cleanup BEFORE calling this.
|
|
2598
|
-
fn resume_pending_fatal<T>(&self, outcome: Result<T, Error>) -> Result<T, Error> {
|
|
2599
|
-
let pending = self.pending_fatal.swap(0, Ordering::SeqCst);
|
|
2600
|
-
// Nothing pending — the case every op takes, and one atomic load.
|
|
2601
|
-
if pending == 0 {
|
|
2602
|
-
return outcome;
|
|
2603
|
-
}
|
|
2604
|
-
// A kill belongs to the Fiber whose callback swallowed it, and finishing
|
|
2605
|
-
// it anywhere else would kill an unrelated caller — the main thread, say,
|
|
2606
|
-
// mid-eval, with no exception and no message. A mismatch is an ordinary
|
|
2607
|
-
// outcome, not a should-not-happen: when the script CATCHES the error the
|
|
2608
|
-
// killed callback threw, that operation may never end, and the next one to
|
|
2609
|
-
// get here is somebody else's. Drop the kill rather than deliver it to the
|
|
2610
|
-
// wrong Fiber. (CRuby's own Fiber#kill bookkeeping still terminates such a
|
|
2611
|
-
// Fiber on its next resume; only its return value is lost.)
|
|
2612
|
-
if pending != current_fiber_id() {
|
|
2613
|
-
return outcome;
|
|
2614
|
-
}
|
|
2615
|
-
drop(outcome);
|
|
2616
|
-
// enum ruby_tag_type's RUBY_TAG_FATAL; not in rb_sys's bindings
|
|
2617
|
-
// (bindgen skips the anonymous enum), and stable since 1.9.
|
|
2618
|
-
const RUBY_TAG_FATAL: std::os::raw::c_int = 8;
|
|
2619
|
-
unsafe { rb_sys::rb_jump_tag(RUBY_TAG_FATAL) };
|
|
2620
|
-
}
|
|
2621
|
-
|
|
2622
2661
|
// Map a terminal reply to a Ruby value (the common eval/call/run shape).
|
|
2623
2662
|
// &self so a Terminated outcome can pick up the JS stack the watchdog snapshot
|
|
2624
2663
|
// captured (see timeout_interrupt / take_timeout_backtrace).
|
|
2625
2664
|
fn reply_value(&self, ruby: &Ruby, reply: VmReply) -> Result<Value, Error> {
|
|
2626
|
-
let outcome = self.reply_value_deferred(ruby, reply);
|
|
2627
|
-
// Every operation that can run Ruby — and so can have a Thread#kill
|
|
2628
|
-
// swallowed by one of its callbacks — ends here, except the few whose own
|
|
2629
|
-
// cleanup has to run first; those call the two halves in order.
|
|
2630
|
-
self.resume_pending_fatal(outcome)
|
|
2631
|
-
}
|
|
2632
|
-
|
|
2633
|
-
// reply_value without finishing a swallowed kill.
|
|
2634
|
-
fn reply_value_deferred(&self, ruby: &Ruby, reply: VmReply) -> Result<Value, Error> {
|
|
2635
2665
|
match reply {
|
|
2636
2666
|
VmReply::Done(Ok(val)) => jsval_to_ruby(ruby, &val),
|
|
2637
2667
|
// Clone (don't take): a nested timeout's terminate unwinds the whole op
|
|
@@ -2659,74 +2689,67 @@ impl Core {
|
|
|
2659
2689
|
.clone()
|
|
2660
2690
|
}
|
|
2661
2691
|
|
|
2662
|
-
//
|
|
2663
|
-
//
|
|
2664
|
-
//
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
//
|
|
2675
|
-
//
|
|
2676
|
-
//
|
|
2677
|
-
//
|
|
2678
|
-
//
|
|
2679
|
-
//
|
|
2680
|
-
//
|
|
2681
|
-
// thread.
|
|
2682
|
-
//
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
//
|
|
2689
|
-
//
|
|
2690
|
-
//
|
|
2691
|
-
//
|
|
2692
|
-
//
|
|
2693
|
-
//
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
)
|
|
2701
|
-
|
|
2692
|
+
// ── the roots array (see the module header + ROOT_IMPORT_RESOLVER) ──────
|
|
2693
|
+
// Keep `val` alive for as long as this isolate is reachable from Ruby. On a
|
|
2694
|
+
// Ruby thread with the GVL: storing can grow the Array, which allocates.
|
|
2695
|
+
fn root_store<T: magnus::IntoValue>(
|
|
2696
|
+
&self,
|
|
2697
|
+
ruby: &Ruby,
|
|
2698
|
+
index: isize,
|
|
2699
|
+
val: T,
|
|
2700
|
+
) -> Result<(), Error> {
|
|
2701
|
+
ruby.get_inner(self.roots).store(index, val)
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
// One entry, or None where nothing is stored (an unset resolver, a released
|
|
2705
|
+
// host fn). Reads the Array WITHOUT the GVL — V8 calls the import hooks with it
|
|
2706
|
+
// released — so it must not run one line of Ruby: `rb_ary_entry` plus
|
|
2707
|
+
// `Proc::from_value` (rb_obj_is_proc) and nothing else. NOT `RArray::entry`,
|
|
2708
|
+
// whose TryConvert would answer a nil slot by CALLING `nil.to_proc`, raising
|
|
2709
|
+
// NoMethodError and rescuing it — Ruby code, an allocation and a longjmp, with
|
|
2710
|
+
// no GVL held (measured: it corrupts the VM — "[BUG] unexpected situation" then
|
|
2711
|
+
// a SEGV — under any concurrent Ruby thread). The entries are pinned by
|
|
2712
|
+
// `mark_roots`, so the VALUE stays put until the caller has it under the GVL.
|
|
2713
|
+
fn root_proc(&self, index: isize) -> Option<Proc> {
|
|
2714
|
+
let roots = unsafe { Ruby::get_unchecked() }.get_inner(self.roots);
|
|
2715
|
+
Proc::from_value(roots.entry::<Value>(index).ok()?)
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
// 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
|
|
2720
|
+
// may be the last reachable): a wrapper that forgot to would leave a live
|
|
2721
|
+
// isolate's host fns unmarked. `mark` PINS — an Array marks its own elements
|
|
2722
|
+
// MOVABLE, and the copies Rust takes out of it (call_proc, root_proc) would go
|
|
2723
|
+
// stale if the compactor moved one. Takes no Rust lock, by construction.
|
|
2724
|
+
fn mark_roots(&self, marker: &gc::Marker) {
|
|
2725
|
+
let roots = unsafe { Ruby::get_unchecked() }.get_inner(self.roots);
|
|
2726
|
+
marker.mark(roots);
|
|
2727
|
+
// Entry by entry, exactly — not `mark_slice`, whose rb_gc_mark_locations is
|
|
2728
|
+
// CONSERVATIVE (it tests each word for heap-likeness) and measured no faster
|
|
2729
|
+
// here. The walk is O(entries) per wrapper per GC, MINOR GCs included (these
|
|
2730
|
+
// wrappers are not wb_protected, so they are marked every time): measured
|
|
2731
|
+
// 2026-09-13 with 600 procs, ~0.1 ms per GC at the 18 wrappers a real embedder
|
|
2732
|
+
// session holds (capybara-simulated: 1 Isolate + 17 Contexts), ~3 ms at 500. If
|
|
2733
|
+
// that ever bites, the fix is for the derived wrappers to mark the ISOLATE'S
|
|
2734
|
+
// Ruby object and let its own mark walk the array once — which needs Core to
|
|
2735
|
+
// hold that object (an Opaque<Value> nothing stores today), not just a rewrite
|
|
2736
|
+
// of this loop.
|
|
2737
|
+
for i in 0..roots.len() {
|
|
2738
|
+
if let Ok(v) = roots.entry::<Value>(i as isize) {
|
|
2739
|
+
marker.mark(v);
|
|
2740
|
+
}
|
|
2702
2741
|
}
|
|
2703
|
-
// Which Fiber has to finish the kill: this one, the one the killed
|
|
2704
|
-
// callback was running on. Checked again at the other end, so a pending
|
|
2705
|
-
// kill can never land on an unrelated caller.
|
|
2706
|
-
self.pending_fatal
|
|
2707
|
-
.store(current_fiber_id(), Ordering::SeqCst);
|
|
2708
|
-
}
|
|
2709
|
-
|
|
2710
|
-
// note_fatal + the message the callback will throw, for the map_err sites.
|
|
2711
|
-
// Rendering is safe to do here, outside any rb_protect with V8 frames live:
|
|
2712
|
-
// magnus formats an exception from its class name and address rather than
|
|
2713
|
-
// calling #message or #inspect, so a caller whose own class raises from those
|
|
2714
|
-
// (checked) cannot longjmp out of this.
|
|
2715
|
-
fn swallow(&self, e: Error) -> String {
|
|
2716
|
-
self.note_fatal(&e);
|
|
2717
|
-
e.to_string()
|
|
2718
2742
|
}
|
|
2719
2743
|
|
|
2720
2744
|
fn call_proc(&self, ruby: &Ruby, host_fn_id: usize, args: &[JsVal]) -> Result<JsVal, String> {
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
};
|
|
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.
|
|
2750
|
+
let proc = self
|
|
2751
|
+
.root_proc(proc_root_index(host_fn_id))
|
|
2752
|
+
.ok_or("unknown host function")?;
|
|
2730
2753
|
// Marshal into a Ruby Array, NOT a Vec<Value>: bare Values in a heap Vec
|
|
2731
2754
|
// are hidden from Ruby's GC mark phase (magnus's own RArray::to_vec doc
|
|
2732
2755
|
// spells this out). With several args, once arg N is parked in the Vec
|
|
@@ -2738,16 +2761,16 @@ impl Core {
|
|
|
2738
2761
|
let ruby_args = ruby.ary_new_capa(args.len());
|
|
2739
2762
|
for v in args {
|
|
2740
2763
|
ruby_args
|
|
2741
|
-
.push(jsval_to_ruby(ruby, v).map_err(|e|
|
|
2742
|
-
.map_err(|e|
|
|
2764
|
+
.push(jsval_to_ruby(ruby, v).map_err(|e| e.to_string())?)
|
|
2765
|
+
.map_err(|e| e.to_string())?;
|
|
2743
2766
|
}
|
|
2744
2767
|
// SAFETY: ruby_args is a live local (so GC keeps it and its elements) and
|
|
2745
2768
|
// is not mutated while the slice is borrowed — as_slice's contract. A VM
|
|
2746
2769
|
// op the proc issues re-enters Core::run (depth > 0) directly — no nested
|
|
2747
2770
|
// frame bookkeeping is needed any more (the call stack IS the nesting).
|
|
2748
2771
|
let result: Result<Value, Error> = proc.call(unsafe { ruby_args.as_slice() });
|
|
2749
|
-
let value = result.map_err(|e|
|
|
2750
|
-
ruby_to_jsval(value).map_err(|e|
|
|
2772
|
+
let value = result.map_err(|e| e.to_string())?;
|
|
2773
|
+
ruby_to_jsval(value).map_err(|e| e.to_string())
|
|
2751
2774
|
}
|
|
2752
2775
|
|
|
2753
2776
|
// Context#call (and call_void). Resolves a dotted function path
|
|
@@ -2833,6 +2856,23 @@ impl Core {
|
|
|
2833
2856
|
Ok(())
|
|
2834
2857
|
}
|
|
2835
2858
|
|
|
2859
|
+
// Isolate#pump_message_loop: run every pending foreground platform task, without blocking.
|
|
2860
|
+
// These are the deferred tasks V8 posts to the platform's task runner rather than running
|
|
2861
|
+
// inline — most importantly FinalizationRegistry cleanup callbacks (posted after a GC finds a
|
|
2862
|
+
// registered target dead) and WeakRef clearing. A pure microtask checkpoint does NOT drive
|
|
2863
|
+
// them; only pumping the message loop does. Returns nothing; safe to call any time (a no-op
|
|
2864
|
+
// when the queue is empty).
|
|
2865
|
+
fn pump_message_loop(&self, ruby: &Ruby) -> Result<(), Error> {
|
|
2866
|
+
let reply = self.run(
|
|
2867
|
+
ruby,
|
|
2868
|
+
Request::PumpMessageLoop {
|
|
2869
|
+
timeout_ms: self.default_timeout_ms,
|
|
2870
|
+
},
|
|
2871
|
+
)?;
|
|
2872
|
+
self.reply_value(ruby, reply)?;
|
|
2873
|
+
Ok(())
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2836
2876
|
fn eval_t(
|
|
2837
2877
|
&self,
|
|
2838
2878
|
ruby: &Ruby,
|
|
@@ -2862,11 +2902,16 @@ impl Core {
|
|
|
2862
2902
|
name: String,
|
|
2863
2903
|
proc: Proc,
|
|
2864
2904
|
) -> Result<Value, Error> {
|
|
2905
|
+
// BEFORE the roots write, not just inside `run` below: storing can REALLOC the
|
|
2906
|
+
// array's element storage, which the import hooks read with the GVL released —
|
|
2907
|
+
// and a refused attach must not consume a slot or leave its proc rooted either.
|
|
2908
|
+
self.ensure_owner_and_live(ruby)?;
|
|
2865
2909
|
let host_fn_id = self.procs.lock().unwrap().alloc(ProcSlot {
|
|
2866
2910
|
context_id,
|
|
2867
|
-
|
|
2911
|
+
live: true,
|
|
2868
2912
|
name: name.clone(),
|
|
2869
2913
|
});
|
|
2914
|
+
self.root_store(ruby, proc_root_index(host_fn_id), proc)?;
|
|
2870
2915
|
let reply = self.run(
|
|
2871
2916
|
ruby,
|
|
2872
2917
|
Request::Attach {
|
|
@@ -2895,20 +2940,31 @@ impl Core {
|
|
|
2895
2940
|
if entries.is_empty() {
|
|
2896
2941
|
return Ok(ruby.qnil().as_value()); // nothing to install, skip the round-trip
|
|
2897
2942
|
}
|
|
2898
|
-
|
|
2943
|
+
self.ensure_owner_and_live(ruby)?; // before the roots writes — see `attach`
|
|
2944
|
+
let allocated: Vec<(String, usize, Proc)> = {
|
|
2899
2945
|
let mut procs = self.procs.lock().unwrap();
|
|
2900
2946
|
entries
|
|
2901
2947
|
.into_iter()
|
|
2902
2948
|
.map(|(name, proc)| {
|
|
2903
2949
|
let id = procs.alloc(ProcSlot {
|
|
2904
2950
|
context_id,
|
|
2905
|
-
|
|
2951
|
+
live: true,
|
|
2906
2952
|
name: name.clone(),
|
|
2907
2953
|
});
|
|
2908
|
-
(name, id)
|
|
2954
|
+
(name, id, proc)
|
|
2909
2955
|
})
|
|
2910
2956
|
.collect()
|
|
2911
2957
|
};
|
|
2958
|
+
// Stored OUTSIDE the table's lock: storing into the array can allocate (the
|
|
2959
|
+
// Array grows), and an allocation may GC — which marks through this isolate's
|
|
2960
|
+
// wrappers, a path that must never wait on `procs`. The Procs parked in the
|
|
2961
|
+
// Vec meanwhile are invisible to the GC (see call_proc's note), but the caller's
|
|
2962
|
+
// `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));
|
|
2967
|
+
}
|
|
2912
2968
|
let reply = self.run(
|
|
2913
2969
|
ruby,
|
|
2914
2970
|
Request::AttachMany {
|
|
@@ -2920,31 +2976,29 @@ impl Core {
|
|
|
2920
2976
|
self.reply_value(ruby, reply)
|
|
2921
2977
|
}
|
|
2922
2978
|
|
|
2923
|
-
//
|
|
2924
|
-
// realm is gone (reset or disposed), so the V8-side functions that
|
|
2925
|
-
//
|
|
2926
|
-
//
|
|
2927
|
-
// realms are indices into
|
|
2928
|
-
|
|
2929
|
-
|
|
2979
|
+
// 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.
|
|
2985
|
+
fn release_context_procs(&self, ruby: &Ruby, context_id: i32) {
|
|
2986
|
+
// Nil the entries BEFORE the ids go back on the free list, so no attach can
|
|
2987
|
+
// ever be handed an id whose old proc is still in the array.
|
|
2988
|
+
let released = self.procs.lock().unwrap().release(context_id);
|
|
2989
|
+
for &id in &released {
|
|
2990
|
+
let _ = self.root_store(ruby, proc_root_index(id), ruby.qnil());
|
|
2991
|
+
}
|
|
2992
|
+
self.procs.lock().unwrap().free.extend(released);
|
|
2930
2993
|
}
|
|
2931
2994
|
|
|
2932
2995
|
fn reset(&self, ruby: &Ruby, context_id: i32) -> Result<Value, Error> {
|
|
2933
2996
|
let reply = self.run(ruby, Request::Reset { context_id })?;
|
|
2934
|
-
|
|
2935
|
-
// before a swallowed kill is finished, or the jump skips it and this
|
|
2936
|
-
// realm's procs stay GC-rooted and their slots unrecycled for the life of
|
|
2937
|
-
// the isolate — a permanent leak in a process that goes on running, since
|
|
2938
|
-
// a killed FIBER leaves its thread alive.
|
|
2939
|
-
let out = self.reply_value_deferred(ruby, reply);
|
|
2997
|
+
let out = self.reply_value(ruby, reply)?;
|
|
2940
2998
|
// Only on success — a refused reset (unknown/suspended realm) keeps
|
|
2941
|
-
// its attached fns callable.
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
if out.is_ok() {
|
|
2945
|
-
self.release_context_procs(context_id);
|
|
2946
|
-
}
|
|
2947
|
-
self.resume_pending_fatal(out)
|
|
2999
|
+
// its attached fns callable.
|
|
3000
|
+
self.release_context_procs(ruby, context_id);
|
|
3001
|
+
Ok(out)
|
|
2948
3002
|
}
|
|
2949
3003
|
|
|
2950
3004
|
// Build a new context; returns its id (replied as an Int).
|
|
@@ -2956,12 +3010,9 @@ impl Core {
|
|
|
2956
3010
|
|
|
2957
3011
|
fn dispose_context(&self, ruby: &Ruby, context_id: i32) -> Result<(), Error> {
|
|
2958
3012
|
let reply = self.run(ruby, Request::DisposeContext { context_id })?;
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
self.release_context_procs(context_id);
|
|
2963
|
-
}
|
|
2964
|
-
self.resume_pending_fatal(out.map(|_| ()))
|
|
3013
|
+
self.reply_value(ruby, reply)?;
|
|
3014
|
+
self.release_context_procs(ruby, context_id);
|
|
3015
|
+
Ok(())
|
|
2965
3016
|
}
|
|
2966
3017
|
|
|
2967
3018
|
// Thin ESM primitives. compile_module returns the new module's id.
|
|
@@ -3038,10 +3089,7 @@ impl Core {
|
|
|
3038
3089
|
// Reclaim THIS op's resolver error and restore the outer op's pair.
|
|
3039
3090
|
let (_, resolver_err) = self.swap_instantiate(saved_resolve, saved_err);
|
|
3040
3091
|
if let Some(exc) = resolver_err {
|
|
3041
|
-
|
|
3042
|
-
// just as much the end of the op, and skipping it would leave a
|
|
3043
|
-
// swallowed kill for some later op to assert.
|
|
3044
|
-
return self.resume_pending_fatal(Err(Error::from(*exc)));
|
|
3092
|
+
return Err(Error::from(*exc));
|
|
3045
3093
|
}
|
|
3046
3094
|
self.reply_value(ruby, reply?)
|
|
3047
3095
|
}
|
|
@@ -3140,10 +3188,18 @@ impl Core {
|
|
|
3140
3188
|
code_cache_from_reply(ruby, reply)
|
|
3141
3189
|
}
|
|
3142
3190
|
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3191
|
+
// Owner-thread only, like every other writer of the roots array (`attach`,
|
|
3192
|
+
// `attach_many`, `dispose`): the store can REALLOC the array's element storage, and
|
|
3193
|
+
// the import hooks read that storage with the GVL released.
|
|
3194
|
+
fn set_dynamic_import_resolver(&self, ruby: &Ruby, proc: Proc) -> Result<(), Error> {
|
|
3195
|
+
self.ensure_owner_and_live(ruby)?;
|
|
3196
|
+
self.root_store(ruby, ROOT_IMPORT_RESOLVER, proc)
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
// The resolver, or None if none was set. Read on the V8 side without the GVL —
|
|
3200
|
+
// see root_proc.
|
|
3201
|
+
fn dynamic_import_resolver(&self) -> Option<Proc> {
|
|
3202
|
+
self.root_proc(ROOT_IMPORT_RESOLVER)
|
|
3147
3203
|
}
|
|
3148
3204
|
|
|
3149
3205
|
// Terminate whatever is running. IsolateHandle is Send + refcounted —
|
|
@@ -3174,12 +3230,16 @@ impl Core {
|
|
|
3174
3230
|
// it entered, and OwnedIsolate's Drop asserts `self == GetCurrent()`
|
|
3175
3231
|
// (then exits). Between ops the isolate is exited, so we must enter here.
|
|
3176
3232
|
unsafe { (*self.iso_ptr.0).enter() };
|
|
3233
|
+
// Only the Rust-side bookkeeping. Clearing the roots ARRAY here would be
|
|
3234
|
+
// pointless work: teardown also runs from Core::drop, and by then the array
|
|
3235
|
+
// is garbage itself — the last wrapper marking it is exactly what went away.
|
|
3236
|
+
// `dispose` clears it explicitly instead, for the isolate the caller disposes
|
|
3237
|
+
// but keeps a handle to.
|
|
3177
3238
|
{
|
|
3178
3239
|
let mut procs = self.procs.lock().unwrap();
|
|
3179
3240
|
procs.slots.clear();
|
|
3180
3241
|
procs.free.clear();
|
|
3181
3242
|
}
|
|
3182
|
-
*self.dynamic_import_resolver.lock().unwrap() = None;
|
|
3183
3243
|
{
|
|
3184
3244
|
let st = istate!(unsafe { &mut *self.iso_ptr.0 });
|
|
3185
3245
|
st.realms = V8State::default();
|
|
@@ -3228,6 +3288,10 @@ impl Core {
|
|
|
3228
3288
|
shared.disposed = true;
|
|
3229
3289
|
}
|
|
3230
3290
|
self.teardown();
|
|
3291
|
+
// Release the host fns NOW rather than when the wrappers are collected: a
|
|
3292
|
+
// disposed isolate can never call one again, and each closes over as much
|
|
3293
|
+
// of the embedder's world as it happens to capture.
|
|
3294
|
+
let _ = ruby.get_inner(self.roots).clear();
|
|
3231
3295
|
Ok(())
|
|
3232
3296
|
}
|
|
3233
3297
|
}
|
|
@@ -3266,7 +3330,7 @@ impl Drop for Core {
|
|
|
3266
3330
|
so it cannot be disposed and is leaked.\n\
|
|
3267
3331
|
The usual cause is a host function that yielded out of a Fiber \
|
|
3268
3332
|
which was never resumed;\n\
|
|
3269
|
-
`Fiber#kill`
|
|
3333
|
+
`Fiber#kill` recovers one if you still hold it. See\n\
|
|
3270
3334
|
https://github.com/ursm/rusty_racer#fibers\n"
|
|
3271
3335
|
);
|
|
3272
3336
|
LEAKED_ISOLATES.fetch_add(1, Ordering::Relaxed);
|
|
@@ -3343,9 +3407,15 @@ impl Isolate {
|
|
|
3343
3407
|
fn low_memory_notification(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
|
|
3344
3408
|
rb_self.core.low_memory_notification(ruby)
|
|
3345
3409
|
}
|
|
3410
|
+
// Isolate#pump_message_loop: run pending foreground platform tasks (FinalizationRegistry
|
|
3411
|
+
// cleanup callbacks and WeakRef clearing among them), without blocking. Pair it with a GC
|
|
3412
|
+
// (low_memory_notification) or call it periodically so weak-collection callbacks actually fire.
|
|
3413
|
+
fn pump_message_loop(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
|
|
3414
|
+
rb_self.core.pump_message_loop(ruby)
|
|
3415
|
+
}
|
|
3346
3416
|
// dynamic_import_resolver = ->(specifier, referrer_url) { module } for import().
|
|
3347
|
-
fn set_dynamic_import_resolver(rb_self: &Self, proc: Proc) {
|
|
3348
|
-
rb_self.core.set_dynamic_import_resolver(proc)
|
|
3417
|
+
fn set_dynamic_import_resolver(ruby: &Ruby, rb_self: &Self, proc: Proc) -> Result<(), Error> {
|
|
3418
|
+
rb_self.core.set_dynamic_import_resolver(ruby, proc)
|
|
3349
3419
|
}
|
|
3350
3420
|
fn dispose(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
|
|
3351
3421
|
rb_self.core.dispose(ruby)
|
|
@@ -3851,6 +3921,16 @@ fn resolve_module_via_ruby(
|
|
|
3851
3921
|
|
|
3852
3922
|
#[magnus::init]
|
|
3853
3923
|
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
3924
|
+
install_classes(ruby)
|
|
3925
|
+
}
|
|
3926
|
+
|
|
3927
|
+
// Define the RustyRacer::* Ruby classes/modules. Split out of the magnus init so
|
|
3928
|
+
// an embedder that links rusty_racer as a LIBRARY (rather than loading its gem
|
|
3929
|
+
// .so) can build its own cdylib and call this from its own `#[magnus::init]` —
|
|
3930
|
+
// e.g. capybara-simulated, which links rusty_racer + a native DOM into one
|
|
3931
|
+
// extension. Standalone gem use goes through `init` above; both define the exact
|
|
3932
|
+
// same surface.
|
|
3933
|
+
pub fn install_classes(ruby: &Ruby) -> Result<(), Error> {
|
|
3854
3934
|
let module = ruby.define_module("RustyRacer")?;
|
|
3855
3935
|
|
|
3856
3936
|
// The isolate (VM) + its isolate-level ops; hands out Contexts.
|
|
@@ -3874,6 +3954,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
3874
3954
|
"low_memory_notification",
|
|
3875
3955
|
method!(Isolate::low_memory_notification, 0),
|
|
3876
3956
|
)?;
|
|
3957
|
+
isolate.define_method("pump_message_loop", method!(Isolate::pump_message_loop, 0))?;
|
|
3877
3958
|
isolate.define_method("dispose", method!(Isolate::dispose, 0))?;
|
|
3878
3959
|
isolate.define_method("disposed?", method!(Isolate::disposed, 0))?;
|
|
3879
3960
|
|
data/ext/rusty_racer/src/ops.rs
CHANGED
|
@@ -184,6 +184,13 @@ pub(crate) enum Request {
|
|
|
184
184
|
// near-heap-limit callback, and tells reclaimable garbage apart from a real
|
|
185
185
|
// leak (memory that survives this is genuinely retained).
|
|
186
186
|
LowMemoryNotification,
|
|
187
|
+
// Run pending foreground platform tasks (Platform::PumpMessageLoop) without blocking —
|
|
188
|
+
// the deferred work V8 posts to its task runner rather than running inline, most
|
|
189
|
+
// importantly FinalizationRegistry cleanup callbacks (arbitrary JS, so time-capped by
|
|
190
|
+
// timeout_ms). Realm-independent.
|
|
191
|
+
PumpMessageLoop {
|
|
192
|
+
timeout_ms: u64,
|
|
193
|
+
},
|
|
187
194
|
}
|
|
188
195
|
|
|
189
196
|
// compile_module result: the module's id plus any produced bytecode cache and
|
|
@@ -439,7 +446,8 @@ fn request_realm(state: &IsolateState, request: &Request) -> Option<i32> {
|
|
|
439
446
|
| Request::ScriptCodeCache { .. }
|
|
440
447
|
| Request::ModuleCodeCache { .. }
|
|
441
448
|
| Request::HeapStatistics
|
|
442
|
-
| Request::LowMemoryNotification
|
|
449
|
+
| Request::LowMemoryNotification
|
|
450
|
+
| Request::PumpMessageLoop { .. } => None,
|
|
443
451
|
}
|
|
444
452
|
}
|
|
445
453
|
|
|
@@ -544,6 +552,7 @@ fn dispatch_one(
|
|
|
544
552
|
Request::ModuleCodeCache { module_id } => op_module_code_cache(scope, module_id),
|
|
545
553
|
Request::HeapStatistics => op_heap_statistics(scope),
|
|
546
554
|
Request::LowMemoryNotification => op_low_memory_notification(scope),
|
|
555
|
+
Request::PumpMessageLoop { timeout_ms } => op_pump_message_loop(scope, timeout_ms),
|
|
547
556
|
}
|
|
548
557
|
}
|
|
549
558
|
|
|
@@ -571,6 +580,38 @@ fn op_low_memory_notification(scope: &mut v8::PinScope<'_, '_, ()>) -> VmReply {
|
|
|
571
580
|
VmReply::Done(Ok(JsVal::Undefined))
|
|
572
581
|
}
|
|
573
582
|
|
|
583
|
+
// Drain the platform's foreground task queue: run every pending task, then stop (never blocks
|
|
584
|
+
// waiting for work). This is what actually fires FinalizationRegistry cleanup callbacks — V8
|
|
585
|
+
// posts them here after a GC finds a registered target dead, and PerformMicrotaskCheckpoint does
|
|
586
|
+
// NOT run them. Runs on the isolate's owner thread, with the isolate entered (the cleanup task
|
|
587
|
+
// enters the target's realm itself). A no-op when the platform is uninitialized or the queue empty.
|
|
588
|
+
//
|
|
589
|
+
// A cleanup callback is ARBITRARY user JS, so it gets the same bracket every other JS-running op has:
|
|
590
|
+
// a watchdog time-caps a runaway (`while(true){}`) callback and makes it terminable, and the
|
|
591
|
+
// `pump_message_loop(…, false)` loop stays bounded because a fired terminate blocks further JS. After
|
|
592
|
+
// the tasks run, a microtask checkpoint drains any microtasks they queued (a promise reaction), so
|
|
593
|
+
// cleanup-triggered continuations settle in this op — matching the binding's auto-microtask model.
|
|
594
|
+
fn op_pump_message_loop(scope: &mut v8::PinScope<'_, '_, ()>, timeout_ms: u64) -> VmReply {
|
|
595
|
+
let watchdog = arm_watchdog(scope, timeout_ms);
|
|
596
|
+
if let Some(platform) = crate::platform() {
|
|
597
|
+
while v8::Platform::pump_message_loop(platform, scope, false) {}
|
|
598
|
+
}
|
|
599
|
+
if let Some(ctx) = context_for(istate!(scope), 0) {
|
|
600
|
+
let context = v8::Local::new(scope, &ctx);
|
|
601
|
+
let scope = &mut v8::ContextScope::new(scope, context);
|
|
602
|
+
checkpoint_draining(scope);
|
|
603
|
+
}
|
|
604
|
+
let fired = disarm_watchdog(scope, watchdog);
|
|
605
|
+
if fired {
|
|
606
|
+
istate!(scope).watchdog_fired = true;
|
|
607
|
+
}
|
|
608
|
+
VmReply::Done(if fired {
|
|
609
|
+
Err(VmError::Terminated)
|
|
610
|
+
} else {
|
|
611
|
+
Ok(JsVal::Undefined)
|
|
612
|
+
})
|
|
613
|
+
}
|
|
614
|
+
|
|
574
615
|
#[allow(clippy::too_many_arguments)]
|
|
575
616
|
fn op_eval(
|
|
576
617
|
scope: &mut v8::PinScope<'_, '_, ()>,
|
|
@@ -775,7 +816,7 @@ fn op_reset(scope: &mut v8::PinScope<'_, '_, ()>, context_id: i32) -> VmReply {
|
|
|
775
816
|
"cannot reset a realm while a request for it is suspended on the V8 stack".into(),
|
|
776
817
|
)))
|
|
777
818
|
} else {
|
|
778
|
-
let (fresh, fresh_queue) = new_realm(scope);
|
|
819
|
+
let (fresh, fresh_queue) = new_realm(scope, context_id);
|
|
779
820
|
{
|
|
780
821
|
let realms = &mut istate!(scope).realms;
|
|
781
822
|
// Swap in the fresh realm and PARK the old context + queue in
|
|
@@ -812,7 +853,7 @@ fn op_create_context(scope: &mut v8::PinScope<'_, '_, ()>) -> VmReply {
|
|
|
812
853
|
realms.next_context_id += 1;
|
|
813
854
|
id
|
|
814
855
|
};
|
|
815
|
-
let (fresh, fresh_queue) = new_realm(scope);
|
|
856
|
+
let (fresh, fresh_queue) = new_realm(scope, id);
|
|
816
857
|
istate!(scope).realms.contexts.insert(id, fresh);
|
|
817
858
|
istate!(scope).realms.queues.insert(id, fresh_queue);
|
|
818
859
|
VmReply::Done(Ok(JsVal::Int(id as i64)))
|
data/lib/rusty_racer/version.rb
CHANGED
data/lib/rusty_racer.rb
CHANGED
|
@@ -14,10 +14,18 @@ require_relative "rusty_racer/version"
|
|
|
14
14
|
# flat fallback.
|
|
15
15
|
versioned = "rusty_racer/#{RUBY_VERSION[/\d+\.\d+/]}/rusty_racer"
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
# Skip the native load when an embedder has already defined the classes. Linking
|
|
18
|
+
# rusty_racer as a library into another extension's cdylib (e.g.
|
|
19
|
+
# capybara-simulated, which bundles rusty_racer + its native DOM) calls
|
|
20
|
+
# install_classes from that extension's own init; loading this gem's separate .so
|
|
21
|
+
# on top would put a SECOND V8 runtime in the process (unsound). This file is then
|
|
22
|
+
# required only for the pure-Ruby API wrappers below.
|
|
23
|
+
unless defined?(RustyRacer::Isolate)
|
|
24
|
+
if File.exist?(File.join(__dir__, "#{versioned}.#{RbConfig::CONFIG['DLEXT']}"))
|
|
25
|
+
require_relative versioned
|
|
26
|
+
else
|
|
27
|
+
require "rusty_racer/rusty_racer"
|
|
28
|
+
end
|
|
21
29
|
end
|
|
22
30
|
|
|
23
31
|
module RustyRacer
|
|
@@ -99,10 +107,11 @@ module RustyRacer
|
|
|
99
107
|
# the binding's job (V8's host contract), and static imports met while linking
|
|
100
108
|
# resolve through this same block (also with the realm as the 3rd arg).
|
|
101
109
|
# (Module#instantiate's own resolve block keeps its 2-arg form.)
|
|
102
|
-
#
|
|
103
|
-
#
|
|
110
|
+
# The native side keeps the proc alive itself (in the isolate's roots array,
|
|
111
|
+
# marked by its wrappers), so it is NOT held in an ivar here: that second
|
|
112
|
+
# reference would outlive `dispose`, which exists to drop whatever the resolver
|
|
113
|
+
# captured the moment the isolate can no longer call it.
|
|
104
114
|
def dynamic_import_resolver=(resolver)
|
|
105
|
-
@dynamic_import_resolver = resolver
|
|
106
115
|
_set_dynamic_import_resolver(resolver)
|
|
107
116
|
end
|
|
108
117
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rusty_racer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Keita Urashima
|
|
8
|
-
autorequire:
|
|
8
|
+
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-09-28 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rb_sys
|
|
@@ -54,7 +54,7 @@ metadata:
|
|
|
54
54
|
source_code_uri: https://github.com/ursm/rusty_racer
|
|
55
55
|
bug_tracker_uri: https://github.com/ursm/rusty_racer/issues
|
|
56
56
|
rubygems_mfa_required: 'true'
|
|
57
|
-
post_install_message:
|
|
57
|
+
post_install_message:
|
|
58
58
|
rdoc_options: []
|
|
59
59
|
require_paths:
|
|
60
60
|
- lib
|
|
@@ -70,7 +70,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
70
70
|
version: '0'
|
|
71
71
|
requirements: []
|
|
72
72
|
rubygems_version: 3.5.22
|
|
73
|
-
signing_key:
|
|
73
|
+
signing_key:
|
|
74
74
|
specification_version: 4
|
|
75
75
|
summary: Embed V8 in Ruby via rusty_v8 + Magnus (rb-sys)
|
|
76
76
|
test_files: []
|