diff --git a/crates/snowbound/web/glue.js b/crates/snowbound/web/glue.js index 145dc52eed3fb9749451e9a545291983b3042479..963d55060f03192d5e68c90099ca46f349bd26bf 100644 --- a/crates/snowbound/web/glue.js +++ b/crates/snowbound/web/glue.js @@ -32,14 +32,16 @@ function storageWorker() { return dir; }; const handle = async (path) => { + const parts = names(path); + path = parts.join("/"); if (!handles.has(path)) { - const parts = names(path); const file = await (await folder(parts.slice(0, -1))).getFileHandle(parts.at(-1), { create: true }); handles.set(path, await file.createSyncAccessHandle()); } return handles.get(path); }; const close = (path) => { + path = names(path).join("/"); for (const [held, open] of handles) if (held === path || held.startsWith(`${path}/`)) { open.close(); diff --git a/tools/web/test_storage.mjs b/tools/web/test_storage.mjs index 839d5207cb9a88506230b3168829a784d1b6583e..073ec828fafd95cb76479f05e079e5f7b5a7a816 100644 --- a/tools/web/test_storage.mjs +++ b/tools/web/test_storage.mjs @@ -5,9 +5,11 @@ import vm from 'node:vm'; const source = await readFile(new URL('../../crates/snowbound/web/glue.js', import.meta.url), 'utf8'); -test('an OPFS flush rejection retains ordered writes until storage recovers', async () => { +test('OPFS retries preserve write order and canonical handle ownership', async () => { const files = new Map([['existing', new Uint8Array([0])]]); const flushed = []; + const held = new Set(); + const opened = []; const replies = []; let unavailable = false; const root = { @@ -16,11 +18,19 @@ test('an OPFS flush rejection retains ordered writes until storage recovers', as yield [name, { kind: 'file', getFile: async () => ({ arrayBuffer: async () => bytes.buffer }) }]; } }, + async removeEntry(path) { + assert.equal(held.has(path), false, 'Removal closes the existing access handle'); + files.delete(path); + }, async getFileHandle(path) { let bytes = files.get(path)?.slice() ?? new Uint8Array(); return { async createSyncAccessHandle() { + if (held.has(path)) throw new Error('Another access handle is open for ' + path); + held.add(path); + opened.push(path); return { + close() { held.delete(path); }, write(part, { at }) { if (at + part.length > bytes.length) { const extended = new Uint8Array(at + part.length); @@ -70,4 +80,12 @@ test('an OPFS flush rejection retains ordered writes until storage recovers', as assert.deepEqual(flushed, [['replica-wal', [1, 2]], ['replica-wal', [1, 3]], ['replica', [4]]]); assert.equal((await send({ kind: 'settle' })).error, null); assert.equal(flushed.length, 3); + assert.equal((await send({ kind: 'store', changes: [['/replica/', 1, [[0, new Uint8Array([5])]]]] })).error, null); + assert.deepEqual([...files.get('replica')], [5]); + assert.equal(opened.filter(path => path === 'replica').length, 1, 'Aliases reuse the existing handle'); + assert.equal((await send({ kind: 'store', changes: [['/replica/']] })).error, null); + assert.equal(files.has('replica'), false); + assert.equal((await send({ kind: 'store', changes: [['replica', 1, [[0, new Uint8Array([6])]]]] })).error, null); + assert.deepEqual([...files.get('replica')], [6]); + assert.equal(opened.filter(path => path === 'replica').length, 2, 'Recreation opens a new handle'); });