From faed1591eff13d072b3c778fc3fa5b8d8712309e Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Mon, 7 Sep 2026 22:34:47 +0200 Subject: [PATCH] fix: fail fast when the plugin storage database is blocked #840 --- lib/__tests__/plugin-storage-blocked.test.ts | 59 ++++++++++++++++++++ lib/plugin-storage.ts | 39 +++++++++++-- 2 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 lib/__tests__/plugin-storage-blocked.test.ts diff --git a/lib/__tests__/plugin-storage-blocked.test.ts b/lib/__tests__/plugin-storage-blocked.test.ts new file mode 100644 index 000000000..4567b23b7 --- /dev/null +++ b/lib/__tests__/plugin-storage-blocked.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import 'fake-indexeddb/auto'; +import { fileStorage } from '../plugin-storage'; + +const DB_NAME = 'bulwark-plugins'; +// One below the version plugin-storage.ts opens, simulating an older tab. +const PREVIOUS_DB_VERSION = 2; + +function openAt(version: number): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, version); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +describe('plugin storage when the upgrade is blocked (#840)', () => { + it('rejects saveFile instead of hanging while an older tab keeps the DB open', async () => { + // An "older tab" that never reacts to versionchange, so the upgrade to the + // current DB_VERSION cannot proceed. + const olderTab = await openAt(PREVIOUS_DB_VERSION); + expect(olderTab.version).toBe(PREVIOUS_DB_VERSION); + + const file = new File(['hello'], 'hello.txt', { type: 'text/plain' }); + const outcome = await Promise.race([ + fileStorage.saveFile('blocked-file', file).then( + () => 'resolved', + (err: unknown) => (err instanceof Error ? err.message : String(err)), + ), + new Promise((resolve) => setTimeout(() => resolve('timeout'), 2000)), + ]); + + expect(outcome).not.toBe('timeout'); + expect(outcome).not.toBe('resolved'); + expect(outcome).toMatch(/blocked/i); + + olderTab.close(); + }); + + it('closes its own connection so a later upgrade is not blocked by it', async () => { + const file = new File(['hello'], 'hello.txt', { type: 'text/plain' }); + await fileStorage.saveFile('closed-file', file); + expect(await fileStorage.getFile('closed-file')).not.toBeNull(); + + // If plugin-storage left its connection open, opening at a higher version + // would fire `blocked` (no onversionchange handler on a foreign handle); + // with the fix the module closes after every transaction. + const blocked = await new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 99); + req.onblocked = () => resolve(true); + req.onsuccess = () => { + req.result.close(); + resolve(false); + }; + req.onerror = () => reject(req.error); + }); + expect(blocked).toBe(false); + }); +}); diff --git a/lib/plugin-storage.ts b/lib/plugin-storage.ts index 988636667..5ce4b5dd5 100644 --- a/lib/plugin-storage.ts +++ b/lib/plugin-storage.ts @@ -32,8 +32,23 @@ function openDB(): Promise { } }; - request.onsuccess = () => resolve(request.result); + request.onsuccess = () => { + const db = request.result; + // Another tab bumping DB_VERSION needs every older connection gone + // before its upgrade can run; close ours instead of blocking it. + db.onversionchange = () => db.close(); + resolve(db); + }; request.onerror = () => reject(request.error); + // Without this the open request never settles while an older tab keeps + // the previous version open, and every caller (attachment upload, plugin + // loading) hangs silently. (#840) + request.onblocked = () => + reject( + new Error( + 'Plugin storage database is blocked by another open tab. Close other Bulwark tabs and try again.', + ), + ); }); } @@ -42,8 +57,14 @@ async function putItem(storeName: string, key: string, value: string | Blob | Fi return new Promise((resolve, reject) => { const tx = db.transaction(storeName, 'readwrite'); tx.objectStore(storeName).put(value, key); - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); + reject(tx.error); + }; }); } @@ -54,6 +75,8 @@ async function getItem(storeName: string, key: string): Promise resolve(request.result ?? null); request.onerror = () => reject(request.error); + tx.oncomplete = () => db.close(); + tx.onerror = () => db.close(); }); } @@ -62,8 +85,14 @@ async function deleteItem(storeName: string, key: string): Promise { return new Promise((resolve, reject) => { const tx = db.transaction(storeName, 'readwrite'); tx.objectStore(storeName).delete(key); - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); + reject(tx.error); + }; }); }