rusty_racer 0.2.0 → 0.2.2
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 +63 -1
- data/ext/rusty_racer/Cargo.toml +1 -1
- data/ext/rusty_racer/src/lib.rs +438 -63
- data/ext/rusty_racer/src/ops.rs +21 -4
- data/lib/rusty_racer/execjs.rb +8 -2
- data/lib/rusty_racer/version.rb +1 -1
- data/lib/rusty_racer.rb +12 -1
- 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: 03be69f3c1409a64a73622db3063447bbea7813f393227d508739f0c636c6859
|
|
4
|
+
data.tar.gz: 7a7ecb5203032be6f8b27133444ce8c3663eb7d9d49aa86726be8c28acf19425
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 57fb55d034906210851ea93be3ad933ee366dfccd1419d4f7f712ed5407082565ccda4ed0e0abadde12ec9af1b958ffab908cbdfb40ef5efa188ed8d2f47154e
|
|
7
|
+
data.tar.gz: 56983cedc5a48dc0a9cf1570da7a7241a6be6a3a8c80ca7114204e6764bc377a0d72814147e318c64974093b625148e8260d8e75168a76e1fa22711a94e5ed69
|
data/README.md
CHANGED
|
@@ -65,6 +65,7 @@ ctx.eval("({a: 1, b: [true, 'x']})") # => {"a"=>1, "b"=>[true, "x"]}
|
|
|
65
65
|
ctx.eval("function add(a, b) { return a + b }")
|
|
66
66
|
ctx.call("add", 20, 22) # => 42
|
|
67
67
|
ctx.call_void("doSideEffect") # runs it; never marshals the return
|
|
68
|
+
ctx.eval_void("globalThis.app = boot()") # ditto for the completion value
|
|
68
69
|
|
|
69
70
|
# Ruby callbacks into JS; a raised Ruby exception becomes a JS exception.
|
|
70
71
|
ctx.attach("rubyUpcase", ->(s) { s.upcase })
|
|
@@ -85,6 +86,14 @@ covers the library. Under it: `ParseError`, `RuntimeError`,
|
|
|
85
86
|
`DisposedError` for using an isolate (or anything it handed out) after
|
|
86
87
|
disposing it; `WrongThreadError` for reaching an isolate from the wrong thread.
|
|
87
88
|
|
|
89
|
+
Reading a result is not passive — marshalling runs JS (getters, `Proxy` traps,
|
|
90
|
+
`toString`), and a throw there fails the operation even though the code itself
|
|
91
|
+
ran to completion. When the value is incidental, say so: `#eval_void`,
|
|
92
|
+
`Script#run_void` and `#call_void` run for the effect and never touch the
|
|
93
|
+
result. A statement list has a completion value whether you want one or not, so
|
|
94
|
+
plumbing an object into a global (`globalThis.win = someProxy`) is enough to
|
|
95
|
+
make a plain `#eval` fail after every one of its writes has landed.
|
|
96
|
+
|
|
88
97
|
ES modules (the embedder owns the URL→module registry):
|
|
89
98
|
|
|
90
99
|
```ruby
|
|
@@ -249,7 +258,12 @@ to one native thread (rusty_v8 exposes no `v8::Locker`), so using it from anothe
|
|
|
249
258
|
thread raises `RustyRacer::WrongThreadError` rather than corrupting the VM.
|
|
250
259
|
|
|
251
260
|
- **`Isolate#terminate` is the one exception** — it is safe to call from any
|
|
252
|
-
thread (it stops a runaway script on the owner thread).
|
|
261
|
+
thread (it stops a runaway script on the owner thread). It stops JavaScript at
|
|
262
|
+
V8's next interrupt check, which a runaway script reaches almost immediately
|
|
263
|
+
and a *short* one may never reach: terminating from inside a host function that
|
|
264
|
+
then returns to a script which simply finishes lets that script finish, and the
|
|
265
|
+
request is dropped rather than carried into the next operation. Use it to stop
|
|
266
|
+
code that is running away, not as a way to fail the call you are inside.
|
|
253
267
|
- **Dispose on the owner thread.** `Isolate#dispose` must run on the owner
|
|
254
268
|
thread. If the last reference to an isolate is instead garbage-collected on a
|
|
255
269
|
*different* thread (e.g. its owner thread already exited), it cannot be
|
|
@@ -281,6 +295,54 @@ process on the next GC or thrown exception. So **don't call into an isolate from
|
|
|
281
295
|
inside a Fiber on a worker thread**; drive isolate ops directly on the thread, or
|
|
282
296
|
keep Fiber/Enumerator-mediated JS calls on the main thread.
|
|
283
297
|
|
|
298
|
+
The other limit is on *where* a Fiber may switch. Operations on one isolate have
|
|
299
|
+
to nest: everything V8 keeps per isolate — the frame chain, the handle scopes,
|
|
300
|
+
the description of the stack — unwinds in the reverse of the order it was
|
|
301
|
+
entered. Resuming a Fiber from inside a host function keeps that (the outer
|
|
302
|
+
operation is simply blocked until the inner one returns, which is why
|
|
303
|
+
`Capybara::Result#find` inside a callback works), but **yielding out of a host
|
|
304
|
+
function** does not: it strands that operation mid-flight. That covers
|
|
305
|
+
`Fiber.yield` and `Fiber#transfer`, an `Enumerator::Yielder#<<` from inside a
|
|
306
|
+
callback, and any blocking call made under a Fiber scheduler (`async`, `falcon`),
|
|
307
|
+
which yields out of the Fiber for you — the last of those without a `Fiber`
|
|
308
|
+
keyword anywhere in your code.
|
|
309
|
+
|
|
310
|
+
What happens next depends on what the stranded operation's Fiber does:
|
|
311
|
+
|
|
312
|
+
- **Resumed while another operation is still running** — the two can now only
|
|
313
|
+
finish in the order they started, which V8 has no room for. There is no
|
|
314
|
+
exception to raise: raising *is* that unwind. So the binding prints what
|
|
315
|
+
happened, which host function did it, and where — then stops the process with
|
|
316
|
+
`SIGABRT` (exit 134). It is not rescuable and there is no opt-out; on a
|
|
317
|
+
threaded server that takes the whole worker with it.
|
|
318
|
+
|
|
319
|
+
```
|
|
320
|
+
[rusty_racer] fatal: operations on this isolate stopped nesting.
|
|
321
|
+
|
|
322
|
+
The host function `hop` handed control to Ruby.
|
|
323
|
+
By the time it came back, the operations in flight on this isolate had
|
|
324
|
+
changed (now 2, was 1) — so they no longer nest. …
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
- **Resumed only after the other operation has finished** — memory-safe, and it
|
|
328
|
+
keeps working. But note that *while* an operation is stranded, every other
|
|
329
|
+
operation on that isolate runs as a **nested** one, and only an outermost
|
|
330
|
+
operation drains microtasks. So a promise queued in the meantime does not
|
|
331
|
+
settle until the stranded operation finishes. **This is the shape a Fiber
|
|
332
|
+
scheduler produces**: it resumes the stranded Fiber once the other fiber's
|
|
333
|
+
operation is done, so `async`/`falcon` get the quiet variant, not the abort.
|
|
334
|
+
|
|
335
|
+
- **Never resumed** — Ruby frees an unresumed Fiber's stack without unwinding it,
|
|
336
|
+
so the operation's frames vanish while the isolate is still entered by them.
|
|
337
|
+
The isolate cannot be disposed after that; it is leaked, with a warning saying
|
|
338
|
+
why. If you still hold the Fiber, **`Fiber#kill` recovers it**: it resumes the
|
|
339
|
+
Fiber to unwind it, so the stranded operation finishes in order and the isolate
|
|
340
|
+
goes back to normal. (The one thing that defeats that is a script which catches
|
|
341
|
+
the error the killed callback throws and carries on — the kill only lands once
|
|
342
|
+
that operation ends, just as it would wait out any Ruby call in progress, and
|
|
343
|
+
until then the script can call further host functions, which run on the
|
|
344
|
+
already-killed Fiber.)
|
|
345
|
+
|
|
284
346
|
## Installation
|
|
285
347
|
|
|
286
348
|
Precompiled gems bundle V8 — no V8 build, no Rust toolchain — for Ruby 3.3, 3.4,
|
data/ext/rusty_racer/Cargo.toml
CHANGED
data/ext/rusty_racer/src/lib.rs
CHANGED
|
@@ -122,11 +122,14 @@ macro_rules! istate {
|
|
|
122
122
|
pub(crate) use istate;
|
|
123
123
|
|
|
124
124
|
// One attach()'d host fn: the realm it was attached into — so resetting or
|
|
125
|
-
// disposing that realm can release the GC root —
|
|
126
|
-
//
|
|
125
|
+
// disposing that realm can release the GC root — the rooted proc itself (None
|
|
126
|
+
// once released; the slot index stays valid as a host_fn_id), and the name it
|
|
127
|
+
// was attached under, kept only so a diagnostic can say which host function is
|
|
128
|
+
// involved (one String per attach, never per call).
|
|
127
129
|
struct ProcSlot {
|
|
128
130
|
context_id: i32,
|
|
129
131
|
proc: Option<RootedProc>,
|
|
132
|
+
name: String,
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
// The isolate's host-fn registry, indexed by host_fn_id. `free` lists slots
|
|
@@ -282,6 +285,39 @@ where
|
|
|
282
285
|
job.r.unwrap()
|
|
283
286
|
}
|
|
284
287
|
|
|
288
|
+
// What handed control to Ruby. Carried through with_gvl for one reason: when
|
|
289
|
+
// the op nesting breaks, the diagnostic can point at the caller's own code
|
|
290
|
+
// instead of saying "a host function". Rendered only on that path, so an
|
|
291
|
+
// ordinary callback pays for a pointer and nothing else.
|
|
292
|
+
enum RubyCall<'a> {
|
|
293
|
+
// An attach()'d host function, by host_fn_id — the name it was attached
|
|
294
|
+
// under lives in the isolate's proc table.
|
|
295
|
+
HostFn(usize),
|
|
296
|
+
// A module resolver (a static instantiate block, a dynamic auto-link, or
|
|
297
|
+
// import()), named by the specifier it was handed.
|
|
298
|
+
Resolver(&'a str),
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
impl RubyCall<'_> {
|
|
302
|
+
fn describe(&self, core: &Core) -> String {
|
|
303
|
+
match self {
|
|
304
|
+
// try_lock, not lock: nothing holds this across a callback (call_proc
|
|
305
|
+
// takes the proc out under the lock and releases it before calling),
|
|
306
|
+
// but blocking here would turn a diagnostic into a hang.
|
|
307
|
+
Self::HostFn(id) => match core
|
|
308
|
+
.procs
|
|
309
|
+
.try_lock()
|
|
310
|
+
.ok()
|
|
311
|
+
.and_then(|procs| procs.slots.get(*id).map(|slot| slot.name.clone()))
|
|
312
|
+
{
|
|
313
|
+
Some(name) => format!("The host function `{name}`"),
|
|
314
|
+
None => "A host function".to_string(),
|
|
315
|
+
},
|
|
316
|
+
Self::Resolver(spec) => format!("The module resolver, resolving `{spec}`,"),
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
285
321
|
// The inverse trampoline: REACQUIRE the GVL to run |f| (a Ruby callback), then
|
|
286
322
|
// release it again. Called from inside a V8 host callback / module resolver,
|
|
287
323
|
// which runs GVL-released under the in-thread runner's `without_gvl`; the proc
|
|
@@ -289,20 +325,41 @@ where
|
|
|
289
325
|
// the proc re-enters the runner, releasing the GVL again) is sound. |f| must
|
|
290
326
|
// NOT let a Ruby exception escape as a longjmp — the callers convert proc
|
|
291
327
|
// errors to a Result value and throw on the JS side instead.
|
|
292
|
-
|
|
328
|
+
//
|
|
329
|
+
// This is also the only place where control can leave an operation WITHOUT it
|
|
330
|
+
// finishing: the Ruby we hand it to may switch Fibers, and a yield out of a
|
|
331
|
+
// callback strands this op mid-flight while the resumer goes on to use the
|
|
332
|
+
// isolate again. Everything V8 keeps per isolate nests strictly — the frame
|
|
333
|
+
// chain, the handle scopes, the stack description — so `core.depth` (the ops in
|
|
334
|
+
// flight on this isolate) coming back unchanged is exactly the condition that
|
|
335
|
+
// they still do: every op started while Ruby held control has also finished.
|
|
336
|
+
// Two atomic loads per callback, and a mismatch is fatal — see
|
|
337
|
+
// fatal_nesting_broken. Both loads and the check live INSIDE the trampoline, so
|
|
338
|
+
// they run with the GVL held and on the stranded op's own stack, which is what
|
|
339
|
+
// lets the diagnostic there read a Ruby backtrace without taking the GVL again.
|
|
340
|
+
fn with_gvl<F, R>(core: &Core, call: RubyCall<'_>, f: F) -> R
|
|
293
341
|
where
|
|
294
342
|
F: FnOnce() -> R,
|
|
295
343
|
{
|
|
296
|
-
struct Job<F, R> {
|
|
344
|
+
struct Job<'a, F, R> {
|
|
345
|
+
core: &'a Core,
|
|
346
|
+
call: RubyCall<'a>,
|
|
297
347
|
f: Option<F>,
|
|
298
348
|
r: Option<R>,
|
|
299
349
|
}
|
|
300
350
|
unsafe extern "C" fn run<F: FnOnce() -> R, R>(data: *mut c_void) -> *mut c_void {
|
|
301
351
|
let job = unsafe { &mut *(data as *mut Job<F, R>) };
|
|
352
|
+
let entry_depth = job.core.depth.load(Ordering::SeqCst);
|
|
302
353
|
job.r = Some((job.f.take().unwrap())());
|
|
354
|
+
let exit_depth = job.core.depth.load(Ordering::SeqCst);
|
|
355
|
+
if exit_depth != entry_depth {
|
|
356
|
+
fatal_nesting_broken(job.core, &job.call, entry_depth, exit_depth);
|
|
357
|
+
}
|
|
303
358
|
null_mut()
|
|
304
359
|
}
|
|
305
360
|
let mut job = Job::<F, R> {
|
|
361
|
+
core,
|
|
362
|
+
call,
|
|
306
363
|
f: Some(f),
|
|
307
364
|
r: None,
|
|
308
365
|
};
|
|
@@ -312,6 +369,101 @@ where
|
|
|
312
369
|
job.r.unwrap()
|
|
313
370
|
}
|
|
314
371
|
|
|
372
|
+
// Ruby handed control back to a callback whose operation is no longer the
|
|
373
|
+
// innermost one on its isolate: the Ruby code switched Fibers, and an operation
|
|
374
|
+
// that started later is still running. The two would now have to unwind in the
|
|
375
|
+
// order they were STARTED rather than the reverse, and V8 has no room for that —
|
|
376
|
+
// finishing this one pops its HandleScope out from under the other's live
|
|
377
|
+
// handles, restores a frame chain that still has the other's frames linked into
|
|
378
|
+
// it, and (at depth 0) exits an isolate the other is still running in.
|
|
379
|
+
//
|
|
380
|
+
// None of the gentler endings exist. Reporting it as a Ruby exception IS that
|
|
381
|
+
// unwind, so raising is the corruption, not a report of it. Waiting for the
|
|
382
|
+
// other operation deadlocks: it is blocked on the Fiber this one is standing in.
|
|
383
|
+
// And refusing the second operation before it started was never possible either
|
|
384
|
+
// — "a Fiber resumed from inside a callback" (supported, and common: every
|
|
385
|
+
// Enumerator is a Fiber) and "a Fiber that yielded out of one" leave Ruby in the
|
|
386
|
+
// same observable state, and CRuby exposes no way to tell a resuming Fiber from
|
|
387
|
+
// a suspended one. The difference only shows up here, when control comes back.
|
|
388
|
+
//
|
|
389
|
+
// So the process stops, while the state is still intact and the cause can still
|
|
390
|
+
// be named, rather than letting V8 abort later on `Check failed:
|
|
391
|
+
// IsOnCentralStack()` — or worse, running JavaScript on freed handles. rb_raise
|
|
392
|
+
// and rb_fatal would longjmp through V8's C++ frames, so this doesn't go through
|
|
393
|
+
// Ruby's error path at all.
|
|
394
|
+
fn fatal_nesting_broken(core: &Core, call: &RubyCall<'_>, entry_depth: u32, exit_depth: u32) -> ! {
|
|
395
|
+
eprintln!(
|
|
396
|
+
"\n[rusty_racer] fatal: operations on this isolate stopped nesting.\n\
|
|
397
|
+
\n\
|
|
398
|
+
{who} handed control to Ruby.\n\
|
|
399
|
+
By the time it came back, the operations in flight on this isolate had\n\
|
|
400
|
+
changed (now {exit_depth}, was {entry_depth}) — so they no longer nest. The Ruby code\n\
|
|
401
|
+
switched Fibers and left this one stranded while the isolate was used\n\
|
|
402
|
+
again: `Fiber.yield` or `Fiber#transfer` out of the callback, or an\n\
|
|
403
|
+
`Enumerator::Yielder#<<` from inside it — anything that suspends the\n\
|
|
404
|
+
callback rather than returning from it.\n\
|
|
405
|
+
\n\
|
|
406
|
+
V8 requires the operations on one isolate to unwind in the reverse of the\n\
|
|
407
|
+
order they started, and that is now impossible: finishing this one would\n\
|
|
408
|
+
pop its handles out from under the other's. Raising a Ruby exception here\n\
|
|
409
|
+
would BE that unwind, and waiting is a deadlock — the other operation is\n\
|
|
410
|
+
blocked on the Fiber this one is standing in. Stopping now, while the\n\
|
|
411
|
+
state is still intact, is what is left.\n\
|
|
412
|
+
\n\
|
|
413
|
+
Resuming a Fiber from inside a callback IS supported, including calling\n\
|
|
414
|
+
back into the isolate from it. What isn't is yielding OUT of the callback\n\
|
|
415
|
+
and using the isolate before the Fiber is resumed again — let the Fiber\n\
|
|
416
|
+
run to completion inside the callback, or finish the operation first.\n\
|
|
417
|
+
\n\
|
|
418
|
+
See https://github.com/ursm/rusty_racer#fibers\n",
|
|
419
|
+
who = call.describe(core),
|
|
420
|
+
);
|
|
421
|
+
print_stranded_backtrace();
|
|
422
|
+
// Put SIGABRT back to the default. Ruby installs a handler for it that prints
|
|
423
|
+
// a [BUG] report — a Ruby-level backtrace, the loaded features, the whole
|
|
424
|
+
// memory map — which buries the explanation above under a page of noise that
|
|
425
|
+
// also reads as "Ruby crashed", which this is not. The process then dies of
|
|
426
|
+
// SIGABRT (exit 134); whether that leaves a core is the operator's ulimit to
|
|
427
|
+
// decide, not ours to override.
|
|
428
|
+
unsafe { libc::signal(libc::SIGABRT, libc::SIG_DFL) };
|
|
429
|
+
std::process::abort()
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// The Ruby frames of the operation whose callback just came back — the one named
|
|
433
|
+
// in the message above, which is usually the stranded one (it regains control
|
|
434
|
+
// first) but is the resumer's if that one got here first. We hold the GVL and are
|
|
435
|
+
// standing on that operation's own stack, so `caller` here is its eval/call. The
|
|
436
|
+
// proc itself has already RETURNED — that return is what got us here — so the
|
|
437
|
+
// yield's own line is not among these frames; naming the callback is what stands
|
|
438
|
+
// in for it. rb_backtrace() would do this in one call, but prints
|
|
439
|
+
// outermost-first, backwards from every backtrace a Ruby reader has seen.
|
|
440
|
+
fn print_stranded_backtrace() {
|
|
441
|
+
use magnus::rb_sys::FromRawValue;
|
|
442
|
+
let Ok(raw) = magnus::rb_sys::protect(|| unsafe { rb_sys::rb_make_backtrace() }) else {
|
|
443
|
+
return;
|
|
444
|
+
};
|
|
445
|
+
let frames = magnus::RArray::from_value(unsafe { magnus::Value::from_raw(raw) })
|
|
446
|
+
.and_then(|a| a.to_vec::<String>().ok())
|
|
447
|
+
.unwrap_or_default();
|
|
448
|
+
// Drop the binding's own frames (`_eval` and the `eval` wrapping it, both at
|
|
449
|
+
// the same line of rusty_racer.rb) so the first line is the caller's. Keep
|
|
450
|
+
// everything if that would leave nothing to print.
|
|
451
|
+
let own = |f: &String| f.contains("/rusty_racer.rb:");
|
|
452
|
+
let user: Vec<&String> = frames.iter().filter(|f| !own(f)).collect();
|
|
453
|
+
let shown: Vec<&String> = if user.is_empty() {
|
|
454
|
+
frames.iter().collect()
|
|
455
|
+
} else {
|
|
456
|
+
user
|
|
457
|
+
};
|
|
458
|
+
if shown.is_empty() {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
eprintln!("That operation is here:");
|
|
462
|
+
for (i, frame) in shown.iter().enumerate() {
|
|
463
|
+
eprintln!("{}{frame}", if i == 0 { "\t" } else { "\tfrom " });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
315
467
|
// Identity of the calling RUBY thread (its Thread VALUE, stable for the thread's
|
|
316
468
|
// life) — used to bind an isolate to its owner thread. Unlike a native ThreadId,
|
|
317
469
|
// this survives Ruby's M:N scheduler moving the thread between native threads.
|
|
@@ -320,6 +472,19 @@ fn current_ruby_thread() -> usize {
|
|
|
320
472
|
unsafe { rb_sys::rb_thread_current() as usize }
|
|
321
473
|
}
|
|
322
474
|
|
|
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
|
+
|
|
323
488
|
// Reduce a magnus Error to a single Exception INSTANCE so it can be GC-rooted and
|
|
324
489
|
// re-raised later WITH ITS ORIGINAL CLASS. A Ruby proc's raise is already an
|
|
325
490
|
// instance; an Error::new(class, msg) from our own code becomes an instance of
|
|
@@ -381,9 +546,9 @@ fn host_fn_callback(
|
|
|
381
546
|
throw_js_error(scope, "host function has no owner");
|
|
382
547
|
return;
|
|
383
548
|
}
|
|
384
|
-
let
|
|
549
|
+
let core = unsafe { &*core_ptr };
|
|
550
|
+
let result: Result<JsVal, String> = with_gvl(core, RubyCall::HostFn(host_fn_id), || {
|
|
385
551
|
let ruby = Ruby::get().unwrap();
|
|
386
|
-
let core = unsafe { &*core_ptr };
|
|
387
552
|
// rb_protect so a Ruby exception raised during arg/return marshalling
|
|
388
553
|
// (ary_new_capa / jsval_to_ruby / ruby_to_jsval can raise on OOM etc.)
|
|
389
554
|
// is CAUGHT here instead of longjmp-ing through V8's C++ frames. The
|
|
@@ -395,7 +560,7 @@ fn host_fn_callback(
|
|
|
395
560
|
ruby.qnil().as_raw()
|
|
396
561
|
}) {
|
|
397
562
|
Ok(_) => out.unwrap_or_else(|| Err("host function did not complete".into())),
|
|
398
|
-
Err(e) => Err(
|
|
563
|
+
Err(e) => Err(core.swallow(e)),
|
|
399
564
|
}
|
|
400
565
|
});
|
|
401
566
|
match result {
|
|
@@ -940,6 +1105,7 @@ fn resolve_imported<'s>(
|
|
|
940
1105
|
if core_ptr.is_null() {
|
|
941
1106
|
return None;
|
|
942
1107
|
}
|
|
1108
|
+
let core = unsafe { &*core_ptr };
|
|
943
1109
|
// The static instantiate block parked for THIS op (Some) vs a dynamic
|
|
944
1110
|
// import's auto-link (None -> dynamic_import_resolver, with the initiating
|
|
945
1111
|
// realm so it can resolve per-realm).
|
|
@@ -950,9 +1116,11 @@ fn resolve_imported<'s>(
|
|
|
950
1116
|
// The error conversion happens INSIDE with_gvl: error_to_exception
|
|
951
1117
|
// calls into Ruby (so it can allocate, so it can GC) and BoxValue::new
|
|
952
1118
|
// registers a GC root — neither is safe with the GVL released.
|
|
953
|
-
match with_gvl(|| {
|
|
954
|
-
resolve_module_via_ruby(
|
|
955
|
-
.
|
|
1119
|
+
match with_gvl(core, RubyCall::Resolver(&spec), || {
|
|
1120
|
+
resolve_module_via_ruby(core, resolve, &spec, &ref_url, None).map_err(|e| {
|
|
1121
|
+
core.note_fatal(&e);
|
|
1122
|
+
error_to_exception(&e).map(BoxValue::new)
|
|
1123
|
+
})
|
|
956
1124
|
}) {
|
|
957
1125
|
Ok(id) => id,
|
|
958
1126
|
// Stash the resolver's own raised exception (GC-rooted) so the
|
|
@@ -965,24 +1133,18 @@ fn resolve_imported<'s>(
|
|
|
965
1133
|
}
|
|
966
1134
|
}
|
|
967
1135
|
None => {
|
|
968
|
-
let resolver =
|
|
1136
|
+
let resolver = core
|
|
969
1137
|
.dynamic_import_resolver
|
|
970
1138
|
.lock()
|
|
971
1139
|
.unwrap()
|
|
972
1140
|
.as_ref()
|
|
973
1141
|
.map(|r| r.get());
|
|
974
1142
|
match resolver {
|
|
975
|
-
Some(p) => match with_gvl(|| {
|
|
976
|
-
resolve_module_via_ruby(
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
&ref_url,
|
|
981
|
-
Some(here.unwrap_or(0)),
|
|
982
|
-
)
|
|
983
|
-
// Rendering the message reads the Ruby exception, so it too
|
|
984
|
-
// stays inside with_gvl.
|
|
985
|
-
.map_err(|e| e.to_string())
|
|
1143
|
+
Some(p) => match with_gvl(core, RubyCall::Resolver(&spec), || {
|
|
1144
|
+
resolve_module_via_ruby(core, p, &spec, &ref_url, Some(here.unwrap_or(0)))
|
|
1145
|
+
// Rendering the message reads the Ruby exception, so it too
|
|
1146
|
+
// stays inside with_gvl.
|
|
1147
|
+
.map_err(|e| core.swallow(e))
|
|
986
1148
|
}) {
|
|
987
1149
|
Ok(id) => id,
|
|
988
1150
|
// Unlike the static branch there is no Ruby frame waiting to
|
|
@@ -1087,7 +1249,8 @@ fn dynamic_import_cb<'s>(
|
|
|
1087
1249
|
reject(scope, "dynamic import has no owner");
|
|
1088
1250
|
return Some(promise);
|
|
1089
1251
|
}
|
|
1090
|
-
let
|
|
1252
|
+
let core = unsafe { &*core_ptr };
|
|
1253
|
+
let resolver_proc = core
|
|
1091
1254
|
.dynamic_import_resolver
|
|
1092
1255
|
.lock()
|
|
1093
1256
|
.unwrap()
|
|
@@ -1095,11 +1258,17 @@ fn dynamic_import_cb<'s>(
|
|
|
1095
1258
|
.map(|r| r.get());
|
|
1096
1259
|
let id = match resolver_proc {
|
|
1097
1260
|
// A raising resolver only fails the import() (it rejects generically);
|
|
1098
|
-
// it must NOT abort the surrounding eval, so swallow the Err here
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1261
|
+
// it must NOT abort the surrounding eval, so swallow the Err here — but
|
|
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.
|
|
1264
|
+
Some(p) => with_gvl(core, RubyCall::Resolver(&spec), || {
|
|
1265
|
+
resolve_module_via_ruby(core, p, &spec, &referrer, Some(initiating)).unwrap_or_else(
|
|
1266
|
+
|e| {
|
|
1267
|
+
core.note_fatal(&e);
|
|
1268
|
+
None
|
|
1269
|
+
},
|
|
1270
|
+
)
|
|
1271
|
+
}),
|
|
1103
1272
|
None => None,
|
|
1104
1273
|
};
|
|
1105
1274
|
match id {
|
|
@@ -1346,6 +1515,17 @@ struct Core {
|
|
|
1346
1515
|
// op. Owner-thread only, like the isolate itself; AtomicUsize for shared
|
|
1347
1516
|
// &Core access.
|
|
1348
1517
|
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,
|
|
1349
1529
|
// Re-entry depth for THIS isolate, readable without a scope (the runner needs
|
|
1350
1530
|
// it to choose the scope kind before any scope exists): 0 = top-level op
|
|
1351
1531
|
// (open a fresh HandleScope from iso_ptr); >0 = a host callback is on the V8
|
|
@@ -1997,8 +2177,16 @@ fn build_snapshot(code: &str, base: Option<Vec<u8>>, warmup: bool) -> Result<Vec
|
|
|
1997
2177
|
{
|
|
1998
2178
|
let cscope = &mut v8::ContextScope::new(scope, context);
|
|
1999
2179
|
if !code.is_empty()
|
|
2000
|
-
&& let Err(e) =
|
|
2001
|
-
|
|
2180
|
+
&& let Err(e) = run_source(
|
|
2181
|
+
cscope,
|
|
2182
|
+
code,
|
|
2183
|
+
if warmup { "<warmup>" } else { "<snapshot>" },
|
|
2184
|
+
// Snapshot/warmup code runs for its EFFECT (the heap it
|
|
2185
|
+
// leaves behind); nothing reads its completion value, so
|
|
2186
|
+
// don't marshal it — a bundle ending in an expression must
|
|
2187
|
+
// not fail the snapshot over a value no one wants.
|
|
2188
|
+
true,
|
|
2189
|
+
)
|
|
2002
2190
|
{
|
|
2003
2191
|
err = Some(match e {
|
|
2004
2192
|
VmError::Parse(m) | VmError::Runtime(m) => m,
|
|
@@ -2131,6 +2319,7 @@ impl Isolate {
|
|
|
2131
2319
|
iso_ptr,
|
|
2132
2320
|
scan_start_field: std::sync::atomic::AtomicUsize::new(0),
|
|
2133
2321
|
installed_stack_limit: std::sync::atomic::AtomicUsize::new(0),
|
|
2322
|
+
pending_fatal: std::sync::atomic::AtomicUsize::new(0),
|
|
2134
2323
|
depth: std::sync::atomic::AtomicU32::new(0),
|
|
2135
2324
|
procs: Mutex::new(ProcTable::default()),
|
|
2136
2325
|
default_timeout_ms: timeout_ms,
|
|
@@ -2282,7 +2471,7 @@ impl Core {
|
|
|
2282
2471
|
&self.installed_stack_limit,
|
|
2283
2472
|
stack_top,
|
|
2284
2473
|
);
|
|
2285
|
-
if depth == 0 {
|
|
2474
|
+
let reply = if depth == 0 {
|
|
2286
2475
|
let mut reply = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
|
2287
2476
|
v8::scope!(let scope, unsafe { &mut *iso });
|
|
2288
2477
|
service_request(scope, request, true)
|
|
@@ -2351,15 +2540,38 @@ impl Core {
|
|
|
2351
2540
|
unsafe { (*iso).exit() };
|
|
2352
2541
|
}
|
|
2353
2542
|
reply
|
|
2543
|
+
};
|
|
2544
|
+
// Everything that has to happen whatever else does goes HERE, inside
|
|
2545
|
+
// the closure, rather than after without_gvl returns:
|
|
2546
|
+
// rb_thread_call_without_gvl checks the thread's pending interrupts as
|
|
2547
|
+
// it re-acquires the GVL, and a Thread#raise, a Timeout, or a Ctrl-C
|
|
2548
|
+
// that lands there longjmps straight past everything written after the
|
|
2549
|
+
// call.
|
|
2550
|
+
//
|
|
2551
|
+
// Balancing the fetch_add is one: a missed decrement is permanent and
|
|
2552
|
+
// silent — the isolate then looks like it has an op in flight for the
|
|
2553
|
+
// rest of its life, so no later op is ever the outermost one
|
|
2554
|
+
// (microtasks stop draining, terminate flags stop being cleared) and
|
|
2555
|
+
// dispose refuses forever. The op is finished by this point: the scope
|
|
2556
|
+
// is dropped and the isolate exited in both branches above.
|
|
2557
|
+
//
|
|
2558
|
+
// Poisoning a panicked isolate is the other: None means the op left V8
|
|
2559
|
+
// possibly inconsistent, and an interrupt arriving on top of that must
|
|
2560
|
+
// not be what decides whether later ops are allowed to touch it. The
|
|
2561
|
+
// raise below still reports it — the lock is safe with the GVL
|
|
2562
|
+
// released, since every other holder takes it briefly and none waits
|
|
2563
|
+
// on an op.
|
|
2564
|
+
if reply.is_none() {
|
|
2565
|
+
self.shared.lock().unwrap().disposed = true;
|
|
2354
2566
|
}
|
|
2567
|
+
self.depth.fetch_sub(1, Ordering::SeqCst);
|
|
2568
|
+
reply
|
|
2355
2569
|
});
|
|
2356
|
-
self.depth.fetch_sub(1, Ordering::SeqCst);
|
|
2357
2570
|
match reply {
|
|
2358
2571
|
Some(reply) => Ok(reply),
|
|
2359
2572
|
None => {
|
|
2360
|
-
// The op panicked
|
|
2361
|
-
//
|
|
2362
|
-
self.shared.lock().unwrap().disposed = true;
|
|
2573
|
+
// The op panicked; the isolate was poisoned inside the closure
|
|
2574
|
+
// above (every later op refuses) rather than risk using it.
|
|
2363
2575
|
Err(Error::new(
|
|
2364
2576
|
err_class(ruby, "InternalError"),
|
|
2365
2577
|
"internal error: operation panicked; the isolate has been disposed",
|
|
@@ -2368,10 +2580,58 @@ impl Core {
|
|
|
2368
2580
|
}
|
|
2369
2581
|
}
|
|
2370
2582
|
|
|
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
|
+
|
|
2371
2622
|
// Map a terminal reply to a Ruby value (the common eval/call/run shape).
|
|
2372
2623
|
// &self so a Terminated outcome can pick up the JS stack the watchdog snapshot
|
|
2373
2624
|
// captured (see timeout_interrupt / take_timeout_backtrace).
|
|
2374
2625
|
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> {
|
|
2375
2635
|
match reply {
|
|
2376
2636
|
VmReply::Done(Ok(val)) => jsval_to_ruby(ruby, &val),
|
|
2377
2637
|
// Clone (don't take): a nested timeout's terminate unwinds the whole op
|
|
@@ -2399,6 +2659,64 @@ impl Core {
|
|
|
2399
2659
|
.clone()
|
|
2400
2660
|
}
|
|
2401
2661
|
|
|
2662
|
+
// A Ruby error raised under a live op cannot be allowed to longjmp — it would
|
|
2663
|
+
// cross V8's C++ frames — so every callback catches it and throws it into JS
|
|
2664
|
+
// as a message instead. That is right for an exception and wrong for exactly
|
|
2665
|
+
// one thing: Thread#kill unwinds with TAG_FATAL, which magnus catches like
|
|
2666
|
+
// anything else, and a killed thread that is merely told about it in
|
|
2667
|
+
// JavaScript is not killed at all — it goes on to run whatever comes next,
|
|
2668
|
+
// while the op's caller is handed a fabricated `RustyRacer::RuntimeError:
|
|
2669
|
+
// Error: Fatal`. Remember it here; Core::run re-asserts it the moment the op
|
|
2670
|
+
// is over and there are no V8 frames left to cross, which is what magnus
|
|
2671
|
+
// would have done with the jump if it could have.
|
|
2672
|
+
//
|
|
2673
|
+
// Only Fatal. The other jumps (a `break` or `next` out of a host proc) have
|
|
2674
|
+
// no live block to return to by the time we could resume them, so they stay
|
|
2675
|
+
// what they are today: an error on the JavaScript side.
|
|
2676
|
+
//
|
|
2677
|
+
// The kill lands at the END of the operation, not at the callback: the
|
|
2678
|
+
// callback still has to return through V8, and what it returns is an ordinary
|
|
2679
|
+
// JavaScript exception, which the script may catch and carry on from — during
|
|
2680
|
+
// which it can call further host functions, running Ruby on the already-killed
|
|
2681
|
+
// thread.
|
|
2682
|
+
//
|
|
2683
|
+
// Terminating the isolate here to make that uncatchable was tried and
|
|
2684
|
+
// REJECTED, for a reason worth writing down because the obvious objection is
|
|
2685
|
+
// the wrong one. V8 does see it: a termination requested from inside a host
|
|
2686
|
+
// callback survives the callback and is observed at the next interrupt check,
|
|
2687
|
+
// which is any JavaScript function entry or a loop long enough to exhaust
|
|
2688
|
+
// Ignition's interrupt budget — only a degenerate tail of host calls and
|
|
2689
|
+
// literals escapes. The problem is what happens when it escapes: the request
|
|
2690
|
+
// stays ARMED, and the sweep that clears a stale one runs only at an outermost
|
|
2691
|
+
// request (see service_request), which is exactly what a stranded operation
|
|
2692
|
+
// leaves the isolate without. It would then terminate an unrelated later
|
|
2693
|
+
// operation, which is a worse failure than the one being fixed — and it does
|
|
2694
|
+
// not even close the window it was for, since a script looping on host calls
|
|
2695
|
+
// alone reaches no interrupt check either.
|
|
2696
|
+
fn note_fatal(&self, e: &Error) {
|
|
2697
|
+
if !matches!(
|
|
2698
|
+
e.error_type(),
|
|
2699
|
+
magnus::error::ErrorType::Jump(magnus::error::Tag::Fatal)
|
|
2700
|
+
) {
|
|
2701
|
+
return;
|
|
2702
|
+
}
|
|
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
|
+
}
|
|
2719
|
+
|
|
2402
2720
|
fn call_proc(&self, ruby: &Ruby, host_fn_id: usize, args: &[JsVal]) -> Result<JsVal, String> {
|
|
2403
2721
|
let proc = {
|
|
2404
2722
|
let procs = self.procs.lock().unwrap();
|
|
@@ -2420,16 +2738,16 @@ impl Core {
|
|
|
2420
2738
|
let ruby_args = ruby.ary_new_capa(args.len());
|
|
2421
2739
|
for v in args {
|
|
2422
2740
|
ruby_args
|
|
2423
|
-
.push(jsval_to_ruby(ruby, v).map_err(|e|
|
|
2424
|
-
.map_err(|e|
|
|
2741
|
+
.push(jsval_to_ruby(ruby, v).map_err(|e| self.swallow(e))?)
|
|
2742
|
+
.map_err(|e| self.swallow(e))?;
|
|
2425
2743
|
}
|
|
2426
2744
|
// SAFETY: ruby_args is a live local (so GC keeps it and its elements) and
|
|
2427
2745
|
// is not mutated while the slice is borrowed — as_slice's contract. A VM
|
|
2428
2746
|
// op the proc issues re-enters Core::run (depth > 0) directly — no nested
|
|
2429
2747
|
// frame bookkeeping is needed any more (the call stack IS the nesting).
|
|
2430
2748
|
let result: Result<Value, Error> = proc.call(unsafe { ruby_args.as_slice() });
|
|
2431
|
-
let value = result.map_err(|e|
|
|
2432
|
-
ruby_to_jsval(value).map_err(|e|
|
|
2749
|
+
let value = result.map_err(|e| self.swallow(e))?;
|
|
2750
|
+
ruby_to_jsval(value).map_err(|e| self.swallow(e))
|
|
2433
2751
|
}
|
|
2434
2752
|
|
|
2435
2753
|
// Context#call (and call_void). Resolves a dotted function path
|
|
@@ -2522,6 +2840,7 @@ impl Core {
|
|
|
2522
2840
|
source: String,
|
|
2523
2841
|
filename: String,
|
|
2524
2842
|
timeout_ms: u64,
|
|
2843
|
+
void: bool,
|
|
2525
2844
|
) -> Result<Value, Error> {
|
|
2526
2845
|
let reply = self.run(
|
|
2527
2846
|
ruby,
|
|
@@ -2530,6 +2849,7 @@ impl Core {
|
|
|
2530
2849
|
source,
|
|
2531
2850
|
filename,
|
|
2532
2851
|
timeout_ms,
|
|
2852
|
+
void,
|
|
2533
2853
|
},
|
|
2534
2854
|
)?;
|
|
2535
2855
|
self.reply_value(ruby, reply)
|
|
@@ -2545,6 +2865,7 @@ impl Core {
|
|
|
2545
2865
|
let host_fn_id = self.procs.lock().unwrap().alloc(ProcSlot {
|
|
2546
2866
|
context_id,
|
|
2547
2867
|
proc: Some(RootedProc(BoxValue::new(proc))),
|
|
2868
|
+
name: name.clone(),
|
|
2548
2869
|
});
|
|
2549
2870
|
let reply = self.run(
|
|
2550
2871
|
ruby,
|
|
@@ -2582,6 +2903,7 @@ impl Core {
|
|
|
2582
2903
|
let id = procs.alloc(ProcSlot {
|
|
2583
2904
|
context_id,
|
|
2584
2905
|
proc: Some(RootedProc(BoxValue::new(proc))),
|
|
2906
|
+
name: name.clone(),
|
|
2585
2907
|
});
|
|
2586
2908
|
(name, id)
|
|
2587
2909
|
})
|
|
@@ -2609,11 +2931,20 @@ impl Core {
|
|
|
2609
2931
|
|
|
2610
2932
|
fn reset(&self, ruby: &Ruby, context_id: i32) -> Result<Value, Error> {
|
|
2611
2933
|
let reply = self.run(ruby, Request::Reset { context_id })?;
|
|
2612
|
-
|
|
2934
|
+
// reply_value_deferred, not reply_value: releasing the procs has to happen
|
|
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);
|
|
2613
2940
|
// Only on success — a refused reset (unknown/suspended realm) keeps
|
|
2614
|
-
// its attached fns callable.
|
|
2615
|
-
|
|
2616
|
-
|
|
2941
|
+
// its attached fns callable. No `?`: BOTH arms have to reach
|
|
2942
|
+
// resume_pending_fatal, or a kill swallowed during a reset that then
|
|
2943
|
+
// fails is left for some later operation, which drops it.
|
|
2944
|
+
if out.is_ok() {
|
|
2945
|
+
self.release_context_procs(context_id);
|
|
2946
|
+
}
|
|
2947
|
+
self.resume_pending_fatal(out)
|
|
2617
2948
|
}
|
|
2618
2949
|
|
|
2619
2950
|
// Build a new context; returns its id (replied as an Int).
|
|
@@ -2625,9 +2956,12 @@ impl Core {
|
|
|
2625
2956
|
|
|
2626
2957
|
fn dispose_context(&self, ruby: &Ruby, context_id: i32) -> Result<(), Error> {
|
|
2627
2958
|
let reply = self.run(ruby, Request::DisposeContext { context_id })?;
|
|
2628
|
-
|
|
2629
|
-
self.
|
|
2630
|
-
|
|
2959
|
+
// Deferred, and both arms delivered, for the same reasons as reset's.
|
|
2960
|
+
let out = self.reply_value_deferred(ruby, reply);
|
|
2961
|
+
if out.is_ok() {
|
|
2962
|
+
self.release_context_procs(context_id);
|
|
2963
|
+
}
|
|
2964
|
+
self.resume_pending_fatal(out.map(|_| ()))
|
|
2631
2965
|
}
|
|
2632
2966
|
|
|
2633
2967
|
// Thin ESM primitives. compile_module returns the new module's id.
|
|
@@ -2704,7 +3038,10 @@ impl Core {
|
|
|
2704
3038
|
// Reclaim THIS op's resolver error and restore the outer op's pair.
|
|
2705
3039
|
let (_, resolver_err) = self.swap_instantiate(saved_resolve, saved_err);
|
|
2706
3040
|
if let Some(exc) = resolver_err {
|
|
2707
|
-
return
|
|
3041
|
+
// Through resume_pending_fatal like the tail below: this return is
|
|
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)));
|
|
2708
3045
|
}
|
|
2709
3046
|
self.reply_value(ruby, reply?)
|
|
2710
3047
|
}
|
|
@@ -2773,12 +3110,13 @@ impl Core {
|
|
|
2773
3110
|
}
|
|
2774
3111
|
}
|
|
2775
3112
|
|
|
2776
|
-
fn run_script(&self, ruby: &Ruby, script_id: i32) -> Result<Value, Error> {
|
|
3113
|
+
fn run_script(&self, ruby: &Ruby, script_id: i32, void: bool) -> Result<Value, Error> {
|
|
2777
3114
|
let reply = self.run(
|
|
2778
3115
|
ruby,
|
|
2779
3116
|
Request::RunScript {
|
|
2780
3117
|
script_id,
|
|
2781
3118
|
timeout_ms: self.default_timeout_ms,
|
|
3119
|
+
void,
|
|
2782
3120
|
},
|
|
2783
3121
|
)?;
|
|
2784
3122
|
self.reply_value(ruby, reply)
|
|
@@ -2881,7 +3219,10 @@ impl Core {
|
|
|
2881
3219
|
if self.depth.load(Ordering::SeqCst) != 0 {
|
|
2882
3220
|
return Err(Error::new(
|
|
2883
3221
|
err_class(ruby, "Error"),
|
|
2884
|
-
"RustyRacer: cannot dispose an isolate
|
|
3222
|
+
"RustyRacer: cannot dispose an isolate with an operation still in \
|
|
3223
|
+
flight — from inside a host callback, or from anywhere at all if a \
|
|
3224
|
+
host callback yielded out of a Fiber that has not been resumed \
|
|
3225
|
+
(see https://github.com/ursm/rusty_racer#fibers)",
|
|
2885
3226
|
));
|
|
2886
3227
|
}
|
|
2887
3228
|
shared.disposed = true;
|
|
@@ -2897,13 +3238,7 @@ impl Drop for Core {
|
|
|
2897
3238
|
if self.shared.lock().unwrap().disposed {
|
|
2898
3239
|
return;
|
|
2899
3240
|
}
|
|
2900
|
-
if current_ruby_thread()
|
|
2901
|
-
// Last wrapper dropped on the owner thread: full teardown. depth is 0
|
|
2902
|
-
// (a running op holds a wrapper alive, so the last drop can't race
|
|
2903
|
-
// one), and Drop can't raise — so just tear down.
|
|
2904
|
-
self.shared.lock().unwrap().disposed = true;
|
|
2905
|
-
self.teardown();
|
|
2906
|
-
} else {
|
|
3241
|
+
if current_ruby_thread() != self.owner {
|
|
2907
3242
|
// Foreign-thread GC drop: a thread-bound isolate CANNOT be disposed
|
|
2908
3243
|
// off its owner thread (that would SEGV) and Drop CANNOT raise — so
|
|
2909
3244
|
// LEAK the OwnedIsolate (it stays in the owner thread's ISOLATES until
|
|
@@ -2912,15 +3247,45 @@ impl Drop for Core {
|
|
|
2912
3247
|
// this leak; the counter makes it observable (RustyRacer.leaked_isolate_count).
|
|
2913
3248
|
LEAKED_ISOLATES.fetch_add(1, Ordering::Relaxed);
|
|
2914
3249
|
self.watchdog.request_shutdown();
|
|
3250
|
+
return;
|
|
2915
3251
|
}
|
|
3252
|
+
// An op still in flight means a host callback yielded out of a Fiber that
|
|
3253
|
+
// was then never resumed — Ruby frees an unresumed Fiber's stack without
|
|
3254
|
+
// unwinding it, so the op's frames simply vanish and its wrapper with
|
|
3255
|
+
// them. The isolate is still ENTERED by those frames, and disposing an
|
|
3256
|
+
// entered isolate is a V8 fatal ("Disposing the isolate that is entered by
|
|
3257
|
+
// a thread"), which is the unexplained abort this binding exists to avoid.
|
|
3258
|
+
// Leak it instead: the process is on its way out anyway, and a leak that
|
|
3259
|
+
// says why beats a fatal that doesn't. This is the quiet twin of
|
|
3260
|
+
// fatal_nesting_broken — same cause, but nothing ever came back to detect
|
|
3261
|
+
// it, so it surfaces here at teardown.
|
|
3262
|
+
if self.depth.load(Ordering::SeqCst) != 0 {
|
|
3263
|
+
eprintln!(
|
|
3264
|
+
"\n[rusty_racer] warning: an isolate is being discarded with an \
|
|
3265
|
+
operation still in flight,\n\
|
|
3266
|
+
so it cannot be disposed and is leaked.\n\
|
|
3267
|
+
The usual cause is a host function that yielded out of a Fiber \
|
|
3268
|
+
which was never resumed;\n\
|
|
3269
|
+
`Fiber#kill` unwinds one cleanly if you still hold it. See\n\
|
|
3270
|
+
https://github.com/ursm/rusty_racer#fibers\n"
|
|
3271
|
+
);
|
|
3272
|
+
LEAKED_ISOLATES.fetch_add(1, Ordering::Relaxed);
|
|
3273
|
+
self.watchdog.request_shutdown();
|
|
3274
|
+
return;
|
|
3275
|
+
}
|
|
3276
|
+
// Last wrapper dropped on the owner thread with nothing in flight: full
|
|
3277
|
+
// teardown. Drop can't raise, so just tear down.
|
|
3278
|
+
self.shared.lock().unwrap().disposed = true;
|
|
3279
|
+
self.teardown();
|
|
2916
3280
|
}
|
|
2917
3281
|
}
|
|
2918
3282
|
|
|
2919
3283
|
// RustyRacer.live_isolate_count -> Integer: isolates currently in the registry
|
|
2920
3284
|
// (created, not yet disposed). RustyRacer.leaked_isolate_count -> Integer:
|
|
2921
|
-
// isolates that could not be disposed because their last wrapper was dropped
|
|
2922
|
-
// the owner thread
|
|
2923
|
-
//
|
|
3285
|
+
// isolates that could not be disposed — because their last wrapper was dropped
|
|
3286
|
+
// off the owner thread, or because an operation was still in flight (see Drop
|
|
3287
|
+
// for both) — a workload that churns owner threads should keep this at 0 by
|
|
3288
|
+
// disposing on the owner thread.
|
|
2924
3289
|
fn live_isolate_count() -> usize {
|
|
2925
3290
|
isolates().lock().unwrap().len()
|
|
2926
3291
|
}
|
|
@@ -3008,12 +3373,14 @@ impl Context {
|
|
|
3008
3373
|
Ok(())
|
|
3009
3374
|
}
|
|
3010
3375
|
// timeout_ms 0 = use the isolate's default; an explicit value overrides it.
|
|
3376
|
+
// |void| discards the completion value instead of marshalling it.
|
|
3011
3377
|
fn eval(
|
|
3012
3378
|
ruby: &Ruby,
|
|
3013
3379
|
rb_self: &Self,
|
|
3014
3380
|
source: String,
|
|
3015
3381
|
timeout_ms: u64,
|
|
3016
3382
|
filename: String,
|
|
3383
|
+
void: bool,
|
|
3017
3384
|
) -> Result<Value, Error> {
|
|
3018
3385
|
rb_self.check_live(ruby)?;
|
|
3019
3386
|
let timeout = if timeout_ms == 0 {
|
|
@@ -3023,7 +3390,7 @@ impl Context {
|
|
|
3023
3390
|
};
|
|
3024
3391
|
rb_self
|
|
3025
3392
|
.core
|
|
3026
|
-
.eval_t(ruby, rb_self.id, source, filename, timeout)
|
|
3393
|
+
.eval_t(ruby, rb_self.id, source, filename, timeout, void)
|
|
3027
3394
|
}
|
|
3028
3395
|
fn call(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result<Value, Error> {
|
|
3029
3396
|
rb_self.check_live(ruby)?;
|
|
@@ -3285,7 +3652,13 @@ impl Script {
|
|
|
3285
3652
|
// thrown exception is a RuntimeError; a timeout/stop a ScriptTerminatedError.
|
|
3286
3653
|
fn run(ruby: &Ruby, rb_self: &Self) -> Result<Value, Error> {
|
|
3287
3654
|
rb_self.check_live(ruby)?;
|
|
3288
|
-
rb_self.core.run_script(ruby, rb_self.script_id)
|
|
3655
|
+
rb_self.core.run_script(ruby, rb_self.script_id, false)
|
|
3656
|
+
}
|
|
3657
|
+
// Run it for its effect only, discarding the completion value (see
|
|
3658
|
+
// Context#eval_void) — what a <script> tag does.
|
|
3659
|
+
fn run_void(ruby: &Ruby, rb_self: &Self) -> Result<Value, Error> {
|
|
3660
|
+
rb_self.check_live(ruby)?;
|
|
3661
|
+
rb_self.core.run_script(ruby, rb_self.script_id, true)
|
|
3289
3662
|
}
|
|
3290
3663
|
fn cached_data(ruby: &Ruby, rb_self: &Self) -> Value {
|
|
3291
3664
|
code_cache_value(ruby, rb_self.cached_data.as_ref())
|
|
@@ -3506,8 +3879,9 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
3506
3879
|
|
|
3507
3880
|
// A v8::Context (realm): eval/call/attach/compile_module.
|
|
3508
3881
|
let context = module.define_class("Context", ruby.class_object())?;
|
|
3509
|
-
// keyword-arg
|
|
3510
|
-
|
|
3882
|
+
// Backs both keyword-arg wrappers in lib: Context#eval(source, timeout_ms:,
|
|
3883
|
+
// filename:) and #eval_void, which differ only in the 4th arg (the void flag).
|
|
3884
|
+
context.define_method("_eval", method!(Context::eval, 4))?;
|
|
3511
3885
|
context.define_method("call", method!(Context::call, -1))?;
|
|
3512
3886
|
context.define_method("call_void", method!(Context::call_void, -1))?;
|
|
3513
3887
|
context.define_method("attach", method!(Context::attach, 2))?;
|
|
@@ -3523,6 +3897,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
3523
3897
|
// Classic compiled script: Context#compile -> #run / #cached_data.
|
|
3524
3898
|
let script = module.define_class("Script", ruby.class_object())?;
|
|
3525
3899
|
script.define_method("run", method!(Script::run, 0))?;
|
|
3900
|
+
script.define_method("run_void", method!(Script::run_void, 0))?;
|
|
3526
3901
|
script.define_method("cached_data", method!(Script::cached_data, 0))?;
|
|
3527
3902
|
script.define_method("cache_rejected?", method!(Script::cache_rejected, 0))?;
|
|
3528
3903
|
script.define_method("create_code_cache", method!(Script::create_code_cache, 0))?;
|
data/ext/rusty_racer/src/ops.rs
CHANGED
|
@@ -51,6 +51,11 @@ pub(crate) enum Request {
|
|
|
51
51
|
source: String,
|
|
52
52
|
filename: String,
|
|
53
53
|
timeout_ms: u64,
|
|
54
|
+
// void = don't marshal the completion value (see Call's |void|). A
|
|
55
|
+
// statement-list eval run for its EFFECT still has one — the last
|
|
56
|
+
// statement's value — and marshalling it can throw (an accessor, a
|
|
57
|
+
// Proxy trap) or walk a huge graph, for a value the caller discards.
|
|
58
|
+
void: bool,
|
|
54
59
|
},
|
|
55
60
|
// Resolve a dotted function path on globalThis and invoke it with marshalled
|
|
56
61
|
// args (v8::Function::call), preserving the holder as `this`. Distinct from
|
|
@@ -146,10 +151,12 @@ pub(crate) enum Request {
|
|
|
146
151
|
produce_cache: bool,
|
|
147
152
|
eager: bool,
|
|
148
153
|
},
|
|
149
|
-
// Bind the script to its context and run it; returns the completion value
|
|
154
|
+
// Bind the script to its context and run it; returns the completion value
|
|
155
|
+
// (unless |void| — see Eval).
|
|
150
156
|
RunScript {
|
|
151
157
|
script_id: i32,
|
|
152
158
|
timeout_ms: u64,
|
|
159
|
+
void: bool,
|
|
153
160
|
},
|
|
154
161
|
DisposeScript {
|
|
155
162
|
script_id: i32,
|
|
@@ -220,6 +227,7 @@ pub(crate) fn run_source(
|
|
|
220
227
|
scope: &mut v8::PinScope<'_, '_>,
|
|
221
228
|
source: &str,
|
|
222
229
|
filename: &str,
|
|
230
|
+
void: bool,
|
|
223
231
|
) -> Result<JsVal, VmError> {
|
|
224
232
|
v8::tc_scope!(let tc, scope);
|
|
225
233
|
// Compile and run as distinct phases so a compile failure maps to
|
|
@@ -252,6 +260,7 @@ pub(crate) fn run_source(
|
|
|
252
260
|
}
|
|
253
261
|
};
|
|
254
262
|
match script.run(tc) {
|
|
263
|
+
Some(_) if void => Ok(JsVal::Undefined),
|
|
255
264
|
Some(value) => marshalled!(tc, value),
|
|
256
265
|
None if tc.has_terminated() => Err(VmError::Terminated),
|
|
257
266
|
None => {
|
|
@@ -448,7 +457,10 @@ fn dispatch_one(
|
|
|
448
457
|
source,
|
|
449
458
|
filename,
|
|
450
459
|
timeout_ms,
|
|
451
|
-
|
|
460
|
+
void,
|
|
461
|
+
} => op_eval(
|
|
462
|
+
scope, context_id, source, filename, timeout_ms, void, outermost,
|
|
463
|
+
),
|
|
452
464
|
Request::Call {
|
|
453
465
|
context_id,
|
|
454
466
|
name,
|
|
@@ -515,7 +527,8 @@ fn dispatch_one(
|
|
|
515
527
|
Request::RunScript {
|
|
516
528
|
script_id,
|
|
517
529
|
timeout_ms,
|
|
518
|
-
|
|
530
|
+
void,
|
|
531
|
+
} => op_run_script(scope, script_id, timeout_ms, void, outermost),
|
|
519
532
|
Request::DisposeScript { script_id } => op_dispose_script(scope, script_id),
|
|
520
533
|
// Serialize the script's CURRENT compile state. The stored handle is
|
|
521
534
|
// the UnboundScript, which V8 fills in with inner-function bytecode as
|
|
@@ -558,12 +571,14 @@ fn op_low_memory_notification(scope: &mut v8::PinScope<'_, '_, ()>) -> VmReply {
|
|
|
558
571
|
VmReply::Done(Ok(JsVal::Undefined))
|
|
559
572
|
}
|
|
560
573
|
|
|
574
|
+
#[allow(clippy::too_many_arguments)]
|
|
561
575
|
fn op_eval(
|
|
562
576
|
scope: &mut v8::PinScope<'_, '_, ()>,
|
|
563
577
|
context_id: i32,
|
|
564
578
|
source: String,
|
|
565
579
|
filename: String,
|
|
566
580
|
timeout_ms: u64,
|
|
581
|
+
void: bool,
|
|
567
582
|
outermost: bool,
|
|
568
583
|
) -> VmReply {
|
|
569
584
|
let outcome = run_js_bracketed(scope, outermost, timeout_ms, "eval", |scope, outermost| {
|
|
@@ -572,7 +587,7 @@ fn op_eval(
|
|
|
572
587
|
Some(ctx) => {
|
|
573
588
|
let context = v8::Local::new(scope, &ctx);
|
|
574
589
|
let scope = &mut v8::ContextScope::new(scope, context);
|
|
575
|
-
let out = run_source(scope, &source, &filename);
|
|
590
|
+
let out = run_source(scope, &source, &filename, void);
|
|
576
591
|
auto_drain(scope, outermost);
|
|
577
592
|
(true, out)
|
|
578
593
|
}
|
|
@@ -1278,6 +1293,7 @@ fn op_run_script(
|
|
|
1278
1293
|
scope: &mut v8::PinScope<'_, '_, ()>,
|
|
1279
1294
|
script_id: i32,
|
|
1280
1295
|
timeout_ms: u64,
|
|
1296
|
+
void: bool,
|
|
1281
1297
|
outermost: bool,
|
|
1282
1298
|
) -> VmReply {
|
|
1283
1299
|
let outcome = run_js_bracketed(
|
|
@@ -1302,6 +1318,7 @@ fn op_run_script(
|
|
|
1302
1318
|
let out = {
|
|
1303
1319
|
v8::tc_scope!(let tc, scope);
|
|
1304
1320
|
match script.run(tc) {
|
|
1321
|
+
Some(_) if void => Ok(JsVal::Undefined),
|
|
1305
1322
|
Some(value) => marshalled!(tc, value),
|
|
1306
1323
|
None if tc.has_terminated() => Err(VmError::Terminated),
|
|
1307
1324
|
None => {
|
data/lib/rusty_racer/execjs.rb
CHANGED
|
@@ -35,9 +35,15 @@ module RustyRacer
|
|
|
35
35
|
# ExecJS guarantees a bare global (no browser/Node ambient): V8 installs a
|
|
36
36
|
# default `console`, so drop it to match the contract (consumers attach
|
|
37
37
|
# their own if needed), exactly as the mini_racer runtime does.
|
|
38
|
-
@context.
|
|
38
|
+
@context.eval_void('delete globalThis.console')
|
|
39
39
|
source = encode(source)
|
|
40
|
-
|
|
40
|
+
# eval_void: a bundle is run to POPULATE the context, and its completion
|
|
41
|
+
# value — the last thing a UMD wrapper happened to evaluate — is nobody's
|
|
42
|
+
# answer. Marshalling it would walk the whole exports graph on every
|
|
43
|
+
# context creation, and a lazy/hostile property there would fail the
|
|
44
|
+
# constructor over a value ExecJS discards. #exec/#eval below must NOT
|
|
45
|
+
# use it: they read the JSON their wrapper returns.
|
|
46
|
+
translate { @context.eval_void(source, filename: LOCATION) } if /\S/.match?(source)
|
|
41
47
|
end
|
|
42
48
|
|
|
43
49
|
# Run statements in a function body and return what they `return` (nil when
|
data/lib/rusty_racer/version.rb
CHANGED
data/lib/rusty_racer.rb
CHANGED
|
@@ -112,7 +112,18 @@ module RustyRacer
|
|
|
112
112
|
# `timeout_ms` (0 = the isolate default) caps this eval; `filename` names the
|
|
113
113
|
# script in stack traces and parse-error locations.
|
|
114
114
|
def eval(source, timeout_ms: 0, filename: '<eval>')
|
|
115
|
-
_eval(source, timeout_ms, filename)
|
|
115
|
+
_eval(source, timeout_ms, filename, false)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Run `source` for its EFFECT and return nil, without marshalling its
|
|
119
|
+
# completion value — the counterpart of #call_void, and what a <script> tag
|
|
120
|
+
# does. Worth reaching for whenever the value is incidental: a statement
|
|
121
|
+
# list still has one (its last statement's), and marshalling reads the
|
|
122
|
+
# value, which runs JS — a getter or a Proxy trap can throw, failing an eval
|
|
123
|
+
# whose writes all landed. `globalThis.win = someProxy` is the shape that
|
|
124
|
+
# bites.
|
|
125
|
+
def eval_void(source, timeout_ms: 0, filename: '<eval>')
|
|
126
|
+
_eval(source, timeout_ms, filename, true)
|
|
116
127
|
end
|
|
117
128
|
|
|
118
129
|
# Compile a classic <script>; returns a RustyRacer::Script to #run.
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rusty_racer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.2
|
|
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-08-
|
|
11
|
+
date: 2026-08-26 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: []
|