fix: fail fast when the plugin storage database is blocked #840

This commit is contained in:
Linus Rath
2026-09-07 22:34:47 +02:00
parent b4f12b6786
commit faed1591ef
2 changed files with 93 additions and 5 deletions
@@ -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<IDBDatabase> {
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<string>((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<boolean>((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);
});
});
+34 -5
View File
@@ -32,8 +32,23 @@ function openDB(): Promise<IDBDatabase> {
}
};
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<T = string>(storeName: string, key: string): Promise<T |
const request = tx.objectStore(storeName).get(key);
request.onsuccess = () => 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<void> {
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);
};
});
}