authorclover caruso <git@paperclover.net> 2026-10-10 21:00:37-07:00
committerclover caruso <git@paperclover.net> 2026-10-10 22:56:06-07:00
log7baef319fd7df019dca65653490704ee440341cd
tree9e46e237d78e7eccea57f67f3992c6c2eea42db5
parent0a2c80e2cfc2b9470ace4d6e4b752bc34c6dd7f9
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

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

2 files changed, 22 insertions(+), 2 deletions(-)

crates/snowbound/web/glue.js+3-1
......@@ -32,14 +32,16 @@ function storageWorker() {
3232 return dir;
3333 };
3434 const handle = async (path) => {
35 const parts = names(path);
36 path = parts.join("/");
3537 if (!handles.has(path)) {
36 const parts = names(path);
3738 const file = await (await folder(parts.slice(0, -1))).getFileHandle(parts.at(-1), { create: true });
3839 handles.set(path, await file.createSyncAccessHandle());
3940 }
4041 return handles.get(path);
4142 };
4243 const close = (path) => {
44 path = names(path).join("/");
4345 for (const [held, open] of handles)
4446 if (held === path || held.startsWith(`${path}/`)) {
4547 open.close();
tools/web/test_storage.mjs+19-1
......@@ -5,9 +5,11 @@ import vm from 'node:vm';
55
66const source = await readFile(new URL('../../crates/snowbound/web/glue.js', import.meta.url), 'utf8');
77
8test('an OPFS flush rejection retains ordered writes until storage recovers', async () => {
8test('OPFS retries preserve write order and canonical handle ownership', async () => {
99 const files = new Map([['existing', new Uint8Array([0])]]);
1010 const flushed = [];
11 const held = new Set();
12 const opened = [];
1113 const replies = [];
1214 let unavailable = false;
1315 const root = {
......@@ -16,11 +18,19 @@ test('an OPFS flush rejection retains ordered writes until storage recovers', as
1618 yield [name, { kind: 'file', getFile: async () => ({ arrayBuffer: async () => bytes.buffer }) }];
1719 }
1820 },
21 async removeEntry(path) {
22 assert.equal(held.has(path), false, 'Removal closes the existing access handle');
23 files.delete(path);
24 },
1925 async getFileHandle(path) {
2026 let bytes = files.get(path)?.slice() ?? new Uint8Array();
2127 return {
2228 async createSyncAccessHandle() {
29 if (held.has(path)) throw new Error('Another access handle is open for ' + path);
30 held.add(path);
31 opened.push(path);
2332 return {
33 close() { held.delete(path); },
2434 write(part, { at }) {
2535 if (at + part.length > bytes.length) {
2636 const extended = new Uint8Array(at + part.length);
......@@ -70,4 +80,12 @@ test('an OPFS flush rejection retains ordered writes until storage recovers', as
7080 assert.deepEqual(flushed, [['replica-wal', [1, 2]], ['replica-wal', [1, 3]], ['replica', [4]]]);
7181 assert.equal((await send({ kind: 'settle' })).error, null);
7282 assert.equal(flushed.length, 3);
83 assert.equal((await send({ kind: 'store', changes: [['/replica/', 1, [[0, new Uint8Array([5])]]]] })).error, null);
84 assert.deepEqual([...files.get('replica')], [5]);
85 assert.equal(opened.filter(path => path === 'replica').length, 1, 'Aliases reuse the existing handle');
86 assert.equal((await send({ kind: 'store', changes: [['/replica/']] })).error, null);
87 assert.equal(files.has('replica'), false);
88 assert.equal((await send({ kind: 'store', changes: [['replica', 1, [[0, new Uint8Array([6])]]]] })).error, null);
89 assert.deepEqual([...files.get('replica')], [6]);
90 assert.equal(opened.filter(path => path === 'replica').length, 2, 'Recreation opens a new handle');
7391});