authorclover caruso <git@paperclover.net> 2026-10-10 19:07:38-07:00
committerclover caruso <git@paperclover.net> 2026-10-10 22:56:05-07:00
log33d9c0e17eaf06bde98a3a918d895d5d2c829e23
treeb5e4f5520a939eede27e085e7e89a4ab4988f8ab
parentfe5ea9c240ad4fc12520c83370d4938f97ff87c5
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix(web): retain failed folder writes before source deletion

Reject durability receipts when a mounted-folder write fails and retry the original ranges before following writes. Bound each receipt to the writes present at submission so later edits cannot starve it. Coalesce best-effort folder scans and propagate removal errors except already missing entries. Old glue fails both destination-error and removal-error regressions (2 failed, exit 1). New glue passes all 8 Node tests, including prefix receipt timing and retry without duplicate successful writes. Fixes #111 Assisted-by: gpt-6.1-sol

2 files changed, 135 insertions(+), 14 deletions(-)

crates/snowbound/web/glue.js+45-14
...@@ -186,9 +186,30 @@ export function storeFiles(changes) {...@@ -186,9 +186,30 @@ export function storeFiles(changes) {
186// `notebook::fs` under FOLDERS, their handles kept in IndexedDB to reach them again.186// `notebook::fs` under FOLDERS, their handles kept in IndexedDB to reach them again.
187const FOLDERS = "/Folders";187const FOLDERS = "/Folders";
188const folders = new Map();188const folders = new Map();
189let writing = Promise.resolve();189const pendingWrites = [];
190const serially = (work) =>190let writing;
191 (writing = writing.then(work).catch((error) => console.error("Writing to the folder", error)));191function writeFolders(through = pendingWrites.at(-1)) {
192 if (!through) return writing ?? Promise.resolve();
193 if (writing) return writing.then(() => {
194 if (pendingWrites.includes(through)) return writeFolders(through);
195 });
196 return writing = Promise.resolve().then(async () => {
197 try {
198 while (pendingWrites.length) {
199 const work = pendingWrites[0];
200 await work();
201 pendingWrites.shift();
202 if (work === through) return;
203 }
204 } finally {
205 writing = undefined;
206 }
207 });
208}
209const serially = (work) => {
210 pendingWrites.push(work);
211 writeFolders().catch((error) => console.error("Writing to the folder", error));
212};
192const folderOf = (path) =>213const folderOf = (path) =>
193 [...folders.keys()].find((root) => path === root || path.startsWith(`${root}/`));214 [...folders.keys()].find((root) => path === root || path.startsWith(`${root}/`));
194const stamp = (file) => `${file.size}:${file.lastModified}`;215const stamp = (file) => `${file.size}:${file.lastModified}`;
...@@ -252,21 +273,27 @@ export async function loadFolders() {...@@ -252,21 +273,27 @@ export async function loadFolders() {
252 }273 }
253 }274 }
254 // Other apps' writes reach the page once they land, as a watch would report them.275 // Other apps' writes reach the page once they land, as a watch would report them.
255 setInterval(() => serially(look), 3000);276 setInterval(() => {
277 if (!pendingWrites.includes(look)) serially(look);
278 }, 3000);
256 return out;279 return out;
257}280}
258281
259/** Hands the page what changed in each folder since it was last read. */282/** Hands the page what changed in each folder since it was last read. */
260async function look() {283async function look() {
261 for (const [root, folder] of folders) {284 try {
262 const seen = new Map();285 for (const [root, folder] of folders) {
263 for (const [path, file] of await walk(folder.handle, root)) {286 const seen = new Map();
264 seen.set(path, file ? stamp(file) : "folder");287 for (const [path, file] of await walk(folder.handle, root)) {
265 if (folder.known.get(path) !== seen.get(path))288 seen.set(path, file ? stamp(file) : "folder");
266 wasm.refreshed(path, file ? new Uint8Array(await file.arrayBuffer()) : null, file?.lastModified ?? 0);289 if (folder.known.get(path) !== seen.get(path))
290 wasm.refreshed(path, file ? new Uint8Array(await file.arrayBuffer()) : null, file?.lastModified ?? 0);
291 }
292 for (const path of folder.known.keys()) if (!seen.has(path)) wasm.refreshed(path, undefined, 0);
293 folder.known = seen;
267 }294 }
268 for (const path of folder.known.keys()) if (!seen.has(path)) wasm.refreshed(path, undefined, 0);295 } catch (error) {
269 folder.known = seen;296 console.error("Reading the folder", error);
270 }297 }
271}298}
272299
...@@ -282,7 +309,11 @@ async function writeFolder([path, length, ranges, committed]) {...@@ -282,7 +309,11 @@ async function writeFolder([path, length, ranges, committed]) {
282 if (folders.has(path)) return;309 if (folders.has(path)) return;
283 const [dir, name, folder] = await locate(path);310 const [dir, name, folder] = await locate(path);
284 if (length === undefined) {311 if (length === undefined) {
285 await dir.removeEntry(name, { recursive: true }).catch(() => {});312 try {
313 await dir.removeEntry(name, { recursive: true });
314 } catch (error) {
315 if (error.name !== "NotFoundError") throw error;
316 }
286 for (const known of [...folder.known.keys()])317 for (const known of [...folder.known.keys()])
287 if (known === path || known.startsWith(`${path}/`)) folder.known.delete(known);318 if (known === path || known.startsWith(`${path}/`)) folder.known.delete(known);
288 return;319 return;
...@@ -354,7 +385,7 @@ function settled() {...@@ -354,7 +385,7 @@ function settled() {
354 settling.push({ resolve, reject });385 settling.push({ resolve, reject });
355 storage.postMessage({ kind: "settle" });386 storage.postMessage({ kind: "settle" });
356 });387 });
357 return Promise.all([kept, writing]);388 return Promise.all([kept, writeFolders()]);
358}389}
359390
360// A deployed build's module and JavaScript lie in b/HASH/, which names the build.391// A deployed build's module and JavaScript lie in b/HASH/, which names the build.
tools/web/test_folder_durability.mjs created+90
...@@ -0,0 +1,90 @@
1import assert from 'node:assert/strict';
2import { readFile } from 'node:fs/promises';
3import test from 'node:test';
4import vm from 'node:vm';
5
6async function browser(folder) {
7 const source = await readFile(new URL('../../crates/snowbound/web/glue.js', import.meta.url), 'utf8');
8 const context = vm.createContext({ folder, queueMicrotask, console: { error() {} } });
9 vm.runInContext(source.replace(/\bexport /g, '').replaceAll('import.meta.url', JSON.stringify(import.meta.url)), context);
10 vm.runInContext(`
11 storage = { postMessage() { queueMicrotask(() => settling.shift().resolve()); } };
12 wasm = { refreshed() {} };
13 folders.set('/Folders/Notebook', { handle: folder, known: new Map() });
14 `, context);
15 return context;
16}
17
18test('a failed destination write refuses source deletion and retries before later writes', async () => {
19 const attempts = [];
20 const written = [];
21 let failing = true;
22 const context = await browser({
23 async getFileHandle(name) {
24 return {
25 async getFile() { return { size: 0, lastModified: 0 }; },
26 async createWritable() {
27 attempts.push(name);
28 if (failing) throw Error('Destination unavailable');
29 return {
30 async write({ data }) { written.push([name, [...data]]); },
31 async truncate() {},
32 async close() {},
33 };
34 },
35 };
36 },
37 });
38 vm.runInContext(`
39 storeFiles([
40 ['/Folders/Notebook/destination.one', 1, [[0, new Uint8Array([1])]]],
41 ['/Folders/Notebook/following.one', 1, [[0, new Uint8Array([2])]]],
42 ]);
43 `, context);
44 let sourceDeleted = false;
45 const move = vm.runInContext('settled()', context).then(() => { sourceDeleted = true; });
46 await assert.rejects(move, /Destination unavailable/);
47 assert.equal(sourceDeleted, false);
48 assert.deepEqual(attempts, ['destination.one']);
49 assert.deepEqual(written, []);
50 failing = false;
51 await vm.runInContext('settled()', context).then(() => { sourceDeleted = true; });
52 assert.equal(sourceDeleted, true);
53 assert.deepEqual(attempts, ['destination.one', 'destination.one', 'following.one']);
54 assert.deepEqual(written, [['destination.one', [1]], ['following.one', [2]]]);
55 vm.runInContext("storeFiles([['/Folders/Notebook/later.one', 1, [[0, new Uint8Array([3])]]]])", context);
56 await vm.runInContext('settled()', context);
57 assert.deepEqual(written, [['destination.one', [1]], ['following.one', [2]], ['later.one', [3]]]);
58});
59
60test('folder deletion retains permission failures and accepts an already missing entry', async () => {
61 const attempts = [];
62 let failing = true;
63 const context = await browser({
64 async removeEntry(name) {
65 attempts.push(name);
66 const error = Error(failing ? 'Permission refused' : 'Already removed');
67 error.name = failing ? 'NotAllowedError' : 'NotFoundError';
68 throw error;
69 },
70 });
71 vm.runInContext("storeFiles([['/Folders/Notebook/deleted.one']])", context);
72 await assert.rejects(vm.runInContext('settled()', context), /Permission refused/);
73 failing = false;
74 await vm.runInContext('settled()', context);
75 assert.deepEqual(attempts, ['deleted.one', 'deleted.one']);
76});
77
78
79test('a destination receipt completes without waiting for later folder writes', async () => {
80 let release;
81 const first = new Promise((resolve) => { release = resolve; });
82 const context = await browser({});
83 context.first = first;
84 vm.runInContext('serially(() => first)', context);
85 const receipt = vm.runInContext('settled()', context);
86 vm.runInContext("serially(() => Promise.reject(Error('Later failure')))", context);
87 release();
88 await receipt;
89 await assert.rejects(vm.runInContext('settled()', context), /Later failure/);
90});