Tests: Test JS typed array view cache edge cases

Add direct indexed access coverage for fixed ArrayBuffer typed array
views that can use cached backing-store pointers. Cover multiple views
on the same buffer, offset views, detach, transfer, truncating transfer,
transferToFixedLength(), slicing, and fixed SharedArrayBuffer views.

Also cover WebAssembly.Memory buffers across fixed, resizable, shared,
and non-shared cases. Exercise memory.grow(), ArrayBuffer resize,
SharedArrayBuffer grow, and conversions between resizable and
fixed-length memory buffers.
This commit is contained in:
Andreas Kling 2026-04-30 14:16:53 +02:00 committed by Andreas Kling
parent 1ab23e67e9
commit ec0833764b
3 changed files with 446 additions and 0 deletions

View file

@ -0,0 +1,213 @@
const NUMERIC_TYPED_ARRAYS = [
Uint8Array,
Uint8ClampedArray,
Uint16Array,
Uint32Array,
Int8Array,
Int16Array,
Int32Array,
Float16Array,
Float32Array,
Float64Array,
];
const BIGINT_TYPED_ARRAYS = [BigUint64Array, BigInt64Array];
function initialValueFor(T) {
if (T === Float16Array || T === Float32Array || T === Float64Array) return 1.5;
if (T === BigUint64Array || T === BigInt64Array) return 17n;
return 17;
}
function secondValueFor(T) {
if (T === Float16Array || T === Float32Array || T === Float64Array) return 2.5;
if (T === BigUint64Array || T === BigInt64Array) return 23n;
return 23;
}
function replacementValueFor(T) {
if (T === Float16Array || T === Float32Array || T === Float64Array) return 3.5;
if (T === BigUint64Array || T === BigInt64Array) return 31n;
return 31;
}
function testTypes(callback) {
NUMERIC_TYPED_ARRAYS.forEach(callback);
BIGINT_TYPED_ARRAYS.forEach(callback);
}
test("direct indexed access stays coherent for multiple fixed views over one buffer", () => {
testTypes(T => {
const buffer = new ArrayBuffer(T.BYTES_PER_ELEMENT * 4);
const wholeView = new T(buffer);
const offsetView = new T(buffer, T.BYTES_PER_ELEMENT, 2);
const initialValue = initialValueFor(T);
const secondValue = secondValueFor(T);
wholeView[1] = initialValue;
expect(offsetView[0]).toBe(initialValue);
expect(Reflect.get(offsetView, 0)).toBe(initialValue);
offsetView[1] = secondValue;
expect(wholeView[2]).toBe(secondValue);
expect(Reflect.get(wholeView, 2)).toBe(secondValue);
});
});
test("detach invalidates every cached fixed view over the buffer", () => {
testTypes(T => {
const buffer = new ArrayBuffer(T.BYTES_PER_ELEMENT * 4);
const wholeView = new T(buffer);
const offsetView = new T(buffer, T.BYTES_PER_ELEMENT, 2);
const initialValue = initialValueFor(T);
const secondValue = secondValueFor(T);
wholeView[0] = initialValue;
offsetView[0] = secondValue;
expect(wholeView[1]).toBe(secondValue);
detachArrayBuffer(buffer);
expect(buffer.detached).toBeTrue();
expect(wholeView.length).toBe(0);
expect(offsetView.length).toBe(0);
expect(wholeView[0]).toBeUndefined();
expect(offsetView[0]).toBeUndefined();
expect(Reflect.get(wholeView, 0)).toBeUndefined();
expect(Reflect.get(offsetView, 0)).toBeUndefined();
wholeView[0] = replacementValueFor(T);
offsetView[0] = replacementValueFor(T);
expect(wholeView[0]).toBeUndefined();
expect(offsetView[0]).toBeUndefined();
expect(Object.hasOwn(wholeView, "0")).toBeFalse();
expect(Object.hasOwn(offsetView, "0")).toBeFalse();
expect(Object.getOwnPropertyDescriptor(wholeView, "0")).toBeUndefined();
expect(Object.getOwnPropertyDescriptor(offsetView, "0")).toBeUndefined();
});
});
test("ArrayBuffer.prototype.transfer invalidates old cached views", () => {
testTypes(T => {
const buffer = new ArrayBuffer(T.BYTES_PER_ELEMENT * 3);
const oldWholeView = new T(buffer);
const oldOffsetView = new T(buffer, T.BYTES_PER_ELEMENT, 1);
const initialValue = initialValueFor(T);
const secondValue = secondValueFor(T);
const replacementValue = replacementValueFor(T);
oldWholeView[0] = initialValue;
oldOffsetView[0] = secondValue;
const newBuffer = buffer.transfer();
const newView = new T(newBuffer);
expect(buffer.detached).toBeTrue();
expect(newBuffer.detached).toBeFalse();
expect(oldWholeView.length).toBe(0);
expect(oldOffsetView.length).toBe(0);
expect(oldWholeView[0]).toBeUndefined();
expect(oldOffsetView[0]).toBeUndefined();
expect(newView[0]).toBe(initialValue);
expect(newView[1]).toBe(secondValue);
oldWholeView[0] = replacementValue;
oldOffsetView[0] = replacementValue;
expect(newView[0]).toBe(initialValue);
expect(newView[1]).toBe(secondValue);
});
});
test("ArrayBuffer.prototype.transfer truncation invalidates offset cached views", () => {
testTypes(T => {
const buffer = new ArrayBuffer(T.BYTES_PER_ELEMENT * 4);
const oldOffsetView = new T(buffer, T.BYTES_PER_ELEMENT * 2, 2);
const initialValue = initialValueFor(T);
const secondValue = secondValueFor(T);
oldOffsetView[0] = initialValue;
oldOffsetView[1] = secondValue;
const newBuffer = buffer.transfer(T.BYTES_PER_ELEMENT * 3);
const newView = new T(newBuffer);
expect(buffer.detached).toBeTrue();
expect(oldOffsetView.length).toBe(0);
expect(oldOffsetView[0]).toBeUndefined();
expect(newView.length).toBe(3);
expect(newView[2]).toBe(initialValue);
});
});
test("ArrayBuffer.prototype.transferToFixedLength invalidates old cached views", () => {
testTypes(T => {
const buffer = new ArrayBuffer(T.BYTES_PER_ELEMENT * 3);
const oldView = new T(buffer);
const initialValue = initialValueFor(T);
oldView[2] = initialValue;
const newBuffer = buffer.transferToFixedLength();
const newView = new T(newBuffer);
expect(buffer.detached).toBeTrue();
expect(newBuffer.detached).toBeFalse();
expect(oldView.length).toBe(0);
expect(oldView[2]).toBeUndefined();
expect(newView.length).toBe(3);
expect(newView[2]).toBe(initialValue);
detachArrayBuffer(newBuffer);
expect(newBuffer.detached).toBeTrue();
expect(newView.length).toBe(0);
expect(newView[0]).toBeUndefined();
});
});
test("ArrayBuffer.prototype.slice creates an independently cached fixed buffer", () => {
testTypes(T => {
const buffer = new ArrayBuffer(T.BYTES_PER_ELEMENT * 4);
const originalView = new T(buffer);
const initialValue = initialValueFor(T);
const secondValue = secondValueFor(T);
originalView[1] = initialValue;
originalView[2] = secondValue;
const slicedBuffer = buffer.slice(T.BYTES_PER_ELEMENT, T.BYTES_PER_ELEMENT * 3);
const slicedView = new T(slicedBuffer);
expect(slicedView.length).toBe(2);
expect(slicedView[0]).toBe(initialValue);
expect(slicedView[1]).toBe(secondValue);
detachArrayBuffer(buffer);
expect(buffer.detached).toBeTrue();
expect(originalView.length).toBe(0);
expect(originalView[1]).toBeUndefined();
expect(slicedBuffer.detached).toBeFalse();
expect(slicedView[0]).toBe(initialValue);
expect(slicedView[1]).toBe(secondValue);
});
});
test("fixed SharedArrayBuffer cached views stay coherent", () => {
NUMERIC_TYPED_ARRAYS.forEach(T => {
const buffer = new SharedArrayBuffer(T.BYTES_PER_ELEMENT * 4);
const wholeView = new T(buffer);
const offsetView = new T(buffer, T.BYTES_PER_ELEMENT, 2);
const initialValue = initialValueFor(T);
const secondValue = secondValueFor(T);
wholeView[1] = initialValue;
expect(offsetView[0]).toBe(initialValue);
offsetView[1] = secondValue;
expect(wholeView[2]).toBe(secondValue);
expect(Reflect.get(wholeView, 2)).toBe(secondValue);
});
});

View file

@ -0,0 +1,59 @@
non-shared fixed memory grow detaches old views
oldBuffer.detached: true
oldView.length: 0
oldView[0]: undefined
newView[0]: 0x11
newView[PAGE_SIZE - 1]: 0x22
newView[PAGE_SIZE]: 0x0
non-shared resizable memory grow keeps views coherent
buffer.detached: false
buffer.byteLength: 131072
trackingView.length: 131072
fixedEndView.length: 4
trackingView[0]: 0x51
trackingView[PAGE_SIZE - 1]: 0x52
trackingView[PAGE_SIZE]: 0x53
freshView[PAGE_SIZE]: 0x53
non-shared resizable buffer.resize keeps views coherent
buffer.detached: false
buffer.byteLength: 131072
trackingView.length: 131072
fixedEndView.length: 2
trackingView[1]: 0x54
trackingView[PAGE_SIZE - 1]: 0x55
trackingView[PAGE_SIZE + 1]: 0x56
freshView[PAGE_SIZE + 1]: 0x56
non-shared resizable to fixed conversion detaches old resizable views
resizableBuffer.detached: true
trackingView.length: 0
fixedBuffer.detached: true
fixedView.length: 0
newView[0]: 0x61
shared fixed memory grow keeps old in-bounds views coherent
firstBuffer.detached: undefined
firstBuffer.byteLength: 65536
secondBuffer.byteLength: 131072
firstView.length: 65536
firstView[0]: 0x73
firstView[PAGE_SIZE - 1]: 0x72
firstView[PAGE_SIZE]: undefined
secondView[0]: 0x73
secondView[PAGE_SIZE]: 0x74
shared resizable memory grow keeps tracking views coherent
buffer.detached: undefined
buffer.byteLength: 131072
trackingView.length: 131072
fixedEndView.length: 4
trackingView[0]: 0x81
trackingView[PAGE_SIZE - 1]: 0x82
trackingView[PAGE_SIZE]: 0x83
freshView[PAGE_SIZE]: 0x83
shared resizable buffer.grow keeps views coherent
buffer.detached: undefined
buffer.byteLength: 131072
trackingView.length: 131072
fixedEndView.length: 2
trackingView[2]: 0x84
trackingView[PAGE_SIZE - 1]: 0x85
trackingView[PAGE_SIZE + 2]: 0x86
freshView[PAGE_SIZE + 2]: 0x86

View file

@ -0,0 +1,174 @@
<!doctype html>
<script src="include.js"></script>
<script>
const PAGE_SIZE = 65536;
function hex(value) {
return value === undefined ? "undefined" : `0x${value.toString(16)}`;
}
function touch(view, index, value) {
for (let i = 0; i < 16; ++i) view[index] = value;
return view[index];
}
test(() => {
println("non-shared fixed memory grow detaches old views");
{
const memory = new WebAssembly.Memory({ initial: 1, maximum: 3 });
const oldBuffer = memory.buffer;
const oldView = new Uint8Array(oldBuffer);
touch(oldView, 0, 0x11);
touch(oldView, PAGE_SIZE - 1, 0x22);
memory.grow(1);
const newView = new Uint8Array(memory.buffer);
oldView[0] = 0x33;
println(`oldBuffer.detached: ${oldBuffer.detached}`);
println(`oldView.length: ${oldView.length}`);
println(`oldView[0]: ${hex(oldView[0])}`);
println(`newView[0]: ${hex(newView[0])}`);
println(`newView[PAGE_SIZE - 1]: ${hex(newView[PAGE_SIZE - 1])}`);
println(`newView[PAGE_SIZE]: ${hex(newView[PAGE_SIZE])}`);
}
println("non-shared resizable memory grow keeps views coherent");
{
const memory = new WebAssembly.Memory({ initial: 1, maximum: 3 });
const buffer = memory.toResizableBuffer();
const trackingView = new Uint8Array(buffer);
const fixedEndView = new Uint8Array(buffer, PAGE_SIZE - 4, 4);
touch(trackingView, 0, 0x51);
touch(fixedEndView, 3, 0x52);
memory.grow(1);
touch(trackingView, PAGE_SIZE, 0x53);
const freshView = new Uint8Array(memory.buffer);
println(`buffer.detached: ${buffer.detached}`);
println(`buffer.byteLength: ${buffer.byteLength}`);
println(`trackingView.length: ${trackingView.length}`);
println(`fixedEndView.length: ${fixedEndView.length}`);
println(`trackingView[0]: ${hex(trackingView[0])}`);
println(`trackingView[PAGE_SIZE - 1]: ${hex(trackingView[PAGE_SIZE - 1])}`);
println(`trackingView[PAGE_SIZE]: ${hex(trackingView[PAGE_SIZE])}`);
println(`freshView[PAGE_SIZE]: ${hex(freshView[PAGE_SIZE])}`);
}
println("non-shared resizable buffer.resize keeps views coherent");
{
const memory = new WebAssembly.Memory({ initial: 1, maximum: 3 });
const buffer = memory.toResizableBuffer();
const trackingView = new Uint8Array(buffer);
const fixedEndView = new Uint8Array(buffer, PAGE_SIZE - 2, 2);
touch(trackingView, 1, 0x54);
touch(fixedEndView, 1, 0x55);
buffer.resize(2 * PAGE_SIZE);
touch(trackingView, PAGE_SIZE + 1, 0x56);
const freshView = new Uint8Array(memory.buffer);
println(`buffer.detached: ${buffer.detached}`);
println(`buffer.byteLength: ${buffer.byteLength}`);
println(`trackingView.length: ${trackingView.length}`);
println(`fixedEndView.length: ${fixedEndView.length}`);
println(`trackingView[1]: ${hex(trackingView[1])}`);
println(`trackingView[PAGE_SIZE - 1]: ${hex(trackingView[PAGE_SIZE - 1])}`);
println(`trackingView[PAGE_SIZE + 1]: ${hex(trackingView[PAGE_SIZE + 1])}`);
println(`freshView[PAGE_SIZE + 1]: ${hex(freshView[PAGE_SIZE + 1])}`);
}
println("non-shared resizable to fixed conversion detaches old resizable views");
{
const memory = new WebAssembly.Memory({ initial: 1, maximum: 3 });
const resizableBuffer = memory.toResizableBuffer();
const trackingView = new Uint8Array(resizableBuffer);
touch(trackingView, 0, 0x61);
const fixedBuffer = memory.toFixedLengthBuffer();
const fixedView = new Uint8Array(fixedBuffer);
memory.grow(1);
const newView = new Uint8Array(memory.buffer);
println(`resizableBuffer.detached: ${resizableBuffer.detached}`);
println(`trackingView.length: ${trackingView.length}`);
println(`fixedBuffer.detached: ${fixedBuffer.detached}`);
println(`fixedView.length: ${fixedView.length}`);
println(`newView[0]: ${hex(newView[0])}`);
}
println("shared fixed memory grow keeps old in-bounds views coherent");
{
const memory = new WebAssembly.Memory({ initial: 1, maximum: 3, shared: true });
const firstBuffer = memory.buffer;
const firstView = new Uint8Array(firstBuffer);
touch(firstView, 0, 0x71);
touch(firstView, PAGE_SIZE - 1, 0x72);
memory.grow(1);
const secondBuffer = memory.buffer;
const secondView = new Uint8Array(secondBuffer);
touch(secondView, 0, 0x73);
touch(secondView, PAGE_SIZE, 0x74);
println(`firstBuffer.detached: ${firstBuffer.detached}`);
println(`firstBuffer.byteLength: ${firstBuffer.byteLength}`);
println(`secondBuffer.byteLength: ${secondBuffer.byteLength}`);
println(`firstView.length: ${firstView.length}`);
println(`firstView[0]: ${hex(firstView[0])}`);
println(`firstView[PAGE_SIZE - 1]: ${hex(firstView[PAGE_SIZE - 1])}`);
println(`firstView[PAGE_SIZE]: ${hex(firstView[PAGE_SIZE])}`);
println(`secondView[0]: ${hex(secondView[0])}`);
println(`secondView[PAGE_SIZE]: ${hex(secondView[PAGE_SIZE])}`);
}
println("shared resizable memory grow keeps tracking views coherent");
{
const memory = new WebAssembly.Memory({ initial: 1, maximum: 3, shared: true });
const buffer = memory.toResizableBuffer();
const trackingView = new Uint8Array(buffer);
const fixedEndView = new Uint8Array(buffer, PAGE_SIZE - 4, 4);
touch(trackingView, 0, 0x81);
touch(fixedEndView, 3, 0x82);
memory.grow(1);
touch(trackingView, PAGE_SIZE, 0x83);
const freshView = new Uint8Array(memory.buffer);
println(`buffer.detached: ${buffer.detached}`);
println(`buffer.byteLength: ${buffer.byteLength}`);
println(`trackingView.length: ${trackingView.length}`);
println(`fixedEndView.length: ${fixedEndView.length}`);
println(`trackingView[0]: ${hex(trackingView[0])}`);
println(`trackingView[PAGE_SIZE - 1]: ${hex(trackingView[PAGE_SIZE - 1])}`);
println(`trackingView[PAGE_SIZE]: ${hex(trackingView[PAGE_SIZE])}`);
println(`freshView[PAGE_SIZE]: ${hex(freshView[PAGE_SIZE])}`);
}
println("shared resizable buffer.grow keeps views coherent");
{
const memory = new WebAssembly.Memory({ initial: 1, maximum: 3, shared: true });
const buffer = memory.toResizableBuffer();
const trackingView = new Uint8Array(buffer);
const fixedEndView = new Uint8Array(buffer, PAGE_SIZE - 2, 2);
touch(trackingView, 2, 0x84);
touch(fixedEndView, 1, 0x85);
buffer.grow(2 * PAGE_SIZE);
touch(trackingView, PAGE_SIZE + 2, 0x86);
const freshView = new Uint8Array(memory.buffer);
println(`buffer.detached: ${buffer.detached}`);
println(`buffer.byteLength: ${buffer.byteLength}`);
println(`trackingView.length: ${trackingView.length}`);
println(`fixedEndView.length: ${fixedEndView.length}`);
println(`trackingView[2]: ${hex(trackingView[2])}`);
println(`trackingView[PAGE_SIZE - 1]: ${hex(trackingView[PAGE_SIZE - 1])}`);
println(`trackingView[PAGE_SIZE + 2]: ${hex(trackingView[PAGE_SIZE + 2])}`);
println(`freshView[PAGE_SIZE + 2]: ${hex(freshView[PAGE_SIZE + 2])}`);
}
});
</script>