Skip to content

feat: allow backing an ArrayBuffer/SharedArrayBuffer with embedder-owned memory - #5466

Open
mansiverma897993 wants to merge 1 commit into
boa-dev:mainfrom
mansiverma897993:feat/external-backed-array-buffers
Open

feat: allow backing an ArrayBuffer/SharedArrayBuffer with embedder-owned memory#5466
mansiverma897993 wants to merge 1 commit into
boa-dev:mainfrom
mansiverma897993:feat/external-backed-array-buffers

Conversation

@mansiverma897993

Copy link
Copy Markdown
Contributor

This PR implements the feature requested in #5447: a public API to back an ArrayBuffer/SharedArrayBuffer with 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 an ArrayBuffer whose bytes alias len bytes of embedder-owned memory at ptr. No copy, no allocation: JS writes are immediately visible to the embedder through ptr and vice versa.
  • JsSharedArrayBuffer::from_external_ptr(ptr, len, context) (unsafe) — same for SharedArrayBuffer.
  • 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):

  • Always fixed-length; resize/grow throw a TypeError.
  • Cannot be detached nor transferred (detach, ArrayBuffer.prototype.transfer) — these throw a TypeError and leave the buffer intact. This matches the Wasm-memory use case (WebAssembly.Memory.prototype.buffer is non-detachable by user code) and keeps ownership unambiguous: Boa never allocates, grows nor frees the region.
  • Everything else (typed arrays, DataView, Atomics, slice, etc.) works transparently on top of the aliased memory.

Implementation

  • ArrayBuffer's internal slot changes from Option<AlignedVec<u8>> to Option<BufferData> with Owned(AlignedVec<u8>) / External(ExternalMemory) variants. All existing accessors (bytes, bytes_mut, bytes_with_len*, len) work on both variants, so typed arrays, DataView and Atomics need no changes.
  • SharedArrayBuffer's Inner.buffer changes from AlignedBox<[AtomicU8]> to a SharedData enum with Owned/External variants (with unsafe impl Send/Sync justified by the atomic-only access contract).
  • The lifetime/ownership question is answered with a documented unsafe pointer constructor (option 1 of the issue), with safety contracts spelled out on every constructor. An Arc-based BackingStore handle 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 for SharedArrayBuffer, plus growable === false.
  • A doctest on JsArrayBuffer::from_external_ptr demonstrating the zero-copy roundtrip.

Closes #5447

🤖 Generated with Claude Code

@mansiverma897993
mansiverma897993 requested a review from a team as a code owner July 30, 2026 11:40
@github-actions github-actions Bot added C-Tests Issues and PRs related to the tests. C-Builtins PRs and Issues related to builtins/intrinsics Waiting On Review Waiting on reviews from the maintainers labels Jul 30, 2026
@github-actions github-actions Bot added this to the v1.0.0 milestone Jul 30, 2026
@github-actions

Copy link
Copy Markdown

Test262 conformance changes

Test result main count PR count difference
Total 53,125 53,125 0
Passed 51,073 51,073 0
Ignored 1,482 1,482 0
Failed 570 570 0
Panics 0 0 0
Conformance 96.14% 96.14% 0.00%

Tested main commit: 4fc75c6ae9d85f2b8065c6716f88e9b35318438c
Tested PR commit: 762e3c48c7c2ad3f76c9497cee1cc9fa69f8db1b
Compare commits: 4fc75c6...762e3c4

@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.89888% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 62.86%. Comparing base (6ddc2b4) to head (762e3c4).
⚠️ Report is 1012 commits behind head on main.

Files with missing lines Patch % Lines
core/engine/src/builtins/array_buffer/mod.rs 75.86% 14 Missing ⚠️
core/engine/src/builtins/array_buffer/shared.rs 84.21% 3 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Shine-neko Shine-neko left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.:

Suggested change
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");

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 jedel1043 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@mansiverma897993
mansiverma897993 force-pushed the feat/external-backed-array-buffers branch from 762e3c4 to 1063d2f Compare August 6, 2026 20:54
@mansiverma897993

mansiverma897993 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

@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 &'static [AtomicU8]. The safe primary constructors are ArrayBuffer::from_external_data / SharedArrayBuffer::from_external_data, and the unsafe from_external_ptr versions are thin wrappers that just build the slice and delegate. I went with a shared slice of atomics instead of &'static mut [u8] because a stored &mut is exactly what makes your second point UB, and the slice type also guarantees the from_raw_parts length/validity requirements by construction.

"Buffer still held by the engine" UB. The engine now accesses external regions only through the existing atomic paths (SliceRef::AtomicSlice, the same machinery SharedArrayBuffer uses) and never materializes &[u8]/&mut [u8] into memory it doesn't own. So a suspended async task holding a buffer reference no longer makes embedder reads UB. Additionally, detach() now works on external buffers (returns a copy, drops the engine's reference), giving embedders a hard release before unmapping/freeing the region.

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 SharedArrayBuffer memory. This also let SharedData drop its hand-written Send/Sync impls.

Shine-neko's points (deep-copying Clone, 8-byte alignment assert, isize::MAX, "valid and unmoved" docs) are folded in too, with tests covering all of the above. Happy to iterate if you'd prefer a different shape!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

C-Builtins PRs and Issues related to builtins/intrinsics C-Tests Issues and PRs related to the tests. Waiting On Review Waiting on reviews from the maintainers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expose a way to back an ArrayBuffer/SharedArrayBuffer with embedder-owned memory

3 participants