Skip to content

Commit 67ab591

Browse files
committed
release v0-3-0: improve package.json for esm module resolution; add more tests
1 parent f82bc04 commit 67ab591

3 files changed

Lines changed: 320 additions & 21 deletions

File tree

package.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
{
22
"name": "thumbdrive",
3-
"version": "0.2.0",
3+
"version": "0.3.0",
44
"description": "An OPFS wrapper offering sync access and multi-tab support",
55
"type": "module",
66
"main": "dist/index.js",
7+
"module": "dist/index.js",
78
"types": "dist/index.d.ts",
9+
"exports": {
10+
".": {
11+
"types": "./dist/index.d.ts",
12+
"import": "./dist/index.js",
13+
"default": "./dist/index.js"
14+
}
15+
},
16+
"sideEffects": false,
817
"repository": "gadget-inc/thumbdrive",
918
"author": "Gadget Software Inc.",
1019
"license": "MIT",

spec/e2e/opfs-broker.spec.ts

Lines changed: 286 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,12 @@ declare global {
1414
}
1515
}
1616

17-
function url(ns: string, arena: string) {
18-
return `/testapp/index.html?ns=${encodeURIComponent(ns)}&arena=${encodeURIComponent(arena)}`;
17+
function url(ns: string, arena: string, options?: { timeout?: number }) {
18+
let u = `/testapp/index.html?ns=${encodeURIComponent(ns)}&arena=${encodeURIComponent(arena)}`;
19+
if (options?.timeout !== undefined) {
20+
u += `&timeout=${options.timeout}`;
21+
}
22+
return u;
1923
}
2024

2125
async function setupPage(page: Page) {
@@ -216,3 +220,283 @@ test("leader failover: when leader stops, another becomes leader and RPCs contin
216220

217221
await context.close();
218222
});
223+
224+
test("leader crash: abrupt tab close triggers failover and RPCs recover", async ({ browser }) => {
225+
const context = await browser.newContext();
226+
const [a, b] = await Promise.all([context.newPage(), context.newPage()]);
227+
const ns = `ns-${Date.now()}-${Math.random().toString(36).slice(2)}`;
228+
const arena = `arena-${Math.random().toString(36).slice(2)}`;
229+
230+
[a, b].forEach(setupPage);
231+
await Promise.all([a.goto(url(ns, arena)), b.goto(url(ns, arena))]);
232+
await Promise.all([waitForReady(a), waitForReady(b)]);
233+
await Promise.all([startCandidate(a), startCandidate(b)]);
234+
235+
await expect.poll(async () => ((await isLeader(a)) ? 1 : 0) + ((await isLeader(b)) ? 1 : 0), { timeout: 5000 }).toBe(1);
236+
237+
const leaderPage = (await isLeader(a)) ? a : b;
238+
const followerPage = leaderPage === a ? b : a;
239+
240+
// Write a file before crash
241+
await writeFile(leaderPage, "/pre-crash.txt", "before");
242+
await expect.poll(() => readFile(followerPage, "/pre-crash.txt"), { timeout: 5000 }).toBe("before");
243+
244+
// Crash the leader (abrupt close, no graceful shutdown)
245+
await leaderPage.close();
246+
247+
// Follower should become the new leader
248+
await expect.poll(() => isLeader(followerPage), { timeout: 10000 }).toBe(true);
249+
250+
// RPCs should work on the new leader
251+
await writeFile(followerPage, "/post-crash.txt", "after");
252+
await expect.poll(() => readFile(followerPage, "/post-crash.txt"), { timeout: 5000 }).toBe("after");
253+
254+
await context.close();
255+
});
256+
257+
test("follower request timeout when no leader responds", async ({ browser }) => {
258+
const context = await browser.newContext();
259+
const lockHolder = await context.newPage();
260+
const follower = await context.newPage();
261+
const ns = `ns-${Date.now()}-${Math.random().toString(36).slice(2)}`;
262+
const arena = `arena-${Math.random().toString(36).slice(2)}`;
263+
264+
setupPage(lockHolder);
265+
setupPage(follower);
266+
267+
// Navigate lock holder to same origin so it can hold the Web Lock
268+
await lockHolder.goto(url(ns, arena));
269+
await waitForReady(lockHolder);
270+
271+
// Hold the ns-scoped Web Lock, preventing any broker from becoming leader
272+
const lockName = `opfs-worker-lock-${ns}`;
273+
await lockHolder.evaluate((name: string) => {
274+
return new Promise<void>((resolve) => {
275+
navigator.locks.request(name, () => {
276+
resolve(); // signal that we have the lock
277+
return new Promise(() => {}); // hold it forever
278+
});
279+
});
280+
}, lockName);
281+
282+
// Start the follower with a short timeout (1s)
283+
await follower.goto(url(ns, arena, { timeout: 1000 }));
284+
await waitForReady(follower);
285+
await startCandidate(follower);
286+
287+
// Follower should not be leader
288+
await expect.poll(() => isLeader(follower), { timeout: 2000 }).toBe(false);
289+
290+
// Try to write — should fail since no leader exists to handle the request
291+
const error = await follower.evaluate(async () => {
292+
try {
293+
await window.thumbdriveTest.writeFile("/timeout-test.txt", "should-fail");
294+
return null;
295+
} catch (e: any) {
296+
return e.message || String(e);
297+
}
298+
});
299+
300+
expect(error).toBeTruthy();
301+
expect(error).toContain("timeout");
302+
303+
await context.close();
304+
});
305+
306+
test("restart cycle: stop and start preserves OPFS data", async ({ page }) => {
307+
const ns = `ns-${Date.now()}-${Math.random().toString(36).slice(2)}`;
308+
const arena = `arena-${Math.random().toString(36).slice(2)}`;
309+
310+
setupPage(page);
311+
await page.goto(url(ns, arena));
312+
await waitForReady(page);
313+
await startCandidate(page);
314+
await expect.poll(() => isLeader(page), { timeout: 5000 }).toBe(true);
315+
316+
// Write a file
317+
await writeFile(page, "/restart-test.txt", "before-restart");
318+
await expect.poll(() => readFile(page, "/restart-test.txt"), { timeout: 5000 }).toBe("before-restart");
319+
320+
// Shutdown gracefully
321+
await shutdownLeader(page);
322+
await expect.poll(() => isLeader(page), { timeout: 5000 }).toBe(false);
323+
324+
// Start again
325+
await startCandidate(page);
326+
await expect.poll(() => isLeader(page), { timeout: 5000 }).toBe(true);
327+
328+
// New writes work after restart
329+
await writeFile(page, "/restart-test-2.txt", "after-restart");
330+
await expect.poll(() => readFile(page, "/restart-test-2.txt"), { timeout: 5000 }).toBe("after-restart");
331+
332+
// Can shutdown again cleanly
333+
await shutdownLeader(page);
334+
await expect.poll(() => isLeader(page), { timeout: 5000 }).toBe(false);
335+
});
336+
337+
test("concurrent writes from multiple tabs with overlapping JSONRPC IDs", async ({ browser }) => {
338+
const context = await browser.newContext();
339+
const [a, b, c] = await Promise.all([context.newPage(), context.newPage(), context.newPage()]);
340+
const ns = `ns-${Date.now()}-${Math.random().toString(36).slice(2)}`;
341+
const arena = `arena-${Math.random().toString(36).slice(2)}`;
342+
343+
[a, b, c].forEach(setupPage);
344+
await Promise.all([a.goto(url(ns, arena)), b.goto(url(ns, arena)), c.goto(url(ns, arena))]);
345+
await Promise.all([waitForReady(a), waitForReady(b), waitForReady(c)]);
346+
await Promise.all([startCandidate(a), startCandidate(b), startCandidate(c)]);
347+
348+
await expect
349+
.poll(async () => ((await isLeader(a)) ? 1 : 0) + ((await isLeader(b)) ? 1 : 0) + ((await isLeader(c)) ? 1 : 0), { timeout: 5000 })
350+
.toBe(1);
351+
352+
// Fire concurrent writes from all three tabs simultaneously.
353+
// Each tab's vscode-jsonrpc connection assigns IDs starting from 0,
354+
// so the broker's ID rewriting must disambiguate them.
355+
await Promise.all([writeFile(a, "/c1.txt", "from-a"), writeFile(b, "/c2.txt", "from-b"), writeFile(c, "/c3.txt", "from-c")]);
356+
357+
// All files should be readable from any tab
358+
await expect.poll(() => readFile(b, "/c1.txt"), { timeout: 5000 }).toBe("from-a");
359+
await expect.poll(() => readFile(c, "/c2.txt"), { timeout: 5000 }).toBe("from-b");
360+
await expect.poll(() => readFile(a, "/c3.txt"), { timeout: 5000 }).toBe("from-c");
361+
362+
await context.close();
363+
});
364+
365+
test("multiple sequential failovers across three tabs", async ({ browser }) => {
366+
const context = await browser.newContext();
367+
const [a, b, c] = await Promise.all([context.newPage(), context.newPage(), context.newPage()]);
368+
const ns = `ns-${Date.now()}-${Math.random().toString(36).slice(2)}`;
369+
const arena = `arena-${Math.random().toString(36).slice(2)}`;
370+
371+
[a, b, c].forEach(setupPage);
372+
await Promise.all([a.goto(url(ns, arena)), b.goto(url(ns, arena)), c.goto(url(ns, arena))]);
373+
await Promise.all([waitForReady(a), waitForReady(b), waitForReady(c)]);
374+
await Promise.all([startCandidate(a), startCandidate(b), startCandidate(c)]);
375+
376+
const pages = [a, b, c];
377+
378+
async function findLeaderIdx() {
379+
const states = await Promise.all(pages.map((p) => isLeader(p).catch(() => false)));
380+
return states.findIndex((x) => x);
381+
}
382+
383+
// Wait for initial leader
384+
let leaderIdx = -1;
385+
await expect
386+
.poll(
387+
async () => {
388+
leaderIdx = await findLeaderIdx();
389+
return leaderIdx >= 0;
390+
},
391+
{ timeout: 5000 }
392+
)
393+
.toBe(true);
394+
395+
// Write from first leader
396+
await writeFile(pages[leaderIdx], "/failover-1.txt", "leader-1");
397+
398+
// --- First failover: crash the leader ---
399+
const firstLeaderIdx = leaderIdx;
400+
await pages[firstLeaderIdx].close();
401+
402+
const remaining = [0, 1, 2].filter((i) => i !== firstLeaderIdx);
403+
404+
// Wait for a new leader among the remaining tabs
405+
await expect
406+
.poll(
407+
async () => {
408+
const states = await Promise.all(remaining.map((i) => isLeader(pages[i]).catch(() => false)));
409+
return states.some((x) => x);
410+
},
411+
{ timeout: 10000 }
412+
)
413+
.toBe(true);
414+
415+
// Find new leader
416+
let secondLeaderIdx = -1;
417+
for (const i of remaining) {
418+
if (await isLeader(pages[i]).catch(() => false)) {
419+
secondLeaderIdx = i;
420+
break;
421+
}
422+
}
423+
424+
// Write from second leader and verify cross-tab read works
425+
const lastIdx = remaining.find((i) => i !== secondLeaderIdx)!;
426+
await writeFile(pages[secondLeaderIdx], "/failover-2.txt", "leader-2");
427+
await expect.poll(() => readFile(pages[lastIdx], "/failover-2.txt"), { timeout: 5000 }).toBe("leader-2");
428+
429+
// --- Second failover: crash the second leader ---
430+
await pages[secondLeaderIdx].close();
431+
432+
// Last page standing should become leader
433+
await expect.poll(() => isLeader(pages[lastIdx]), { timeout: 10000 }).toBe(true);
434+
435+
// RPCs still work after two consecutive failovers
436+
await writeFile(pages[lastIdx], "/failover-3.txt", "leader-3");
437+
await expect.poll(() => readFile(pages[lastIdx], "/failover-3.txt"), { timeout: 5000 }).toBe("leader-3");
438+
439+
await context.close();
440+
});
441+
442+
test("RPCs sent during failover transition eventually resolve", async ({ browser }) => {
443+
const context = await browser.newContext();
444+
const [a, b, c] = await Promise.all([context.newPage(), context.newPage(), context.newPage()]);
445+
const ns = `ns-${Date.now()}-${Math.random().toString(36).slice(2)}`;
446+
const arena = `arena-${Math.random().toString(36).slice(2)}`;
447+
448+
// Use a short broker timeout so failed RPCs don't block the test for 20s
449+
[a, b, c].forEach(setupPage);
450+
await Promise.all([
451+
a.goto(url(ns, arena, { timeout: 2000 })),
452+
b.goto(url(ns, arena, { timeout: 2000 })),
453+
c.goto(url(ns, arena, { timeout: 2000 })),
454+
]);
455+
await Promise.all([waitForReady(a), waitForReady(b), waitForReady(c)]);
456+
await Promise.all([startCandidate(a), startCandidate(b), startCandidate(c)]);
457+
458+
const pages = [a, b, c];
459+
460+
async function findLeaderIdx() {
461+
const states = await Promise.all(pages.map((p) => isLeader(p).catch(() => false)));
462+
return states.findIndex((x) => x);
463+
}
464+
465+
let leaderIdx = -1;
466+
await expect
467+
.poll(
468+
async () => {
469+
leaderIdx = await findLeaderIdx();
470+
return leaderIdx >= 0;
471+
},
472+
{ timeout: 5000 }
473+
)
474+
.toBe(true);
475+
476+
// Identify the two survivors
477+
const survivors = [0, 1, 2].filter((i) => i !== leaderIdx).map((i) => pages[i]);
478+
479+
// Crash the leader
480+
await pages[leaderIdx].close();
481+
482+
// Immediately fire RPCs from both survivors — the first attempt may time out
483+
// if the broadcast arrives before the new leader is ready, so we retry once.
484+
async function writeWithRetry(page: Page, path: string, content: string) {
485+
try {
486+
await writeFile(page, path, content);
487+
} catch {
488+
// First attempt failed (likely timeout during transition). Retry after
489+
// giving the new leader time to finish booting.
490+
await page.waitForTimeout(500);
491+
await writeFile(page, path, content);
492+
}
493+
}
494+
495+
await Promise.all([writeWithRetry(survivors[0], "/race-1.txt", "race-1"), writeWithRetry(survivors[1], "/race-2.txt", "race-2")]);
496+
497+
// Both writes should be readable from either survivor
498+
await expect.poll(() => readFile(survivors[0], "/race-2.txt"), { timeout: 5000 }).toBe("race-2");
499+
await expect.poll(() => readFile(survivors[1], "/race-1.txt"), { timeout: 5000 }).toBe("race-1");
500+
501+
await context.close();
502+
});

0 commit comments

Comments
 (0)