wasm-bindgen icon indicating copy to clipboard operation
wasm-bindgen copied to clipboard

Impossible to use `ImageData::new_with_u8_clamped_array` etc reliably

Open kawadakk opened this issue 1 month ago • 8 comments

We've received a crash report indicating that CanvasRenderingContext2D#putImageData threw an error because the provided ImageData was detached from the WASM memory, even through there is no user code between ImageData creation and use.

InvalidStateError: Failed to execute 'putImageData' on 'CanvasRenderingContext2D': The source data has been detached.

The code that crashed is shown below:

let image_data = ImageData::new_with_u8_clamped_array(Clamped(data), width).expect("ImageData");

ctx.put_image_data(&image_data, 0., 0.)
    .expect("putImageData");

Inspecting the WASM module revealed that wasm-bindgen inserted a function call to __externref_table_alloc in-between. __externref_table_alloc can allocate heap memory and grow the WASM memory, detaching any existing views (that of the ImageData in this case) into the memory buffer.

This means that it's currently impossible to reliably use any external function/constructor that creates a view into the WASM memory and returns an externref.

kawadakk avatar Nov 20 '25 08:11 kawadakk

wasm-bindgen version? Do put_image_data interact with the wasm and js array? This issue has been fixed in the recent version.

Spxg avatar Nov 20 '25 08:11 Spxg

wasm-bindgen version?

The versions of wasm-bindgen library and CLI used to build the application were 0.2.104 and 0.2.89 respectively. After upgrading both to 0.2.105, I still see the call to __externref_table_alloc.

(local.set $50
 (call $fimport$57    <--- __wbg_new_with_u8_clamped_array_ae7b2eea4486cd31
  ...
 )
)
(table.set $1
 (local.tee $8
  (call $2149)        <--- __externref_table_alloc
 )
 (local.get $50)
)
...
(call $fimport$58     <--- __wbg_putImageData_5763f38e63905991
 (table.get $1
  (local.get $7)
 )
 (table.get $1
  (local.get $8)
 )
 (f64.const 0)
 (f64.const 0)
)

Do put_image_data interact with the wasm and js array?

In the compiled code, all put_image_data does is to get externrefs and call the following glue code:

    imports.wbg.__wbg_putImageData_5763f38e63905991 = function() { return handleError(function (arg0, arg1, arg2, arg3) {
        arg0.putImageData(arg1, arg2, arg3);
    }, arguments) };

arg1 contains the ImageData, which was initialized with a Uint8ClampedArray (created by getClampedArrayU8FromWasm0) viewing the WASM memory buffer.

If the memory buffer viewed by arg1.data is detached at this point, CanvasRenderingContext2D#putImageData will throw InvalidStateError (the put pixels from an ImageData onto a bitmap algorithm step 2) here.

kawadakk avatar Nov 20 '25 09:11 kawadakk

Do you have backtrace and reproducible example?

Spxg avatar Nov 20 '25 11:11 Spxg

I'm not sure if it's the same issue: https://github.com/wasm-bindgen/wasm-bindgen/pull/4622

Spxg avatar Nov 20 '25 11:11 Spxg

A reproducible example is:

#[cfg(test)]
mod tests {
    use wasm_bindgen::{Clamped, JsValue};
    use wasm_bindgen_test::wasm_bindgen_test;

    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);

    #[wasm_bindgen_test]
    fn detached() {
        let buf = vec![1, 2, 3, 255];
        let image_data = web_sys::ImageData::new_with_u8_clamped_array(Clamped(&buf), 1).unwrap();
        let _ = (0..10000).map(JsValue::from).collect::<Vec<_>>();
        image_data.data();
    }
}

Spxg avatar Nov 20 '25 15:11 Spxg

const originalArray = new Uint8ClampedArray([255, 0, 0, 255, 0, 255, 0, 255]);
const imageData = new ImageData(originalArray, 2, 1);
console.log(imageData.data[0]);
originalArray[0] = 123;
console.log(originalArray[0]); 
console.log(imageData.data[0]); 
// output: 255 123 123

ImageData does not copy the array during construction. Instead, it is used as a view.

function getUint8ClampedArrayMemory0() {
    if (cachedUint8ClampedArrayMemory0 === null || cachedUint8ClampedArrayMemory0.byteLength === 0) {
        cachedUint8ClampedArrayMemory0 = new Uint8ClampedArray(wasm.memory.buffer);
    }
    return cachedUint8ClampedArrayMemory0;
}

function getClampedArrayU8FromWasm0(ptr, len) {
    ptr = ptr >>> 0;
    return getUint8ClampedArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
}

const ret = new ImageData(getClampedArrayU8FromWasm0(arg0, arg1), arg2 >>> 0);

wasm-bindgen passed in a subarray (view), but ImageData held the view, which caused the issue. I think wasm-bindgen can do very little, and I suggest using the new_with_js_u8_clamped_array method to avoid this issue:

let image_data = ImageData::new_with_js_u8_clamped_array(
    &Uint8ClampedArray::new_from_slice(&data),
    width,
)
.expect("ImageData");

Spxg avatar Nov 20 '25 16:11 Spxg

I think we can implement the slab inside the WebAssembly.Table empty slots directly avoiding the need for a Rust slab entirely and hence Rust allocations entirely. This would then only trigger Table.grow() and not memory grow resolving the issue here.

Would be a fun project actually in src/externref.rs to rather write i31's into the empty table slots to manage the slab in the table instead and remove the Slab there entirely.

guybedford avatar Nov 20 '25 20:11 guybedford

directly avoiding the need for a Rust slab entirely and hence Rust allocations entirely.

But in this case, there will happen as long as memory.grow:

#[cfg(test)]
mod tests {
    use wasm_bindgen::Clamped;
    use wasm_bindgen_test::wasm_bindgen_test;

    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);

    #[wasm_bindgen_test]
    fn detached() {
        let buf = vec![1, 2, 3, 255];
        let image_data = web_sys::ImageData::new_with_u8_clamped_array(Clamped(&buf), 1).unwrap();
        // grow
        let _ = vec![0; 50000];
        image_data.data();
    }
}

Spxg avatar Nov 21 '25 03:11 Spxg