From 96f54291ee23b014d9fd64d18b9a57a4ae68b996 Mon Sep 17 00:00:00 2001 From: Charles Hoskinson Date: Tue, 14 Jul 2026 19:11:45 -0600 Subject: [PATCH] fix(webgpu): release GPU resources on failure --- backend/accelerated/webgpu/web/package.json | 3 +- .../webgpu/web/src/curvegpu/buffer_pool.ts | 10 + .../webgpu/web/src/curvegpu/context.ts | 1 + .../web/src/curvegpu/msm_gpu_runtime.ts | 25 +- .../webgpu/web/src/curvegpu/msm_pippenger.ts | 268 ++++++++++-------- .../webgpu/web/test/resource_cleanup.test.mjs | 268 ++++++++++++++++++ 6 files changed, 444 insertions(+), 131 deletions(-) create mode 100644 backend/accelerated/webgpu/web/test/resource_cleanup.test.mjs diff --git a/backend/accelerated/webgpu/web/package.json b/backend/accelerated/webgpu/web/package.json index 7da05eab59..7d7fd0fb3c 100644 --- a/backend/accelerated/webgpu/web/package.json +++ b/backend/accelerated/webgpu/web/package.json @@ -38,7 +38,8 @@ "build:wasm:plonk": "npm run build:wasm:assets && npm run build:wasm:plonk:webgpu && npm run build:wasm:plonk:native", "build:wasm:plonk:native": "GOOS=js GOARCH=wasm go build -o dist/assets/plonk-native.wasm ../plonk/internal/wasmruntime/native", "build:wasm:plonk:webgpu": "GOOS=js GOARCH=wasm go build -o dist/assets/plonk-webgpu.wasm ../plonk/internal/wasmruntime/webgpu", - "lint": "eslint ." + "lint": "eslint .", + "test": "npm run build && node --test test/*.test.mjs" }, "devDependencies": { "@eslint/js": "^9.0.0", diff --git a/backend/accelerated/webgpu/web/src/curvegpu/buffer_pool.ts b/backend/accelerated/webgpu/web/src/curvegpu/buffer_pool.ts index e7943b6511..67aa4d88e1 100644 --- a/backend/accelerated/webgpu/web/src/curvegpu/buffer_pool.ts +++ b/backend/accelerated/webgpu/web/src/curvegpu/buffer_pool.ts @@ -28,6 +28,7 @@ export class BufferPool { private readonly pool: Map = new Map(); private readonly meta = new WeakMap(); private totalBytes = 0; + private closed = false; constructor(device: GPUDevice, options?: { maxPooledBytes?: number }) { this.device = device; @@ -39,6 +40,9 @@ export class BufferPool { * May return a cached buffer from a previous `release` call. */ acquire(size: number, usage: number, label?: string): GPUBuffer { + if (this.closed) { + throw new Error("buffer pool is closed"); + } const roundedSize = nextPowerOfTwo(Math.max(4, size)); const key = poolKey(roundedSize, usage); const entries = this.pool.get(key); @@ -63,6 +67,11 @@ export class BufferPool { buffer.destroy(); return; } + if (this.closed) { + buffer.destroy(); + this.meta.delete(buffer); + return; + } if (this.totalBytes + m.size > this.maxBytes) { buffer.destroy(); this.meta.delete(buffer); @@ -83,6 +92,7 @@ export class BufferPool { * is closed to avoid GPU memory leaks. */ destroy(): void { + this.closed = true; for (const entries of this.pool.values()) { for (const { buffer } of entries) { buffer.destroy(); diff --git a/backend/accelerated/webgpu/web/src/curvegpu/context.ts b/backend/accelerated/webgpu/web/src/curvegpu/context.ts index 9e915d2483..603c99c4ac 100644 --- a/backend/accelerated/webgpu/web/src/curvegpu/context.ts +++ b/backend/accelerated/webgpu/web/src/curvegpu/context.ts @@ -105,6 +105,7 @@ export async function createCurveGPUContext(options: CurveGPUContextOptions = {} } closed = true; bufferPool.destroy(); + device.destroy(); }, }; } diff --git a/backend/accelerated/webgpu/web/src/curvegpu/msm_gpu_runtime.ts b/backend/accelerated/webgpu/web/src/curvegpu/msm_gpu_runtime.ts index a4752b2c1e..03c208edc8 100644 --- a/backend/accelerated/webgpu/web/src/curvegpu/msm_gpu_runtime.ts +++ b/backend/accelerated/webgpu/web/src/curvegpu/msm_gpu_runtime.ts @@ -203,17 +203,26 @@ export async function readbackBuffer( buffer: GPUBuffer, size: number, ): Promise { + let mapped = false; const staging = device.createBuffer({ label: "g1-readback-staging", size, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, }); - const encoder = device.createCommandEncoder({ label: "g1-readback-encoder" }); - encoder.copyBufferToBuffer(buffer, 0, staging, 0, size); - device.queue.submit([encoder.finish()]); - await staging.mapAsync(GPUMapMode.READ); - const bytes = new Uint8Array(staging.getMappedRange()).slice(); - staging.unmap(); - staging.destroy(); - return bytes; + try { + const encoder = device.createCommandEncoder({ label: "g1-readback-encoder" }); + encoder.copyBufferToBuffer(buffer, 0, staging, 0, size); + device.queue.submit([encoder.finish()]); + await staging.mapAsync(GPUMapMode.READ); + mapped = true; + const bytes = new Uint8Array(staging.getMappedRange()).slice(); + staging.unmap(); + mapped = false; + return bytes; + } finally { + if (mapped) { + staging.unmap(); + } + staging.destroy(); + } } diff --git a/backend/accelerated/webgpu/web/src/curvegpu/msm_pippenger.ts b/backend/accelerated/webgpu/web/src/curvegpu/msm_pippenger.ts index 9a7168d426..2ae4fcfffe 100644 --- a/backend/accelerated/webgpu/web/src/curvegpu/msm_pippenger.ts +++ b/backend/accelerated/webgpu/web/src/curvegpu/msm_pippenger.ts @@ -68,24 +68,46 @@ export function buildJacPippengerRuntime( count, labelPrefix, } = options; - const weightedBucketOutput = createEmptyPointStorageBuffer(device, `${labelPrefix}-weighted-out`, bucketCountOut, pointBytes); - const weightParams = createParamsBuffer(device, `${labelPrefix}-weight-params`, uniformBytes, { count: bucketCountOut }); - const weightBindGroup = createBindGroupForBuffers(device, kernels.weightJac, `${labelPrefix}-weight-bg`, - bucketOutput, zeroInput, weightedBucketOutput, weightParams, bucketValuesInput); - await submitKernel(device, kernels.weightJac, weightBindGroup, bucketCountOut, `${labelPrefix}-weight`, workgroupSize, debug); + const cleanupBuffers: GPUBuffer[] = []; + let windowOutput: GPUBuffer | undefined; + let succeeded = false; + try { + const weightedBucketOutput = createEmptyPointStorageBuffer(device, `${labelPrefix}-weighted-out`, bucketCountOut, pointBytes); + cleanupBuffers.push(weightedBucketOutput); + const weightParams = createParamsBuffer(device, `${labelPrefix}-weight-params`, uniformBytes, { count: bucketCountOut }); + cleanupBuffers.push(weightParams); + const weightBindGroup = createBindGroupForBuffers(device, kernels.weightJac, `${labelPrefix}-weight-bg`, + bucketOutput, zeroInput, weightedBucketOutput, weightParams, bucketValuesInput); + await submitKernel(device, kernels.weightJac, weightBindGroup, bucketCountOut, `${labelPrefix}-weight`, workgroupSize, debug); - const windowSize = Math.max(1, count * metadata.numWindows) * pointBytes; - const windowUsage = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST; - const windowOutput = pool - ? pool.acquire(windowSize, windowUsage, `${labelPrefix}-window-out`) - : createEmptyPointStorageBuffer(device, `${labelPrefix}-window-out`, count * metadata.numWindows, pointBytes); + const windowSize = Math.max(1, count * metadata.numWindows) * pointBytes; + const windowUsage = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST; + windowOutput = pool + ? pool.acquire(windowSize, windowUsage, `${labelPrefix}-window-out`) + : createEmptyPointStorageBuffer(device, `${labelPrefix}-window-out`, count * metadata.numWindows, pointBytes); - const windowParams = createParamsBuffer(device, `${labelPrefix}-window-params`, uniformBytes, { count: count * metadata.numWindows }); - const windowBindGroup = createBindGroupForBuffers(device, kernels.subsumJac, `${labelPrefix}-window-bg`, - weightedBucketOutput, zeroInput, windowOutput, windowParams, bucketValuesInput, windowStartsInput, windowCountsInput); - await submitKernel(device, kernels.subsumJac, windowBindGroup, count * metadata.numWindows * workgroupSize, - `${labelPrefix}-window`, workgroupSize, debug); - return { windowOutput, cleanupBuffers: [weightedBucketOutput, weightParams, windowParams] }; + const windowParams = createParamsBuffer(device, `${labelPrefix}-window-params`, uniformBytes, { count: count * metadata.numWindows }); + cleanupBuffers.push(windowParams); + const windowBindGroup = createBindGroupForBuffers(device, kernels.subsumJac, `${labelPrefix}-window-bg`, + weightedBucketOutput, zeroInput, windowOutput, windowParams, bucketValuesInput, windowStartsInput, windowCountsInput); + await submitKernel(device, kernels.subsumJac, windowBindGroup, count * metadata.numWindows * workgroupSize, + `${labelPrefix}-window`, workgroupSize, debug); + succeeded = true; + return { windowOutput, cleanupBuffers }; + } finally { + if (!succeeded) { + if (windowOutput) { + if (pool) { + pool.release(windowOutput); + } else { + windowOutput.destroy(); + } + } + for (let i = cleanupBuffers.length - 1; i >= 0; i -= 1) { + cleanupBuffers[i].destroy(); + } + } + } }, }; } @@ -147,118 +169,120 @@ export async function runSparseSignedPippengerMSM(options: { } const storageInUsage = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST; const storagePointUsage = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST; + const pooledBuffers: GPUBuffer[] = []; + const ownedBuffers: GPUBuffer[] = []; + const trackPooled = (buffer: GPUBuffer): GPUBuffer => { + pooledBuffers.push(buffer); + return buffer; + }; + const trackOwned = (buffer: GPUBuffer): GPUBuffer => { + ownedBuffers.push(buffer); + return buffer; + }; - const zeroSize = Math.max(4, pointBytes); - const zeroInput = pool - ? pool.acquire(zeroSize, storageInUsage, `${labelPrefix}-zero`) - : createStorageBufferFromBytes(device, `${labelPrefix}-zero`, zeroPointBytes, pointBytes); - if (pool) { - device.queue.writeBuffer(zeroInput, 0, zeroPointBytes.buffer, zeroPointBytes.byteOffset, zeroPointBytes.byteLength); - } - - const basesSize = Math.max(1, termsPerInstance * count) * pointBytes; - const basesInput = pool - ? pool.acquire(basesSize, storageInUsage, `${labelPrefix}-bases`) - : createStorageBufferFromBytes(device, `${labelPrefix}-bases`, basesBytes, basesSize); - if (pool) { - device.queue.writeBuffer(basesInput, 0, basesBytes.buffer, basesBytes.byteOffset, basesBytes.byteLength); - } - const baseIndicesInput = createU32StorageBuffer(device, `${labelPrefix}-base-indices`, metadata.baseIndices); - const bucketPointersInput = createU32StorageBuffer(device, `${labelPrefix}-bucket-pointers`, metadata.bucketPointers); - const bucketSizesInput = createU32StorageBuffer(device, `${labelPrefix}-bucket-sizes`, metadata.bucketSizes); + try { + const zeroSize = Math.max(4, pointBytes); + const zeroInput = pool + ? trackPooled(pool.acquire(zeroSize, storageInUsage, `${labelPrefix}-zero`)) + : trackOwned(createStorageBufferFromBytes(device, `${labelPrefix}-zero`, zeroPointBytes, pointBytes)); + if (pool) { + device.queue.writeBuffer(zeroInput, 0, zeroPointBytes.buffer, zeroPointBytes.byteOffset, zeroPointBytes.byteLength); + } - const bucketCountOut = metadata.bucketPointers.length; - const bucketSize = Math.max(1, bucketCountOut) * pointBytes; - const bucketOutput = pool - ? pool.acquire(bucketSize, storagePointUsage, `${labelPrefix}-bucket-out`) - : createEmptyPointStorageBuffer(device, `${labelPrefix}-bucket-out`, bucketCountOut, pointBytes); - const bucketParams = createParamsBuffer(device, `${labelPrefix}-bucket-params`, uniformBytes, { - count: bucketCountOut, - termsPerInstance, - window, - numWindows: metadata.numWindows, - bucketCount: metadata.bucketCount, - }); - const bucketBindGroup = createBindGroupForBuffers( - device, - runtime.bucket, - `${labelPrefix}-bucket-bg`, - basesInput, - zeroInput, - bucketOutput, - bucketParams, - baseIndicesInput, - bucketPointersInput, - bucketSizesInput, - ); - await submitKernel(device, runtime.bucket, bucketBindGroup, bucketCountOut, `${labelPrefix}-bucket`, runtime.bucketWorkgroupSize ?? 64, debug); + const basesSize = Math.max(1, termsPerInstance * count) * pointBytes; + const basesInput = pool + ? trackPooled(pool.acquire(basesSize, storageInUsage, `${labelPrefix}-bases`)) + : trackOwned(createStorageBufferFromBytes(device, `${labelPrefix}-bases`, basesBytes, basesSize)); + if (pool) { + device.queue.writeBuffer(basesInput, 0, basesBytes.buffer, basesBytes.byteOffset, basesBytes.byteLength); + } + const baseIndicesInput = trackOwned(createU32StorageBuffer(device, `${labelPrefix}-base-indices`, metadata.baseIndices)); + const bucketPointersInput = trackOwned(createU32StorageBuffer(device, `${labelPrefix}-bucket-pointers`, metadata.bucketPointers)); + const bucketSizesInput = trackOwned(createU32StorageBuffer(device, `${labelPrefix}-bucket-sizes`, metadata.bucketSizes)); - const bucketValuesInput = createU32StorageBuffer(device, `${labelPrefix}-bucket-values`, metadata.bucketValues); - const windowStartsInput = createU32StorageBuffer(device, `${labelPrefix}-window-starts`, metadata.windowStarts); - const windowCountsInput = createU32StorageBuffer(device, `${labelPrefix}-window-counts`, metadata.windowCounts); + const bucketCountOut = metadata.bucketPointers.length; + const bucketSize = Math.max(1, bucketCountOut) * pointBytes; + const bucketOutput = pool + ? trackPooled(pool.acquire(bucketSize, storagePointUsage, `${labelPrefix}-bucket-out`)) + : trackOwned(createEmptyPointStorageBuffer(device, `${labelPrefix}-bucket-out`, bucketCountOut, pointBytes)); + const bucketParams = trackOwned(createParamsBuffer(device, `${labelPrefix}-bucket-params`, uniformBytes, { + count: bucketCountOut, + termsPerInstance, + window, + numWindows: metadata.numWindows, + bucketCount: metadata.bucketCount, + })); + const bucketBindGroup = createBindGroupForBuffers( + device, + runtime.bucket, + `${labelPrefix}-bucket-bg`, + basesInput, + zeroInput, + bucketOutput, + bucketParams, + baseIndicesInput, + bucketPointersInput, + bucketSizesInput, + ); + await submitKernel(device, runtime.bucket, bucketBindGroup, bucketCountOut, `${labelPrefix}-bucket`, runtime.bucketWorkgroupSize ?? 64, debug); - const { windowOutput, cleanupBuffers: windowReductionCleanup } = await runtime.reduceWindows({ - device, - pool, - pointBytes, - uniformBytes, - zeroInput, - bucketOutput, - bucketCountOut, - bucketValuesInput, - windowStartsInput, - windowCountsInput, - metadata, - count, - labelPrefix, - }); + const bucketValuesInput = trackOwned(createU32StorageBuffer(device, `${labelPrefix}-bucket-values`, metadata.bucketValues)); + const windowStartsInput = trackOwned(createU32StorageBuffer(device, `${labelPrefix}-window-starts`, metadata.windowStarts)); + const windowCountsInput = trackOwned(createU32StorageBuffer(device, `${labelPrefix}-window-counts`, metadata.windowCounts)); - const finalSize = Math.max(1, count) * pointBytes; - const finalOutput = pool - ? pool.acquire(finalSize, storagePointUsage, `${labelPrefix}-final-out`) - : createEmptyPointStorageBuffer(device, `${labelPrefix}-final-out`, count, pointBytes); - const finalParams = createParamsBuffer(device, `${labelPrefix}-final-params`, uniformBytes, { - count, - termsPerInstance, - window, - numWindows: metadata.numWindows, - bucketCount: metadata.bucketCount, - }); - const finalBindGroup = createBindGroupForBuffers( - device, - runtime.combine, - `${labelPrefix}-final-bg`, - windowOutput, - zeroInput, - finalOutput, - finalParams, - ); - await submitKernel(device, runtime.combine, finalBindGroup, count, `${labelPrefix}-final`, 64, debug); + const { windowOutput, cleanupBuffers: windowReductionCleanup } = await runtime.reduceWindows({ + device, + pool, + pointBytes, + uniformBytes, + zeroInput, + bucketOutput, + bucketCountOut, + bucketValuesInput, + windowStartsInput, + windowCountsInput, + metadata, + count, + labelPrefix, + }); + if (pool) { + trackPooled(windowOutput); + } else { + trackOwned(windowOutput); + } + windowReductionCleanup.forEach(trackOwned); - const result = await readbackBuffer(device, finalOutput, Math.max(1, count) * pointBytes); + const finalSize = Math.max(1, count) * pointBytes; + const finalOutput = pool + ? trackPooled(pool.acquire(finalSize, storagePointUsage, `${labelPrefix}-final-out`)) + : trackOwned(createEmptyPointStorageBuffer(device, `${labelPrefix}-final-out`, count, pointBytes)); + const finalParams = trackOwned(createParamsBuffer(device, `${labelPrefix}-final-params`, uniformBytes, { + count, + termsPerInstance, + window, + numWindows: metadata.numWindows, + bucketCount: metadata.bucketCount, + })); + const finalBindGroup = createBindGroupForBuffers( + device, + runtime.combine, + `${labelPrefix}-final-bg`, + windowOutput, + zeroInput, + finalOutput, + finalParams, + ); + await submitKernel(device, runtime.combine, finalBindGroup, count, `${labelPrefix}-final`, 64, debug); - if (pool) { - pool.release(zeroInput); - pool.release(basesInput); - pool.release(bucketOutput); - pool.release(windowOutput); - pool.release(finalOutput); - } else { - zeroInput.destroy(); - basesInput.destroy(); - bucketOutput.destroy(); - windowOutput.destroy(); - finalOutput.destroy(); + return await readbackBuffer(device, finalOutput, Math.max(1, count) * pointBytes); + } finally { + if (pool) { + for (let i = pooledBuffers.length - 1; i >= 0; i -= 1) { + pool.release(pooledBuffers[i]); + } + } + for (let i = ownedBuffers.length - 1; i >= 0; i -= 1) { + ownedBuffers[i].destroy(); + } } - baseIndicesInput.destroy(); - bucketPointersInput.destroy(); - bucketSizesInput.destroy(); - bucketValuesInput.destroy(); - windowStartsInput.destroy(); - windowCountsInput.destroy(); - windowReductionCleanup.forEach((buffer) => buffer.destroy()); - bucketParams.destroy(); - finalParams.destroy(); - - return result; } diff --git a/backend/accelerated/webgpu/web/test/resource_cleanup.test.mjs b/backend/accelerated/webgpu/web/test/resource_cleanup.test.mjs new file mode 100644 index 0000000000..2c13c461fe --- /dev/null +++ b/backend/accelerated/webgpu/web/test/resource_cleanup.test.mjs @@ -0,0 +1,268 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +globalThis.GPUBufferUsage = { + STORAGE: 1, + COPY_DST: 2, + COPY_SRC: 4, + MAP_READ: 8, + UNIFORM: 16, +}; +globalThis.GPUMapMode = { READ: 1 }; + +const { + buildJacPippengerRuntime, + runSparseSignedPippengerMSM, +} = await import("../dist/src/curvegpu/msm_pippenger.js"); +const { readbackBuffer } = await import("../dist/src/curvegpu/msm_gpu_runtime.js"); +const { createCurveGPUContext } = await import("../dist/src/curvegpu/context.js"); + +function createFakeDevice(failOnCompletion = Number.POSITIVE_INFINITY, failMap = false) { + const buffers = []; + let completions = 0; + let destroyCalls = 0; + + const device = { + buffers, + limits: { maxComputeWorkgroupSizeX: 256 }, + lost: new Promise(() => {}), + queue: { + writeBuffer() {}, + submit() {}, + onSubmittedWorkDone() { + completions += 1; + if (completions === failOnCompletion) { + return Promise.reject(new Error("forced GPU failure")); + } + return Promise.resolve(); + }, + }, + createBuffer(descriptor) { + const buffer = { + descriptor, + destroyed: false, + mapped: false, + pooled: false, + destroy() { + this.destroyed = true; + }, + mapAsync() { + if (failMap) { + return Promise.reject(new Error("forced map failure")); + } + this.mapped = true; + return Promise.resolve(); + }, + getMappedRange() { + return new ArrayBuffer(descriptor.size); + }, + unmap() { + this.mapped = false; + }, + }; + buffers.push(buffer); + return buffer; + }, + createBindGroup() { + return {}; + }, + createCommandEncoder() { + return { + beginComputePass() { + return { + setPipeline() {}, + setBindGroup() {}, + dispatchWorkgroups() {}, + end() {}, + }; + }, + copyBufferToBuffer() {}, + finish() { + return {}; + }, + }; + }, + destroy() { + destroyCalls += 1; + }, + get destroyCalls() { + return destroyCalls; + }, + }; + return device; +} + +function createFakePool(device) { + const acquired = []; + const released = []; + return { + acquired, + released, + acquire(size, usage, label) { + const buffer = device.createBuffer({ size, usage, label }); + buffer.pooled = true; + acquired.push(buffer); + return buffer; + }, + release(buffer) { + released.push(buffer); + }, + }; +} + +function fakeKernel() { + return { pipeline: {}, bindGroupLayout: {} }; +} + +test("sparse Pippenger releases every buffer when the first dispatch fails", async () => { + const device = createFakeDevice(1); + const pool = createFakePool(device); + const runtime = { + bucket: fakeKernel(), + combine: fakeKernel(), + async reduceWindows() { + throw new Error("unexpected window reduction"); + }, + }; + + await assert.rejects( + runSparseSignedPippengerMSM({ + device, + pool, + runtime, + basesBytes: new Uint8Array(12), + pointBytes: 12, + uniformBytes: 32, + zeroPointBytes: new Uint8Array(12), + scalarWords: new Uint32Array(8), + count: 1, + termsPerInstance: 1, + window: 4, + labelPrefix: "test-msm", + }), + /forced GPU failure/, + ); + + assert.equal(pool.released.length, pool.acquired.length); + assert.deepEqual(new Set(pool.released), new Set(pool.acquired)); + const owned = device.buffers.filter((buffer) => !buffer.pooled); + assert.ok(owned.length > 0); + assert.ok(owned.every((buffer) => buffer.destroyed)); +}); + +test("sparse Pippenger releases every buffer after a successful run", async () => { + const device = createFakeDevice(); + const pool = createFakePool(device); + const runtime = buildJacPippengerRuntime({ + bucket: fakeKernel(), + weightJac: fakeKernel(), + subsumJac: fakeKernel(), + combine: fakeKernel(), + }); + + const result = await runSparseSignedPippengerMSM({ + device, + pool, + runtime, + basesBytes: new Uint8Array(12), + pointBytes: 12, + uniformBytes: 32, + zeroPointBytes: new Uint8Array(12), + scalarWords: new Uint32Array(8), + count: 1, + termsPerInstance: 1, + window: 4, + labelPrefix: "test-msm-success", + }); + + assert.equal(result.byteLength, 12); + assert.equal(pool.released.length, pool.acquired.length); + assert.deepEqual(new Set(pool.released), new Set(pool.acquired)); + const owned = device.buffers.filter((buffer) => !buffer.pooled); + assert.ok(owned.length > 0); + assert.ok(owned.every((buffer) => buffer.destroyed)); +}); + +test("window reduction cleans intermediate buffers when its second dispatch fails", async () => { + const device = createFakeDevice(2); + const pool = createFakePool(device); + const runtime = buildJacPippengerRuntime({ + bucket: fakeKernel(), + weightJac: fakeKernel(), + subsumJac: fakeKernel(), + combine: fakeKernel(), + }); + const input = { destroy() {} }; + + await assert.rejects( + runtime.reduceWindows({ + device, + pool, + pointBytes: 12, + uniformBytes: 32, + zeroInput: input, + bucketOutput: input, + bucketCountOut: 1, + bucketValuesInput: input, + windowStartsInput: input, + windowCountsInput: input, + metadata: { numWindows: 2 }, + count: 1, + labelPrefix: "test-window", + }), + /forced GPU failure/, + ); + + assert.equal(pool.acquired.length, 1); + assert.deepEqual(pool.released, pool.acquired); + const owned = device.buffers.filter((buffer) => !buffer.pooled); + assert.ok(owned.length > 0); + assert.ok(owned.every((buffer) => buffer.destroyed)); +}); + +test("readback destroys its staging buffer when mapping fails", async () => { + const device = createFakeDevice(Number.POSITIVE_INFINITY, true); + const source = { destroy() {} }; + + await assert.rejects(readbackBuffer(device, source, 16), /forced map failure/); + + assert.equal(device.buffers.length, 1); + assert.equal(device.buffers[0].destroyed, true); +}); + +test("closing a context destroys the device and closes its buffer pool once", async () => { + const device = createFakeDevice(); + const adapter = { + limits: {}, + async requestDevice() { + return device; + }, + }; + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + gpu: { + async requestAdapter() { + return adapter; + }, + }, + }, + }); + + const context = await createCurveGPUContext({ requireAdapterLimits: false }); + const cached = context.bufferPool.acquire(8, globalThis.GPUBufferUsage.STORAGE); + context.bufferPool.release(cached); + const checkedOut = context.bufferPool.acquire(32, globalThis.GPUBufferUsage.STORAGE); + + context.close(); + context.close(); + context.bufferPool.release(checkedOut); + + assert.equal(device.destroyCalls, 1); + assert.equal(cached.destroyed, true); + assert.equal(checkedOut.destroyed, true); + assert.throws( + () => context.bufferPool.acquire(4, globalThis.GPUBufferUsage.STORAGE), + /buffer pool is closed/, + ); +});