rusty_racer 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 23ab2fe2f98436d0c48cc380809611878f1a7fc0b367d8b4d677a822e718efd4
4
- data.tar.gz: c23f8e67fb71a192331d63030d66d138dd9b1cdd136b264107b75456f762516b
3
+ metadata.gz: f6e242a3632078fa63367b6e4d81ee7d720b6cfaef8355067137444b6fd48441
4
+ data.tar.gz: 1996ff995ae82ca4dba4de4e17c2e577d820b606bed710813e27e32d7c7a178e
5
5
  SHA512:
6
- metadata.gz: 9f1144b81ef5f02bac43efe2790974f50e4cfcbbca924bea182602c2615419178f5f76376c14daee424e6c7dbbaee93152cf8969275ee61c5bf8891d1a8124b0
7
- data.tar.gz: cea6a7fbf475ad7ae58cce4556ed1834d3a7aabb3bdf832fc82060b4e386e9ff0e9572dd32a4b759c51e66eedcb3478ed085e49c841958aa2fc20595ce9d55d2
6
+ metadata.gz: 7bca218e9cb0384d7c4e61b8bb5d1c748eb38393163b5d104803698c8f51fe6fae9b4600d4a815be5b98b70a3dbce7ea60b2219d596a652e2f3b9c8144f50a55
7
+ data.tar.gz: 05b6aa55215bb555000fb9b99b698ac974f74d73a4c9687d04f6fa84039d4890d92be58e7edf68a78befb98919e329e34d8c95e938cd886cf05d469219d2039a
data/README.md CHANGED
@@ -154,6 +154,24 @@ Also available:
154
154
  isolate's heap. All realms are mutually same-origin (with a host namespace,
155
155
  `NS.contextGlobal(id)` reaches another realm's `globalThis`, like a same-origin
156
156
  `iframe.contentWindow`), so this is **not** an isolation boundary.
157
+ - **Zero-copy buffer transfer/share between isolates** — with a host namespace, the
158
+ `NS` object exposes the engine primitive for implementing `postMessage`
159
+ transferables and `SharedArrayBuffer` sharing, both with no byte copy:
160
+ - **Transfer** (the transfer list): `NS.transferOut(arrayBufferOrView)` detaches
161
+ a buffer (its `byteLength` → 0) and returns an integer token; `NS.transferIn(token)`
162
+ rebuilds an `ArrayBuffer` over the **same** memory in another isolate.
163
+ - **Share** (a `SharedArrayBuffer`): `NS.shareOut(sharedArrayBuffer)` returns a
164
+ token *without* detaching — the source stays live; `NS.shareIn(token)` rebuilds
165
+ a `SharedArrayBuffer` over the same memory, so both isolates see each other's
166
+ writes and `Atomics` work across them (including across threads).
167
+
168
+ The backing store is heap-external and atomic-refcounted, so a token stays
169
+ importable even after the exporting isolate is disposed. `transferOut`/`shareOut`
170
+ return `0` for an unsuitable argument (so the caller can fall back to a copy);
171
+ `transferIn`/`shareIn` return `undefined` for an unknown or wrong-kind token;
172
+ `NS.transferDrop(token)` releases an exported buffer that is never imported.
173
+ `RustyRacer.pending_transfer_count` reports exported-but-not-yet-imported buffers
174
+ so dropped messages don't leak silently.
157
175
  - **`Isolate#perform_microtask_checkpoint`** — manual microtask drain. The default
158
176
  `microtasks: :auto` also drains at the end of each outermost eval/call/evaluate;
159
177
  `microtasks: :explicit` leaves it fully manual. There is no event loop or timers
@@ -4,7 +4,7 @@
4
4
  # libv8-rusty needed under the cibuildgem native-per-platform model).
5
5
  [package]
6
6
  name = "rusty_racer"
7
- version = "0.1.5"
7
+ version = "0.1.7"
8
8
  edition = "2021"
9
9
  publish = false
10
10
 
@@ -647,6 +647,54 @@ static NEXT_ISOLATE_ID: std::sync::atomic::AtomicU32 = std::sync::atomic::Atomic
647
647
  // dispose isolates explicitly on their owner thread before that thread exits.
648
648
  static LEAKED_ISOLATES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
649
649
 
650
+ // ── zero-copy ArrayBuffer transfer registry ────────────────────────────────
651
+ //
652
+ // A SharedRef<BackingStore> parked here so a buffer can cross from one isolate to
653
+ // another with NO byte copy. A backing store is V8's own heap-external,
654
+ // atomic-refcounted allocation, designed to be co-owned by live ArrayBuffers "even
655
+ // across isolates" (rusty_v8 ArrayBuffer docs). The embedder (capybara-simulated's
656
+ // cross-isolate postMessage transport) exports a buffer from the source realm,
657
+ // ships the returned integer token through its normal message plumbing, and
658
+ // rebuilds a buffer over the SAME memory in the destination realm — replacing the
659
+ // prior copy-out/copy-in of the bytes. Two flavours share this registry:
660
+ // - TRANSFER (NS.transferOut/transferIn): an ArrayBuffer; the source is detached
661
+ // (moved). The recipient gets an ArrayBuffer.
662
+ // - SHARE (NS.shareOut/shareIn): a SharedArrayBuffer; the source stays live and
663
+ // both realms see each other's writes (Atomics work across them). The
664
+ // recipient gets a SharedArrayBuffer.
665
+ // `shared` records which, so the matching import rebuilds the right type and a
666
+ // crossed import (transferIn of a share token, or vice versa) is refused.
667
+ //
668
+ // SharedRef<BackingStore> is NOT auto-Send (BackingStore is Send but not Sync, so
669
+ // SharedPtrBase's `T: Sync` Send-bound is unmet). It rides in this struct with a
670
+ // manual Send for the same reason as SendIso/IsoPtr: the registry only ever MOVES
671
+ // the handle between owner threads (insert on the exporter's thread, remove on the
672
+ // importer's) under the Mutex, never sharing &handle concurrently — and shared_ptr's
673
+ // refcount is atomic, so dropping it on either thread is sound. No Sync impl: the
674
+ // handle is never aliased across threads.
675
+ struct SendBackingStore {
676
+ store: v8::SharedRef<v8::BackingStore>,
677
+ shared: bool,
678
+ }
679
+ unsafe impl Send for SendBackingStore {}
680
+
681
+ // Exported-but-not-yet-imported backing stores, keyed by token. A GLOBAL (not
682
+ // per-isolate): the whole point is to bridge two isolates, and tokens are unique
683
+ // process-wide. An exported buffer that is never imported (a dropped message)
684
+ // pins its memory here until NS.transferDrop releases it — RustyRacer
685
+ // .pending_transfer_count makes that observable.
686
+ static TRANSFER_REGISTRY: std::sync::OnceLock<Mutex<HashMap<u64, SendBackingStore>>> =
687
+ std::sync::OnceLock::new();
688
+
689
+ fn transfer_registry() -> &'static Mutex<HashMap<u64, SendBackingStore>> {
690
+ TRANSFER_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
691
+ }
692
+
693
+ // Monotonic token source. Starts at 1 so 0 is free as the JS-side "not
694
+ // transferable — fall back to a copy" sentinel. A token is f64-exact for ~2^53
695
+ // transfers, which a process will never reach.
696
+ static NEXT_TRANSFER_TOKEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
697
+
650
698
  // Run a microtask checkpoint with DRAINING set (nesting-safe via save/restore),
651
699
  // so a nested Reset/DisposeContext issued by a drained microtask is refused.
652
700
  fn checkpoint_draining(scope: &mut v8::PinScope<'_, '_>) {
@@ -1317,6 +1365,155 @@ fn set_promise_reject_handler(
1317
1365
  }
1318
1366
  }
1319
1367
 
1368
+ // NS.transferOut(arrayBufferOrView) -> a positive integer token, or 0 when the
1369
+ // argument can't be zero-copy transferred so the caller falls back to a copy.
1370
+ // Grabs the buffer's backing store, DETACHES the source (transfer semantics: its
1371
+ // byteLength -> 0, like a structured-clone transfer list), and parks the store
1372
+ // under the returned token. The bytes are untouched — only the source realm
1373
+ // loses access. A view transfers its whole underlying ArrayBuffer.
1374
+ //
1375
+ // Returns 0 (copy fallback) for: a non-buffer argument; a non-detachable buffer
1376
+ // (WebAssembly/asm.js memory); and a SharedArrayBuffer or SAB-backed view — a SAB
1377
+ // is *shared*, not transferred (detaching it would be a category error), so it
1378
+ // belongs to NS.shareOut, not here.
1379
+ fn transfer_out(
1380
+ scope: &mut v8::PinScope<'_, '_>,
1381
+ args: v8::FunctionCallbackArguments<'_>,
1382
+ mut rv: v8::ReturnValue<'_, v8::Value>,
1383
+ ) {
1384
+ let value = args.get(0);
1385
+ let ab = if value.is_array_buffer() {
1386
+ v8::Local::<v8::ArrayBuffer>::try_from(value).ok()
1387
+ } else if value.is_array_buffer_view() {
1388
+ v8::Local::<v8::ArrayBufferView>::try_from(value)
1389
+ .ok()
1390
+ .and_then(|view| view.buffer(scope))
1391
+ } else {
1392
+ None
1393
+ };
1394
+ // Only transfer what we can neuter: zero-copy sharing a buffer the source
1395
+ // realm keeps live would alias mutable memory across isolates. Caller copies.
1396
+ let Some(ab) = ab.filter(|ab| ab.is_detachable()) else {
1397
+ rv.set(v8::Number::new(scope, 0.0).into());
1398
+ return;
1399
+ };
1400
+ // Grab the store BEFORE detaching — detach severs the buffer from it; the
1401
+ // SharedRef then keeps the memory alive on its own via the atomic refcount.
1402
+ let store = ab.get_backing_store();
1403
+ // is_detachable() does NOT guarantee detach succeeds: a buffer carrying an
1404
+ // [[ArrayBufferDetachKey]] returns None here and stays live. Register only
1405
+ // once the source is genuinely neutered, else both realms would alias one
1406
+ // backing store. (rusty_racer sets no detach keys today, so this is belt-and-
1407
+ // braces against the safety invariant ever silently breaking.)
1408
+ if ab.detach(None) != Some(true) {
1409
+ rv.set(v8::Number::new(scope, 0.0).into());
1410
+ return;
1411
+ }
1412
+ let token = NEXT_TRANSFER_TOKEN.fetch_add(1, Ordering::Relaxed);
1413
+ transfer_registry()
1414
+ .lock()
1415
+ .unwrap()
1416
+ .insert(token, SendBackingStore { store, shared: false });
1417
+ rv.set(v8::Number::new(scope, token as f64).into());
1418
+ }
1419
+
1420
+ // NS.shareOut(sharedArrayBuffer) -> a positive integer token, or 0 for a
1421
+ // non-SharedArrayBuffer argument (so the caller falls back to a copy). Unlike
1422
+ // transferOut this does NOT detach: a SAB is *shared*, so the source realm keeps
1423
+ // its live SAB and the recipient's shareIn'd SAB sees the same memory (Atomics
1424
+ // work across them). Takes a bare SAB; for a SAB-backed view the caller passes
1425
+ // view.buffer.
1426
+ fn share_out(
1427
+ scope: &mut v8::PinScope<'_, '_>,
1428
+ args: v8::FunctionCallbackArguments<'_>,
1429
+ mut rv: v8::ReturnValue<'_, v8::Value>,
1430
+ ) {
1431
+ let Ok(sab) = v8::Local::<v8::SharedArrayBuffer>::try_from(args.get(0)) else {
1432
+ rv.set(v8::Number::new(scope, 0.0).into());
1433
+ return;
1434
+ };
1435
+ let store = sab.get_backing_store();
1436
+ let token = NEXT_TRANSFER_TOKEN.fetch_add(1, Ordering::Relaxed);
1437
+ transfer_registry()
1438
+ .lock()
1439
+ .unwrap()
1440
+ .insert(token, SendBackingStore { store, shared: true });
1441
+ rv.set(v8::Number::new(scope, token as f64).into());
1442
+ }
1443
+
1444
+ // Parse a transfer-token argument: a finite, integral JS number >= 1 (0 is the
1445
+ // "not transferable" sentinel the *Out fns never issue, and tokens are f64-exact
1446
+ // only up to 2^53). Rejects NaN / Infinity / fractional / negative / out-of-range
1447
+ // values — which a bare `as u64` would truncate or saturate into a COLLISION with
1448
+ // a live token, stealing an unrelated transfer — so a malformed token instead
1449
+ // reads as "unknown" (transferIn -> undefined, transferDrop -> no-op). Must run
1450
+ // before locking the registry: number_value can invoke a user valueOf/
1451
+ // Symbol.toPrimitive, i.e. re-enter these callbacks.
1452
+ fn transfer_token(scope: &mut v8::PinScope<'_, '_>, value: v8::Local<v8::Value>) -> Option<u64> {
1453
+ let n = value.number_value(scope)?;
1454
+ (n.is_finite() && n.fract() == 0.0 && (1.0..=9_007_199_254_740_992.0).contains(&n))
1455
+ .then_some(n as u64)
1456
+ }
1457
+
1458
+ // Consume a token whose kind matches |want_shared| (a transfer token for
1459
+ // transferIn, a share token for shareIn). Peeks the kind under the lock and only
1460
+ // removes on a match, so a crossed import (transferIn of a share token, or vice
1461
+ // versa) leaves the entry intact and reads as undefined instead of rebuilding the
1462
+ // wrong buffer type over the memory.
1463
+ fn take_store(token: u64, want_shared: bool) -> Option<v8::SharedRef<v8::BackingStore>> {
1464
+ let mut reg = transfer_registry().lock().unwrap();
1465
+ match reg.get(&token) {
1466
+ Some(e) if e.shared == want_shared => reg.remove(&token).map(|e| e.store),
1467
+ _ => None,
1468
+ }
1469
+ }
1470
+
1471
+ // NS.transferIn(token) -> a fresh ArrayBuffer over the transferred backing store
1472
+ // (no byte copy), or undefined for an unknown/crossed token (already imported, the
1473
+ // message was dropped, or it was a share token). Consumes the token: the new
1474
+ // buffer co-owns the store, so the registry's reference is released.
1475
+ fn transfer_in(
1476
+ scope: &mut v8::PinScope<'_, '_>,
1477
+ args: v8::FunctionCallbackArguments<'_>,
1478
+ mut rv: v8::ReturnValue<'_, v8::Value>,
1479
+ ) {
1480
+ let Some(token) = transfer_token(scope, args.get(0)) else {
1481
+ return;
1482
+ };
1483
+ if let Some(store) = take_store(token, false) {
1484
+ rv.set(v8::ArrayBuffer::with_backing_store(scope, &store).into());
1485
+ }
1486
+ }
1487
+
1488
+ // NS.shareIn(token) -> a fresh SharedArrayBuffer over the shared backing store
1489
+ // (same memory as the source's SAB), or undefined for an unknown/crossed token.
1490
+ fn share_in(
1491
+ scope: &mut v8::PinScope<'_, '_>,
1492
+ args: v8::FunctionCallbackArguments<'_>,
1493
+ mut rv: v8::ReturnValue<'_, v8::Value>,
1494
+ ) {
1495
+ let Some(token) = transfer_token(scope, args.get(0)) else {
1496
+ return;
1497
+ };
1498
+ if let Some(store) = take_store(token, true) {
1499
+ rv.set(v8::SharedArrayBuffer::with_backing_store(scope, &store).into());
1500
+ }
1501
+ }
1502
+
1503
+ // NS.transferDrop(token): release a parked backing store (transfer OR share)
1504
+ // WITHOUT importing it — for when the embedder discards a message whose buffer was
1505
+ // exported. A no-op for an unknown token. Without this, an exported-but-never-
1506
+ // imported buffer would pin its memory until process exit.
1507
+ fn transfer_drop(
1508
+ scope: &mut v8::PinScope<'_, '_>,
1509
+ args: v8::FunctionCallbackArguments<'_>,
1510
+ _rv: v8::ReturnValue<'_, v8::Value>,
1511
+ ) {
1512
+ if let Some(token) = transfer_token(scope, args.get(0)) {
1513
+ transfer_registry().lock().unwrap().remove(&token);
1514
+ }
1515
+ }
1516
+
1320
1517
  // V8 calls this synchronously on promise rejections with no handler (and the
1321
1518
  // later revocations when a handler IS added). Forwards
1322
1519
  // (event, contextId, promise, reason) to the registered JS recorder — the
@@ -1433,7 +1630,7 @@ fn install_host_namespace(
1433
1630
  let context = v8::Local::new(scope, ctx);
1434
1631
  let scope = &mut v8::ContextScope::new(scope, context);
1435
1632
  let ns = v8::Object::new(scope);
1436
- let members: [(&str, Option<v8::Local<v8::Function>>); 4] = [
1633
+ let members: [(&str, Option<v8::Local<v8::Function>>); 9] = [
1437
1634
  ("drainMicrotasks", v8::Function::new(scope, drain_microtasks)),
1438
1635
  ("contextGlobal", v8::Function::new(scope, context_global)),
1439
1636
  ("contextOf", v8::Function::new(scope, context_of)),
@@ -1441,6 +1638,11 @@ fn install_host_namespace(
1441
1638
  "setPromiseRejectHandler",
1442
1639
  v8::Function::new(scope, set_promise_reject_handler),
1443
1640
  ),
1641
+ ("transferOut", v8::Function::new(scope, transfer_out)),
1642
+ ("transferIn", v8::Function::new(scope, transfer_in)),
1643
+ ("shareOut", v8::Function::new(scope, share_out)),
1644
+ ("shareIn", v8::Function::new(scope, share_in)),
1645
+ ("transferDrop", v8::Function::new(scope, transfer_drop)),
1444
1646
  ];
1445
1647
  for (member, function) in members {
1446
1648
  if let (Some(f), Some(k)) = (function, v8::String::new(scope, member)) {
@@ -2284,6 +2486,17 @@ fn leaked_isolate_count() -> usize {
2284
2486
  LEAKED_ISOLATES.load(Ordering::Relaxed)
2285
2487
  }
2286
2488
 
2489
+ // RustyRacer.pending_transfer_count -> Integer: backing stores exported via
2490
+ // NS.transferOut but not yet imported (NS.transferIn) or released
2491
+ // (NS.transferDrop). PROCESS-WIDE (the registry bridges isolates, so the count
2492
+ // aggregates every isolate's outstanding transfers — compare deltas, not the
2493
+ // absolute value, when isolates run concurrently). A transport that always pairs
2494
+ // an export with one of those keeps its own contribution at 0; a rising count
2495
+ // means dropped messages are leaking transferred memory.
2496
+ fn pending_transfer_count() -> usize {
2497
+ transfer_registry().lock().unwrap().len()
2498
+ }
2499
+
2287
2500
  // Thin magnus-method wrappers.
2288
2501
  // Isolate = the VM and its isolate-level operations; it hands out Contexts.
2289
2502
  impl Isolate {
@@ -2818,5 +3031,6 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
2818
3031
  // Observability for the thread-confined lifecycle (see Drop for Core).
2819
3032
  module.define_singleton_method("live_isolate_count", function!(live_isolate_count, 0))?;
2820
3033
  module.define_singleton_method("leaked_isolate_count", function!(leaked_isolate_count, 0))?;
3034
+ module.define_singleton_method("pending_transfer_count", function!(pending_transfer_count, 0))?;
2821
3035
  Ok(())
2822
3036
  }
@@ -6,6 +6,9 @@
6
6
  // STACK_DEBUG (set at init); everything else is private to this module.
7
7
 
8
8
  use std::ffi::c_void;
9
+ // Only native_stack_bounds (linux) needs it; gated so non-linux builds (macOS)
10
+ // don't warn it unused.
11
+ #[cfg(target_os = "linux")]
9
12
  use std::ptr::null_mut;
10
13
  use std::sync::atomic::{AtomicBool, Ordering};
11
14
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RustyRacer
4
- VERSION = "0.1.5"
4
+ VERSION = "0.1.7"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rusty_racer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keita Urashima