rusty_racer 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 23ab2fe2f98436d0c48cc380809611878f1a7fc0b367d8b4d677a822e718efd4
4
- data.tar.gz: c23f8e67fb71a192331d63030d66d138dd9b1cdd136b264107b75456f762516b
3
+ metadata.gz: 6d14d201ab91d7e00d99d448826a92ad13c5b082b3ebe4b9d1f03778f89abf04
4
+ data.tar.gz: 3dc4d0bb904458984b24e12806dd756f17ae9f541f479df277e218fe8cb70478
5
5
  SHA512:
6
- metadata.gz: 9f1144b81ef5f02bac43efe2790974f50e4cfcbbca924bea182602c2615419178f5f76376c14daee424e6c7dbbaee93152cf8969275ee61c5bf8891d1a8124b0
7
- data.tar.gz: cea6a7fbf475ad7ae58cce4556ed1834d3a7aabb3bdf832fc82060b4e386e9ff0e9572dd32a4b759c51e66eedcb3478ed085e49c841958aa2fc20595ce9d55d2
6
+ metadata.gz: 856d5dbbb8c3c70b1a4a574c85711c2bb89b3d343eaf4316d7714249ba5f6c04113187a728155627634a4eedcca0901cd427734e9fe9e89e9051fe0ffdaf4a6b
7
+ data.tar.gz: 07dd013c094a65e09e2e32ce97a2ac874b2b1dd357e440914a4fdd282612bae3afce4c41bfcc62302457573a7d8681e91117186f0d32cd0aefff27160032bdfa
data/README.md CHANGED
@@ -154,6 +154,19 @@ 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 between isolates** — with a host namespace,
158
+ `NS.transferOut(arrayBufferOrView)` detaches a buffer (its `byteLength` → 0) and
159
+ returns an integer token; `NS.transferIn(token)` rebuilds an `ArrayBuffer` over
160
+ the **same** memory in another isolate with no byte copy (the backing store is
161
+ heap-external and atomic-refcounted, so the token stays importable even after the
162
+ exporting isolate is disposed). `NS.transferOut` returns `0` for a non-buffer or
163
+ non-detachable argument — including a `SharedArrayBuffer`, which is *shared*, not
164
+ transferred — so the caller can fall back to a copy; `NS.transferIn` returns
165
+ `undefined` for an unknown token; `NS.transferDrop(token)` releases an exported
166
+ buffer that is never imported. This is the engine primitive for
167
+ implementing `postMessage` transferables; `RustyRacer.pending_transfer_count`
168
+ reports exported-but-not-yet-imported buffers so dropped messages don't leak
169
+ silently.
157
170
  - **`Isolate#perform_microtask_checkpoint`** — manual microtask drain. The default
158
171
  `microtasks: :auto` also drains at the end of each outermost eval/call/evaluate;
159
172
  `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.6"
8
8
  edition = "2021"
9
9
  publish = false
10
10
 
@@ -647,6 +647,44 @@ 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 large ArrayBuffer can cross from one
653
+ // isolate to 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 via
657
+ // NS.transferOut, ships the returned integer token through its normal message
658
+ // plumbing, and rebuilds an ArrayBuffer over the SAME memory in the destination
659
+ // realm via NS.transferIn — replacing the prior copy-out/copy-in of the bytes.
660
+ //
661
+ // SharedRef<BackingStore> is NOT auto-Send (BackingStore is Send but not Sync, so
662
+ // SharedPtrBase's `T: Sync` Send-bound is unmet). It rides in this newtype with a
663
+ // manual Send for the same reason as SendIso/IsoPtr: the registry only ever MOVES
664
+ // the handle between owner threads (insert on the exporter's thread, remove on the
665
+ // importer's) under the Mutex, never sharing &handle concurrently — and shared_ptr's
666
+ // refcount is atomic, so dropping it on either thread is sound. No Sync impl: the
667
+ // handle is never aliased across threads.
668
+ struct SendBackingStore(v8::SharedRef<v8::BackingStore>);
669
+ unsafe impl Send for SendBackingStore {}
670
+
671
+ // Exported-but-not-yet-imported backing stores, keyed by token. A GLOBAL (not
672
+ // per-isolate): the whole point is to bridge two isolates, and tokens are unique
673
+ // process-wide. An exported buffer that is never imported (a dropped message)
674
+ // pins its memory here until NS.transferDrop releases it — RustyRacer
675
+ // .pending_transfer_count makes that observable.
676
+ static TRANSFER_REGISTRY: std::sync::OnceLock<Mutex<HashMap<u64, SendBackingStore>>> =
677
+ std::sync::OnceLock::new();
678
+
679
+ fn transfer_registry() -> &'static Mutex<HashMap<u64, SendBackingStore>> {
680
+ TRANSFER_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
681
+ }
682
+
683
+ // Monotonic token source. Starts at 1 so 0 is free as the JS-side "not
684
+ // transferable — fall back to a copy" sentinel. A token is f64-exact for ~2^53
685
+ // transfers, which a process will never reach.
686
+ static NEXT_TRANSFER_TOKEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
687
+
650
688
  // Run a microtask checkpoint with DRAINING set (nesting-safe via save/restore),
651
689
  // so a nested Reset/DisposeContext issued by a drained microtask is refused.
652
690
  fn checkpoint_draining(scope: &mut v8::PinScope<'_, '_>) {
@@ -1317,6 +1355,105 @@ fn set_promise_reject_handler(
1317
1355
  }
1318
1356
  }
1319
1357
 
1358
+ // NS.transferOut(arrayBufferOrView) -> a positive integer token, or 0 when the
1359
+ // argument can't be zero-copy transferred so the caller falls back to a copy.
1360
+ // Grabs the buffer's backing store, DETACHES the source (transfer semantics: its
1361
+ // byteLength -> 0, like a structured-clone transfer list), and parks the store
1362
+ // under the returned token. The bytes are untouched — only the source realm
1363
+ // loses access. A view transfers its whole underlying ArrayBuffer.
1364
+ //
1365
+ // Returns 0 (copy fallback) for: a non-buffer argument; a non-detachable buffer
1366
+ // (WebAssembly/asm.js memory); and a SharedArrayBuffer or SAB-backed view — a
1367
+ // SAB is *shared*, not transferred (detaching it would be a category error), so
1368
+ // it has no place in this transfer primitive; sharing it zero-copy across
1369
+ // isolates would be a separate feature.
1370
+ fn transfer_out(
1371
+ scope: &mut v8::PinScope<'_, '_>,
1372
+ args: v8::FunctionCallbackArguments<'_>,
1373
+ mut rv: v8::ReturnValue<'_, v8::Value>,
1374
+ ) {
1375
+ let value = args.get(0);
1376
+ let ab = if value.is_array_buffer() {
1377
+ v8::Local::<v8::ArrayBuffer>::try_from(value).ok()
1378
+ } else if value.is_array_buffer_view() {
1379
+ v8::Local::<v8::ArrayBufferView>::try_from(value)
1380
+ .ok()
1381
+ .and_then(|view| view.buffer(scope))
1382
+ } else {
1383
+ None
1384
+ };
1385
+ // Only transfer what we can neuter: zero-copy sharing a buffer the source
1386
+ // realm keeps live would alias mutable memory across isolates. Caller copies.
1387
+ let Some(ab) = ab.filter(|ab| ab.is_detachable()) else {
1388
+ rv.set(v8::Number::new(scope, 0.0).into());
1389
+ return;
1390
+ };
1391
+ // Grab the store BEFORE detaching — detach severs the buffer from it; the
1392
+ // SharedRef then keeps the memory alive on its own via the atomic refcount.
1393
+ let store = ab.get_backing_store();
1394
+ // is_detachable() does NOT guarantee detach succeeds: a buffer carrying an
1395
+ // [[ArrayBufferDetachKey]] returns None here and stays live. Register only
1396
+ // once the source is genuinely neutered, else both realms would alias one
1397
+ // backing store. (rusty_racer sets no detach keys today, so this is belt-and-
1398
+ // braces against the safety invariant ever silently breaking.)
1399
+ if ab.detach(None) != Some(true) {
1400
+ rv.set(v8::Number::new(scope, 0.0).into());
1401
+ return;
1402
+ }
1403
+ let token = NEXT_TRANSFER_TOKEN.fetch_add(1, Ordering::Relaxed);
1404
+ transfer_registry()
1405
+ .lock()
1406
+ .unwrap()
1407
+ .insert(token, SendBackingStore(store));
1408
+ rv.set(v8::Number::new(scope, token as f64).into());
1409
+ }
1410
+
1411
+ // Parse a transfer-token argument: a finite, integral JS number >= 1 (0 is the
1412
+ // "not transferable" sentinel transferOut never issues, and tokens are f64-exact
1413
+ // only up to 2^53). Rejects NaN / Infinity / fractional / negative / out-of-range
1414
+ // values — which a bare `as u64` would truncate or saturate into a COLLISION with
1415
+ // a live token, stealing an unrelated transfer — so a malformed token instead
1416
+ // reads as "unknown" (transferIn -> undefined, transferDrop -> no-op). Must run
1417
+ // before locking the registry: number_value can invoke a user valueOf/
1418
+ // Symbol.toPrimitive, i.e. re-enter these callbacks.
1419
+ fn transfer_token(scope: &mut v8::PinScope<'_, '_>, value: v8::Local<v8::Value>) -> Option<u64> {
1420
+ let n = value.number_value(scope)?;
1421
+ (n.is_finite() && n.fract() == 0.0 && (1.0..=9_007_199_254_740_992.0).contains(&n))
1422
+ .then_some(n as u64)
1423
+ }
1424
+
1425
+ // NS.transferIn(token) -> a fresh ArrayBuffer over the transferred backing store
1426
+ // (no byte copy), or undefined for an unknown token (already imported, or the
1427
+ // message was dropped). Consumes the token: the new buffer co-owns the store, so
1428
+ // the registry's reference is released.
1429
+ fn transfer_in(
1430
+ scope: &mut v8::PinScope<'_, '_>,
1431
+ args: v8::FunctionCallbackArguments<'_>,
1432
+ mut rv: v8::ReturnValue<'_, v8::Value>,
1433
+ ) {
1434
+ let Some(token) = transfer_token(scope, args.get(0)) else {
1435
+ return;
1436
+ };
1437
+ let entry = transfer_registry().lock().unwrap().remove(&token);
1438
+ if let Some(SendBackingStore(store)) = entry {
1439
+ rv.set(v8::ArrayBuffer::with_backing_store(scope, &store).into());
1440
+ }
1441
+ }
1442
+
1443
+ // NS.transferDrop(token): release a transferred backing store WITHOUT importing
1444
+ // it — for when the embedder discards a message whose buffer was exported.
1445
+ // A no-op for an unknown token. Without this, an exported-but-never-imported
1446
+ // buffer would pin its memory until process exit.
1447
+ fn transfer_drop(
1448
+ scope: &mut v8::PinScope<'_, '_>,
1449
+ args: v8::FunctionCallbackArguments<'_>,
1450
+ _rv: v8::ReturnValue<'_, v8::Value>,
1451
+ ) {
1452
+ if let Some(token) = transfer_token(scope, args.get(0)) {
1453
+ transfer_registry().lock().unwrap().remove(&token);
1454
+ }
1455
+ }
1456
+
1320
1457
  // V8 calls this synchronously on promise rejections with no handler (and the
1321
1458
  // later revocations when a handler IS added). Forwards
1322
1459
  // (event, contextId, promise, reason) to the registered JS recorder — the
@@ -1433,7 +1570,7 @@ fn install_host_namespace(
1433
1570
  let context = v8::Local::new(scope, ctx);
1434
1571
  let scope = &mut v8::ContextScope::new(scope, context);
1435
1572
  let ns = v8::Object::new(scope);
1436
- let members: [(&str, Option<v8::Local<v8::Function>>); 4] = [
1573
+ let members: [(&str, Option<v8::Local<v8::Function>>); 7] = [
1437
1574
  ("drainMicrotasks", v8::Function::new(scope, drain_microtasks)),
1438
1575
  ("contextGlobal", v8::Function::new(scope, context_global)),
1439
1576
  ("contextOf", v8::Function::new(scope, context_of)),
@@ -1441,6 +1578,9 @@ fn install_host_namespace(
1441
1578
  "setPromiseRejectHandler",
1442
1579
  v8::Function::new(scope, set_promise_reject_handler),
1443
1580
  ),
1581
+ ("transferOut", v8::Function::new(scope, transfer_out)),
1582
+ ("transferIn", v8::Function::new(scope, transfer_in)),
1583
+ ("transferDrop", v8::Function::new(scope, transfer_drop)),
1444
1584
  ];
1445
1585
  for (member, function) in members {
1446
1586
  if let (Some(f), Some(k)) = (function, v8::String::new(scope, member)) {
@@ -2284,6 +2424,17 @@ fn leaked_isolate_count() -> usize {
2284
2424
  LEAKED_ISOLATES.load(Ordering::Relaxed)
2285
2425
  }
2286
2426
 
2427
+ // RustyRacer.pending_transfer_count -> Integer: backing stores exported via
2428
+ // NS.transferOut but not yet imported (NS.transferIn) or released
2429
+ // (NS.transferDrop). PROCESS-WIDE (the registry bridges isolates, so the count
2430
+ // aggregates every isolate's outstanding transfers — compare deltas, not the
2431
+ // absolute value, when isolates run concurrently). A transport that always pairs
2432
+ // an export with one of those keeps its own contribution at 0; a rising count
2433
+ // means dropped messages are leaking transferred memory.
2434
+ fn pending_transfer_count() -> usize {
2435
+ transfer_registry().lock().unwrap().len()
2436
+ }
2437
+
2287
2438
  // Thin magnus-method wrappers.
2288
2439
  // Isolate = the VM and its isolate-level operations; it hands out Contexts.
2289
2440
  impl Isolate {
@@ -2818,5 +2969,6 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
2818
2969
  // Observability for the thread-confined lifecycle (see Drop for Core).
2819
2970
  module.define_singleton_method("live_isolate_count", function!(live_isolate_count, 0))?;
2820
2971
  module.define_singleton_method("leaked_isolate_count", function!(leaked_isolate_count, 0))?;
2972
+ module.define_singleton_method("pending_transfer_count", function!(pending_transfer_count, 0))?;
2821
2973
  Ok(())
2822
2974
  }
@@ -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.6"
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.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keita Urashima