Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
6569dc7
fix: add IDB backing store corruption healing mechanism
leshniak May 15, 2026
a2c8ea7
fix: guard stale dbp reject handler, document concurrent budget drain
leshniak May 15, 2026
213480b
fix: align comments and logs with actual behavior (drop cache, not cl…
leshniak May 15, 2026
1bb21b2
refactor: simplify error type checks per review feedback
leshniak May 18, 2026
7633f32
feat: extend IDB heal to detect Safari connection lost error
leshniak May 19, 2026
1f14374
refactor: DRY error classification and promise cleanup in createStore
leshniak May 20, 2026
8bc7cd4
fix: improve diagnostic logging for all heal and error paths
leshniak May 20, 2026
fccdb4a
style: fix Prettier formatting in createStoreTest
leshniak May 20, 2026
3fbb657
fix: downgrade heal attempt logs from logAlert to logInfo
leshniak May 21, 2026
a4ebed8
feat: add visibilitychange probe for proactive IDB health check
leshniak May 20, 2026
74d04d6
fix: improve diagnostic logging for probe and all heal paths
leshniak May 20, 2026
b63de8b
fix: move probe healthy log to req.onsuccess to avoid false positive
leshniak May 20, 2026
6445eaa
fix: tighten error detection to handle DOMException not extending Error
leshniak May 29, 2026
74a7370
fix: tighten error detection to handle DOMException not extending Error
leshniak May 29, 2026
f492eac
Merge remote-tracking branch 'origin/fix/idb-corruption-detect-and-he…
leshniak May 29, 2026
00e8667
fix: add fallback label in getBudgetedHealErrorLabel
leshniak May 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 161 additions & 24 deletions lib/storage/providers/IDBKeyValProvider/createStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,55 @@ import * as IDB from 'idb-keyval';
import type {UseStore} from 'idb-keyval';
import * as Logger from '../../../Logger';

const HEAL_ATTEMPTS_MAX = 3;

/**
* Detects the Chromium-specific IDB backing store corruption error.
* Fires when LevelDB files backing IndexedDB are corrupted and Chrome's
* internal recovery (RepairDB -> delete -> recreate) also fails.
*/
function isBackingStoreError(error: unknown): boolean {
return (error instanceof Error || error instanceof DOMException) && (error as Error).message.includes('Internal error opening backing store');
}

/**
* Detects Safari/WebKit IDB connection termination errors.
* Fires when Safari kills the IDB server process for backgrounded tabs.
* WebKit bugs: https://bugs.webkit.org/show_bug.cgi?id=197050, https://bugs.webkit.org/show_bug.cgi?id=201483
*/
function isConnectionLostError(error: unknown): boolean {
if (!(error instanceof Error || error instanceof DOMException)) return false;
const msg = (error as Error).message.toLowerCase();
return msg.includes('connection to indexed database server lost') || msg.includes('connection is closing');
}

function isInvalidStateError(error: unknown): boolean {
return (error instanceof Error || error instanceof DOMException) && (error as Error).name === 'InvalidStateError';
}

/** Errors that trigger a budgeted heal-and-retry in store(). */
function isBudgetedHealError(error: unknown): boolean {
return isBackingStoreError(error) || isConnectionLostError(error);
}

function getBudgetedHealErrorLabel(error: unknown): string {
if (isBackingStoreError(error)) return 'backing store';
if (isConnectionLostError(error)) return 'connection lost';
return 'unknown';
}

/** Union of all error types indicating a stale/dead IDB connection. Used by the visibilitychange probe. */
function isStaleConnectionError(error: unknown): boolean {
return isInvalidStateError(error) || isBackingStoreError(error) || isConnectionLostError(error);
}

// This is a copy of the createStore function from idb-keyval, we need a custom implementation
// because we need to create the database manually in order to ensure that the store exists before we use it.
// If the store does not exist, idb-keyval will throw an error
// source: https://github.com/jakearchibald/idb-keyval/blob/9d19315b4a83897df1e0193dccdc29f78466a0f3/src/index.ts#L12
function createStore(dbName: string, storeName: string): UseStore {
let dbp: Promise<IDBDatabase> | undefined;
let healAttemptsRemaining = HEAL_ATTEMPTS_MAX;

const attachHandlers = (db: IDBDatabase) => {
// Browsers may close idle IDB connections at any time, especially Safari.
Expand All @@ -30,18 +73,27 @@ function createStore(dbName: string, storeName: string): UseStore {
};
};

// Cache the open promise and attach handlers + rejection cleanup.
// On rejection, clears dbp so the next operation retries with a fresh indexedDB.open()
// instead of returning the same rejected promise.
// Guard: only clear if dbp hasn't been replaced by a concurrent heal/retry.
function cacheOpenPromise(openPromise: Promise<IDBDatabase>) {
dbp = openPromise;
const currentPromise = openPromise;
openPromise.then(attachHandlers, () => {
if (dbp !== currentPromise) {
return;
}
dbp = undefined;
});
return openPromise;
}

const getDB = () => {
if (dbp) return dbp;
const request = indexedDB.open(dbName);
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
dbp = IDB.promisifyRequest(request);

dbp.then(
attachHandlers,
// eslint-disable-next-line @typescript-eslint/no-empty-function
() => {},
);
return dbp;
return cacheOpenPromise(IDB.promisifyRequest(request));
};

// Ensures the store exists in the DB. If missing, bumps the version to trigger
Expand All @@ -66,10 +118,7 @@ function createStore(dbName: string, storeName: string): UseStore {
updatedDatabase.createObjectStore(storeName);
};

dbp = IDB.promisifyRequest(request);
// eslint-disable-next-line @typescript-eslint/no-empty-function
dbp.then(attachHandlers, () => {});
return dbp;
return cacheOpenPromise(IDB.promisifyRequest(request));
};

function executeTransaction<T>(txMode: IDBTransactionMode, callback: (store: IDBObjectStore) => T | PromiseLike<T>): Promise<T> {
Expand All @@ -78,23 +127,111 @@ function createStore(dbName: string, storeName: string): UseStore {
.then((db) => callback(db.transaction(storeName, txMode).objectStore(storeName)));
}

// If the connection was closed between getDB() resolving and db.transaction() executing,
// the transaction throws InvalidStateError. We catch it and retry once with a fresh connection.
return (txMode, callback) =>
executeTransaction(txMode, callback).catch((error) => {
if (error instanceof DOMException && error.name === 'InvalidStateError') {
Logger.logAlert('IDB InvalidStateError, retrying with fresh connection', {
function resetHealBudget<T>(result: T): T {
healAttemptsRemaining = HEAL_ATTEMPTS_MAX;
return result;
}

// Proactive IDB health check when tab returns to foreground.
// Safari kills IDB connections for backgrounded tabs. By probing before
// the ReconnectApp write storm hits, we drop the stale dbp early so the
// first real operation opens a fresh connection instead of failing.
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible' || !dbp) {
return;
}

Logger.logInfo('IDB visibilitychange probe: tab became visible, checking connection health', {dbName, storeName});

const probePromise = dbp;

const dropCacheIfStale = (error: unknown) => {
if (dbp !== probePromise || !isStaleConnectionError(error)) {
return;
}
Logger.logAlert('IDB visibilitychange probe: stale connection detected, dropping cached connection', {
dbName,
storeName,
txMode,
errorMessage: error.message,
errorMessage: error instanceof Error ? error.message : String(error),
});
dbp = undefined;
// Retry only once — this call is not wrapped, so if it also fails the error propagates normally.
return executeTransaction(txMode, callback);
}
throw error;
};

probePromise.then((db) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle probe promise rejections

When the tab becomes visible while dbp is still a pending indexedDB.open() and that open later rejects (for example with the same backing-store or connection-lost errors handled below), this separate probePromise.then(...) chain rejects without any rejection handler. The original store operation may catch its own chain, but the promise returned by this then is ignored, so browsers can emit an unhandled rejection during foregrounding; add a rejection handler/catch for the probe path.

Useful? React with 👍 / 👎.

if (dbp !== probePromise) {
return;
}
try {
const tx = db.transaction(storeName, 'readonly');
const probeStore = tx.objectStore(storeName);
const req = probeStore.count();
req.onsuccess = () => {
Logger.logInfo('IDB visibilitychange probe: connection is healthy', {dbName, storeName});
};
req.onerror = () => {
dropCacheIfStale(req.error);
};
} catch (error) {
dropCacheIfStale(error);
}
});
});
}

// Handles three recoverable error classes:
// 1. InvalidStateError — connection closed between getDB() resolving and db.transaction().
// Retry once with a fresh connection. No budget limit (transient, always worth one reopen).
// 2. Backing store corruption (Chromium UnknownError) — drop cached connection and reopen.
// 3. Connection lost (Safari UnknownError) — IDB server terminated for backgrounded tabs.
// Both 2 and 3 share a heal budget (3 attempts, reset on success).
// Mirrors Dexie's PR1398_maxLoop pattern: https://github.com/dexie/Dexie.js/blob/master/src/functions/temp-transaction.ts
// Note: concurrent store() calls share the budget. Under overlapping failures each caller
// decrements independently, so the budget may drain faster than one-per-incident. This is
// acceptable — same as Dexie's approach — and the budget resets on any success.
return (txMode, callback) =>
executeTransaction(txMode, callback)
.then(resetHealBudget)
.catch((error) => {
if (isInvalidStateError(error)) {
Logger.logInfo('IDB InvalidStateError — dropping cached connection and retrying', {
dbName,
storeName,
txMode,
errorMessage: error instanceof Error ? error.message : String(error),
});
dbp = undefined;
return executeTransaction(txMode, callback).then(resetHealBudget);
}

if (isBudgetedHealError(error) && healAttemptsRemaining > 0) {
healAttemptsRemaining--;
const label = getBudgetedHealErrorLabel(error);
Logger.logInfo(`IDB heal: ${label} error detected — dropping cached connection and reopening (${healAttemptsRemaining} attempts left)`, {
dbName,
storeName,
});
dbp = undefined;
return executeTransaction(txMode, callback).then((result) => {
Logger.logInfo(`IDB heal: successfully recovered after ${label} error`, {dbName, storeName});
return resetHealBudget(result);
});
}

if (isBudgetedHealError(error)) {
Logger.logAlert(`IDB heal: ${getBudgetedHealErrorLabel(error)} error — heal budget exhausted, giving up`, {
dbName,
storeName,
});
} else {
Logger.logAlert('IDB error is not recoverable, giving up', {
dbName,
storeName,
errorMessage: error instanceof Error ? error.message : String(error),
});
}
throw error;
});
}

export default createStore;
Loading
Loading