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() {...@@ -32,14 +32,16 @@ function storageWorker() {
32 return dir;32 return dir;
33 };33 };
34 const handle = async (path) => {34 const handle = async (path) => {
35 const parts = names(path);
36 path = parts.join("/");
35 if (!handles.has(path)) {37 if (!handles.has(path)) {
36 const parts = names(path);
37 const file = await (await folder(parts.slice(0, -1))).getFileHandle(parts.at(-1), { create: true });38 const file = await (await folder(parts.slice(0, -1))).getFileHandle(parts.at(-1), { create: true });
38 handles.set(path, await file.createSyncAccessHandle());39 handles.set(path, await file.createSyncAccessHandle());
39 }40 }
40 return handles.get(path);41 return handles.get(path);
41 };42 };
42 const close = (path) => {43 const close = (path) => {
44 path = names(path).join("/");
43 for (const [held, open] of handles)45 for (const [held, open] of handles)
44 if (held === path || held.startsWith(`${path}/`)) {46 if (held === path || held.startsWith(`${path}/`)) {
45 open.close();47 open.close();
tools/web/test_storage.mjs+19-1
...@@ -5,9 +5,11 @@ import vm from 'node:vm';...@@ -5,9 +5,11 @@ import vm from 'node:vm';
55
6const source = await readFile(new URL('../../crates/snowbound/web/glue.js', import.meta.url), 'utf8');6const 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 () => {
9 const files = new Map([['existing', new Uint8Array([0])]]);9 const files = new Map([['existing', new Uint8Array([0])]]);
10 const flushed = [];10 const flushed = [];
11 const held = new Set();
12 const opened = [];
11 const replies = [];13 const replies = [];
12 let unavailable = false;14 let unavailable = false;
13 const root = {15 const root = {
...@@ -16,11 +18,19 @@ test('an OPFS flush rejection retains ordered writes until storage recovers', as...@@ -16,11 +18,19 @@ test('an OPFS flush rejection retains ordered writes until storage recovers', as
16 yield [name, { kind: 'file', getFile: async () => ({ arrayBuffer: async () => bytes.buffer }) }];18 yield [name, { kind: 'file', getFile: async () => ({ arrayBuffer: async () => bytes.buffer }) }];
17 }19 }
18 },20 },
21 async removeEntry(path) {
22 assert.equal(held.has(path), false, 'Removal closes the existing access handle');
23 files.delete(path);
24 },
19 async getFileHandle(path) {25 async getFileHandle(path) {
20 let bytes = files.get(path)?.slice() ?? new Uint8Array();26 let bytes = files.get(path)?.slice() ?? new Uint8Array();
21 return {27 return {
22 async createSyncAccessHandle() {28 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);
23 return {32 return {
33 close() { held.delete(path); },
24 write(part, { at }) {34 write(part, { at }) {
25 if (at + part.length > bytes.length) {35 if (at + part.length > bytes.length) {
26 const extended = new Uint8Array(at + part.length);36 const extended = new Uint8Array(at + part.length);
...@@ -70,4 +80,12 @@ test('an OPFS flush rejection retains ordered writes until storage recovers', as...@@ -70,4 +80,12 @@ test('an OPFS flush rejection retains ordered writes until storage recovers', as
70 assert.deepEqual(flushed, [['replica-wal', [1, 2]], ['replica-wal', [1, 3]], ['replica', [4]]]);80 assert.deepEqual(flushed, [['replica-wal', [1, 2]], ['replica-wal', [1, 3]], ['replica', [4]]]);
71 assert.equal((await send({ kind: 'settle' })).error, null);81 assert.equal((await send({ kind: 'settle' })).error, null);
72 assert.equal(flushed.length, 3);82 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');
73});91});