Files
Nathan Whitaker 094e47196e fix(webgpu): own mapped range backing stores (#36257)
## Summary

- back mapped WebGPU ranges with V8-owned `ArrayBuffer` storage
- copy writable ranges back before unmapping and detach every returned
range when unmapping or destroying the buffer
- cover multi-range writeback and mapped-range detachment

## Background

Writable mapped ranges previously used backend memory directly, while
readable ranges were copied without being retained for detachment. This
made returned `ArrayBuffer` lifetimes depend on the native mapping and
left readable ranges attached after `unmap()`.

Keeping JavaScript ranges in owned storage makes their lifetime
independent of the backend mapping. Writable data is copied back before
the mapping closes, including when callers request multiple
non-overlapping ranges.

## Tests

- `cargo test -p deno_webgpu`
- `cargo build -p deno -p test_server`
- `cargo test -p unit_tests --test unit webgpu_test`
- `cargo clippy -p deno_webgpu --all-targets -- -D warnings`
- `./tools/format.js ext/webgpu/buffer.rs tests/unit/webgpu_test.ts`
- `./tools/lint.js tests/unit/webgpu_test.ts`
2026-07-27 20:45:48 +00:00

332 lines
8.7 KiB
Rust

// Copyright 2018-2026 the Deno authors. MIT license.
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Duration;
use deno_core::GarbageCollected;
use deno_core::WebIDL;
use deno_core::futures::channel::oneshot;
use deno_core::op2;
use deno_core::v8;
use deno_core::webidl::WebIdlInterfaceConverter;
use deno_error::JsErrorBox;
use wgpu_core::device::HostMap as MapMode;
use crate::Instance;
use crate::error::GPUGenericError;
#[derive(WebIDL)]
#[webidl(dictionary)]
pub(crate) struct GPUBufferDescriptor {
#[webidl(default = String::new())]
pub label: String,
pub size: u64,
#[options(enforce_range = true)]
pub usage: u32,
#[webidl(default = false)]
pub mapped_at_creation: bool,
}
#[derive(Debug, thiserror::Error, deno_error::JsError)]
pub enum BufferError {
#[class(generic)]
#[error(transparent)]
Canceled(#[from] oneshot::Canceled),
#[class("DOMExceptionOperationError")]
#[error(transparent)]
Access(#[from] wgpu_core::resource::BufferAccessError),
#[class("DOMExceptionOperationError")]
#[error("{0}")]
Operation(&'static str),
#[class(inherit)]
#[error(transparent)]
Other(#[from] JsErrorBox),
}
pub struct GPUBuffer {
pub instance: Instance,
pub error_handler: super::error::ErrorHandler,
pub id: wgpu_core::id::BufferId,
pub device: wgpu_core::id::DeviceId,
pub label: String,
pub size: u64,
pub usage: u32,
pub map_state: RefCell<&'static str>,
pub map_mode: RefCell<Option<MapMode>>,
pub mapped_js_buffers: RefCell<Vec<MappedJsBuffer>>,
}
pub struct MappedJsBuffer {
pub buffer: v8::Global<v8::ArrayBuffer>,
pub offset: u64,
pub size: u64,
pub copy_on_unmap: bool,
}
impl Drop for GPUBuffer {
fn drop(&mut self) {
self.instance.buffer_drop(self.id);
}
}
impl GPUBuffer {
fn detach_mapped_js_buffers(&self, scope: &mut v8::PinScope<'_, '_>) {
for mapped in self.mapped_js_buffers.replace(vec![]) {
let ab = mapped.buffer.open(scope);
ab.detach(None);
}
}
fn writeback_and_detach_mapped_js_buffers(
&self,
scope: &mut v8::PinScope<'_, '_>,
) -> Result<(), BufferError> {
let mut first_error = None;
for mapped in self.mapped_js_buffers.replace(vec![]) {
let ab = mapped.buffer.open(scope);
// A zero `byte_length` means the range is already detached, which can
// only happen if the caller detached it themselves (e.g. by transferring
// it to a worker). There is nothing left to read from, so the range is
// skipped: any writes made through a transferred copy are not propagated
// to the buffer. That matches the mapping model, where the range handed
// out by `getMappedRange()` is the only view that stays valid until
// `unmap()`.
if mapped.copy_on_unmap && mapped.size != 0 && ab.byte_length() != 0 {
match self.instance.buffer_get_mapped_range(
self.id,
mapped.offset,
Some(mapped.size),
) {
Ok((dst, _)) => {
if let Some(src) = ab.data() {
// SAFETY: `src` points to this V8-owned ArrayBuffer's backing
// store, and `dst`/`mapped.size` were revalidated by wgpu for
// the currently mapped range.
unsafe {
std::ptr::copy_nonoverlapping(
src.as_ptr() as *const u8,
dst.as_ptr(),
mapped.size as usize,
);
}
}
}
Err(err) => {
first_error.get_or_insert(BufferError::Access(err));
}
}
}
ab.detach(None);
}
if let Some(err) = first_error {
return Err(err);
}
Ok(())
}
}
impl WebIdlInterfaceConverter for GPUBuffer {
const NAME: &'static str = "GPUBuffer";
}
// SAFETY: we're sure this can be GCed
unsafe impl GarbageCollected for GPUBuffer {
fn trace(&self, _visitor: &mut deno_core::v8::cppgc::Visitor) {}
fn get_name(&self) -> &'static std::ffi::CStr {
c"GPUBuffer"
}
}
#[op2]
impl GPUBuffer {
#[constructor]
#[cppgc]
fn constructor(_: bool) -> Result<GPUBuffer, GPUGenericError> {
Err(GPUGenericError::InvalidConstructor)
}
#[getter]
#[string]
fn label(&self) -> String {
self.label.clone()
}
#[setter]
#[string]
fn label(&self, #[webidl] _label: String) {
// TODO(@crowlKats): no-op, needs wpgu to implement changing the label
}
#[getter]
#[number]
fn size(&self) -> u64 {
self.size
}
#[getter]
fn usage(&self) -> u32 {
self.usage
}
#[getter]
#[string]
fn map_state(&self) -> &'static str {
*self.map_state.borrow()
}
// In the successful case, the promise should resolve to undefined, but
// `#[undefined]` does not seem to work here.
// https://github.com/denoland/deno/issues/29603
async fn map_async(
&self,
#[webidl(options(enforce_range = true))] mode: u32,
#[webidl(default = 0)] offset: u64,
#[webidl] size: Option<u64>,
) -> Result<(), BufferError> {
let read_mode = (mode & 0x0001) == 0x0001;
let write_mode = (mode & 0x0002) == 0x0002;
if (read_mode && write_mode) || (!read_mode && !write_mode) {
return Err(BufferError::Operation(
"exactly one of READ or WRITE map mode must be set",
));
}
let mode = if read_mode {
MapMode::Read
} else {
assert!(write_mode);
MapMode::Write
};
{
*self.map_state.borrow_mut() = "pending";
}
let (sender, receiver) =
oneshot::channel::<wgpu_core::resource::BufferAccessResult>();
{
let callback = Box::new(move |status| {
sender.send(status).unwrap();
});
let err = self
.instance
.buffer_map_async(
self.id,
offset,
size,
wgpu_core::resource::BufferMapOperation {
host: mode,
callback: Some(callback),
},
)
.err();
if err.is_some() {
self.error_handler.push_error(err);
return Err(BufferError::Operation("validation error occurred"));
}
}
let done = Rc::new(RefCell::new(false));
let done_ = done.clone();
let device_poll_fut = async move {
while !*done.borrow() {
{
self
.instance
.device_poll(self.device, wgpu_types::PollType::wait_indefinitely())
.unwrap();
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
Ok::<(), BufferError>(())
};
let receiver_fut = async move {
receiver.await??;
let mut done = done_.borrow_mut();
*done = true;
Ok::<(), BufferError>(())
};
tokio::try_join!(device_poll_fut, receiver_fut)?;
*self.map_state.borrow_mut() = "mapped";
*self.map_mode.borrow_mut() = Some(mode);
Ok(())
}
fn get_mapped_range<'s>(
&self,
scope: &mut v8::PinScope<'s, '_>,
#[webidl(default = 0)] offset: u64,
#[webidl] size: Option<u64>,
) -> Result<v8::Local<'s, v8::ArrayBuffer>, BufferError> {
let (slice_pointer, range_size) = self
.instance
.buffer_get_mapped_range(self.id, offset, size)
.map_err(BufferError::Access)?;
let mode = self.map_mode.borrow();
let mode = mode.as_ref().unwrap();
// SAFETY: creating a slice from the pointer and length provided by wgpu.
// Copy into a V8-owned backing store so a later GPUBuffer.destroy(), drop,
// or backend unmap cannot leave JS holding a pointer to freed native memory.
let slice = unsafe {
std::slice::from_raw_parts(slice_pointer.as_ptr(), range_size as usize)
};
let bs = v8::ArrayBuffer::new_backing_store_from_vec(slice.to_vec());
let shared_bs = bs.make_shared();
let ab = v8::ArrayBuffer::with_backing_store(scope, &shared_bs);
self.mapped_js_buffers.borrow_mut().push(MappedJsBuffer {
buffer: v8::Global::new(scope, ab),
offset,
size: range_size,
copy_on_unmap: mode == &MapMode::Write,
});
Ok(ab)
}
#[nofast]
#[undefined]
fn unmap(&self, scope: &mut v8::PinScope<'_, '_>) -> Result<(), BufferError> {
// Writeback has to happen while the backend mapping is still live, so it
// runs before `buffer_unmap()`. If it fails we bail out and leave
// `map_state` as "mapped", matching the previous behavior where a failing
// `buffer_unmap()` left the state untouched. In practice this is
// unreachable: the range was validated by `getMappedRange()` and the
// mapping is still active here.
self.writeback_and_detach_mapped_js_buffers(scope)?;
self
.instance
.buffer_unmap(self.id)
.map_err(BufferError::Access)?;
*self.map_state.borrow_mut() = "unmapped";
Ok(())
}
#[nofast]
#[undefined]
fn destroy(&self, scope: &mut v8::PinScope<'_, '_>) {
self.detach_mapped_js_buffers(scope);
self.instance.buffer_destroy(self.id);
}
}