feat: allow backing an ArrayBuffer/SharedArrayBuffer with embedder-owned memory - #5466
Conversation
Test262 conformance changes
Tested main commit: |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #5466 +/- ##
===========================================
+ Coverage 47.24% 62.86% +15.61%
===========================================
Files 476 530 +54
Lines 46892 59167 +12275
===========================================
+ Hits 22154 37195 +15041
+ Misses 24738 21972 -2766 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Shine-neko
left a comment
There was a problem hiding this comment.
Thanks for tackling this so quickly! I tested this branch end-to-end embedding Boa, backing WebAssembly.Memory.prototype.buffer with a wasm linear memory via from_external_ptr, and it works: a napi-rs wasm addon now sees JS-side writes and runs correctly. Two things I hit while reviewing the unsafe surface:
| } | ||
|
|
||
| /// The internal representation of an `ArrayBuffer` object. | ||
| #[derive(Debug, Clone, Trace, Finalize, JsData)] |
There was a problem hiding this comment.
ArrayBuffer derives Clone, which is now unsound for the External variant: cloning copies the raw pointer, so two ArrayBuffers alias the same region and can each hand out &mut [u8] to it simultaneously. Owned buffers are fine (the Vec is deep-copied), but an external clone breaks the aliasing invariant from_external_data documents.
Would a manual Clone that deep-copies the External variant into an Owned one (or rejects cloning external buffers) work here?
There was a problem hiding this comment.
Good catch, that derive was unsound as written. Fixed with a manual Clone for BufferData that deep-copies the External variant into an Owned one, so a cloned buffer is always independent and can never hand out an aliasing &mut [u8].
(With the rework from jedel1043's review the engine no longer creates &[u8]/&mut [u8] into external regions at all — only atomic accesses — so a bitwise clone would technically no longer be UB, but deep-copying keeps the "clone = independent buffer" semantics and avoids surprising aliasing either way.)
| /// Panics if `ptr` is null. | ||
| #[must_use] | ||
| pub unsafe fn from_external_ptr(ptr: *mut u8, len: usize) -> Self { | ||
| let ptr = NonNull::new(ptr.cast::<AtomicU8>()).expect("`ptr` must be non-null"); |
There was a problem hiding this comment.
For a SharedArrayBuffer, Atomics and wider views (Int32Array, Float64Array, BigInt64Array) perform aligned atomic accesses. A byte-aligned ptr isn't enough — the base address must satisfy the largest alignment those ops need. Worth asserting/documenting it here, e.g.:
| let ptr = NonNull::new(ptr.cast::<AtomicU8>()).expect("`ptr` must be non-null"); | |
| let ptr = NonNull::new(ptr.cast::<AtomicU8>()).expect("`ptr` must be non-null"); | |
| debug_assert!(ptr.as_ptr() as usize % 8 == 0, "external SharedArrayBuffer backing must be 8-byte aligned"); |
There was a problem hiding this comment.
Agreed — and it actually applies to non-shared external ArrayBuffers too now, since after the redesign the engine accesses all external regions through atomics (including AtomicU64 element accesses and the batched copies). I went one step further than the debug_assert! and added a hard assert! at construction (it's a one-time cost) requiring 8-byte base alignment for any non-empty external region, in both ArrayBuffer and SharedArrayBuffer constructors, plus a # Panics note in the docs. There's also a #[should_panic] test for the misaligned case.
| fn as_slice(&self) -> &[u8] { | ||
| // SAFETY: The creator of an `ExternalMemory` guarantees that `ptr` is valid | ||
| // for reads and writes of `len` bytes for the whole lifetime of the buffer. | ||
| unsafe { slice::from_raw_parts(self.ptr.as_ptr(), self.len) } |
There was a problem hiding this comment.
One more precondition for slice::from_raw_parts: the region size must be <= isize::MAX bytes. It's a documented safety requirement of from_raw_parts, so it'd be worth either asserting it at construction (from_external_data/from_external_ptr) or adding it to the # Safety list the caller must uphold.
There was a problem hiding this comment.
Fixed by construction on the safe path: external regions are now represented as &'static [AtomicU8], and the safe from_external_data constructors take that slice directly, so the len <= isize::MAX bound is guaranteed by the slice type itself. The unsafe from_external_ptr wrappers assert len <= isize::MAX (and non-null) before building the slice, documented under # Panics.
| // SAFETY: The external region is only ever accessed through atomic operations, and the | ||
| // creator of an externally-backed `SharedArrayBuffer` guarantees that the region stays | ||
| // valid for the whole lifetime of the buffer, making it safe to share between threads. | ||
| unsafe impl Send for SharedData {} |
There was a problem hiding this comment.
The SAFETY comment covers lifetime/validity, but one case worth spelling out: if the external region can be relocated (e.g. a growable WebAssembly memory that moves its base on memory.grow), the stored ptr silently dangles. The invariant the caller must uphold is stronger than "stays valid" — it's "stays valid and unmoved". Might be worth adding that word here and in the # Safety docs, since the wasm-memory use case is exactly where it bites.
There was a problem hiding this comment.
Good point — the wasm use case is exactly where this bites. The safety docs of both from_external_ptr constructors now require the region to stay "valid and unmoved at the same address", and explicitly call out the growable-WebAssembly-memory-relocating-on-memory.grow case as an example of a silently-dangling pointer.
This particular unsafe impl Send/Sync is actually gone entirely now: with the external region stored as &'static [AtomicU8], SharedData is automatically Send + Sync, so the invariant lives only in the constructor's safety contract where the caller can actually uphold it.
jedel1043
left a comment
There was a problem hiding this comment.
Thank you for tackling this problem!
While I do think we should offer an API similar to this for embedders, I'm worried that this might not be the ideal API for it, for a couple of reasons:
-
Taking a pointer and a length then casting to slices is basically what
&'static mut [u8]already does, so we should try to delegate to that if possible. However, this is mostly minor. -
Embedders passing a buffer to the engine don't have a good way to ensure the buffer is not held by the engine if they want to access the written data, which would cause UB if the buffer is still alive at the point of reading e.g. an async task that suspended while holding an arraybuffer mutable reference.
-
This might not be completely thread safe? Its safe if the engine and the embedder run interchangeably in the same thread, but if the embedder is running its main loop in another thread, nothing guarantees that the embedder will read the correct values from the buffer after the engine finishes writing data to it.
…ned memory Adds a public API to create `ArrayBuffer`s and `SharedArrayBuffer`s whose bytes alias an embedder-supplied memory region instead of a Boa-owned allocation, enabling zero-copy sharing of byte regions (Wasm linear memories, mmap'd files, GPU-mapped buffers) between JavaScript and native host code: - `ArrayBuffer::from_external_data` / `SharedArrayBuffer::from_external_data`: safe constructors taking a `&'static [AtomicU8]` region - `ArrayBuffer::from_external_ptr` / `SharedArrayBuffer::from_external_ptr`: unsafe convenience wrappers over pointer + length that build the slice and delegate to `from_external_data` - the same constructor pairs on `JsArrayBuffer` / `JsSharedArrayBuffer` - `is_external()` on all four types External regions are represented as `&'static [AtomicU8]`, and the engine only ever accesses them through the existing atomic access paths (`SliceRef::AtomicSlice`/`SliceRefMut::AtomicSlice`), never materializing `&[u8]`/`&mut [u8]` references into memory it does not own. This makes it sound for the embedder to access the region from the same thread at any time, even if e.g. a suspended async task still holds a reference into the buffer, and makes cross-thread visibility follow the usual atomics rules. It also allows `SharedData` to drop its hand-written `Send`/`Sync` impls. Externally-backed buffers are always fixed-length and cannot be resized, grown nor transferred. Non-shared external buffers can be detached, which returns a copy of the region's contents and drops the engine's reference into the region, giving embedders a hard guarantee that the engine can no longer access it (e.g. before unmapping it). Cloning an `ArrayBuffer` deep-copies external regions into a Boa-owned allocation, so clones never alias the original region. External regions must be 8-byte aligned (asserted at construction) to keep the wider typed-array views (`Float64Array`, `BigInt64Array`, ...) properly aligned. Closes boa-dev#5447
762e3c4 to
1063d2f
Compare
|
@jedel1043 @Shine-neko Thanks for the detailed feedback! I reworked the design around your three points: Delegating to slices. External regions are now a real slice "Buffer still held by the engine" UB. The engine now accesses external regions only through the existing atomic paths ( Thread safety. Every engine access to external memory is atomic, so cross-thread access follows the ordinary happens-before rules the same, now documented, contract as Shine-neko's points (deep-copying |
This PR implements the feature requested in #5447: a public API to back an
ArrayBuffer/SharedArrayBufferwith embedder-owned memory, enabling zero-copy sharing of byte regions (WebAssembly linear memories, mmap'd files, GPU-mapped buffers) between JavaScript and native host code.What's added
Public API:
JsArrayBuffer::from_external_ptr(ptr, len, context)(unsafe) — creates anArrayBufferwhose bytes aliaslenbytes of embedder-owned memory atptr. No copy, no allocation: JS writes are immediately visible to the embedder throughptrand vice versa.JsSharedArrayBuffer::from_external_ptr(ptr, len, context)(unsafe) — same forSharedArrayBuffer.ArrayBuffer::from_external_data(ptr, len)/SharedArrayBuffer::from_external_ptr(ptr, len)(unsafe) — lower-level constructors on the buffer data types themselves.is_external()on all four types.Semantics of externally-backed buffers (the conservative answers to the design questions raised in the issue):
resize/growthrow aTypeError.detach,ArrayBuffer.prototype.transfer) — these throw aTypeErrorand leave the buffer intact. This matches the Wasm-memory use case (WebAssembly.Memory.prototype.bufferis non-detachable by user code) and keeps ownership unambiguous: Boa never allocates, grows nor frees the region.DataView,Atomics,slice, etc.) works transparently on top of the aliased memory.Implementation
ArrayBuffer's internal slot changes fromOption<AlignedVec<u8>>toOption<BufferData>withOwned(AlignedVec<u8>)/External(ExternalMemory)variants. All existing accessors (bytes,bytes_mut,bytes_with_len*,len) work on both variants, so typed arrays,DataViewandAtomicsneed no changes.SharedArrayBuffer'sInner.bufferchanges fromAlignedBox<[AtomicU8]>to aSharedDataenum withOwned/Externalvariants (withunsafe impl Send/Syncjustified by the atomic-only access contract).unsafepointer constructor (option 1 of the issue), with safety contracts spelled out on every constructor. AnArc-basedBackingStorehandle could be layered on top later without changing the internals.Tests
external_array_buffer_zero_copy— JS writes visible through the embedder's pointer and vice versa.external_array_buffer_cannot_detach_nor_resize— detach/resize throw and leave the buffer usable.external_shared_array_buffer_zero_copy— same aliasing checks forSharedArrayBuffer, plusgrowable === false.JsArrayBuffer::from_external_ptrdemonstrating the zero-copy roundtrip.Closes #5447
🤖 Generated with Claude Code