From 7baef319fd7df019dca65653490704ee440341cd Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sat, 10 Oct 2026 21:00:37 -0700 Subject: [PATCH] fix: canonicalize browser storage handle ownership OPFS resolves paths with trailing slashes to the same file. Derive cached handle keys and removal matching from the same path components, so password replacement reuses and closes the existing owner. Cover alias writes, removal and recreation alongside ordered outage recovery. Fixes #118 Assisted-by: gpt-6.1-sol --- crates/snowbound/web/glue.js | 4 +++- tools/web/test_storage.mjs | 20 +++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) 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'); }); -- 2.54.0