Skip to content

Commit 7633f32

Browse files
leshniakclaude
andcommitted
feat: extend IDB heal to detect Safari connection lost error
Add isConnectionLostError() to detect 'Connection to Indexed Database server lost' and 'Connection is closing' — Safari/WebKit errors that fire when the browser terminates IDB connections for backgrounded tabs. Uses the same heal-and-retry mechanism as backing store corruption: drop cached dbp, retry once with fresh indexedDB.open(), shared budget. Addresses Expensify/App#87864. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1bb21b2 commit 7633f32

2 files changed

Lines changed: 144 additions & 2 deletions

File tree

lib/storage/providers/IDBKeyValProvider/createStore.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@ function isBackingStoreError(error: unknown): boolean {
1313
return error instanceof Error && error.message.includes('Internal error opening backing store');
1414
}
1515

16+
/**
17+
* Detects Safari/WebKit IDB connection termination errors.
18+
* Fires when Safari kills the IDB server process for backgrounded tabs.
19+
* WebKit bugs: https://bugs.webkit.org/show_bug.cgi?id=197050, https://bugs.webkit.org/show_bug.cgi?id=201483
20+
*/
21+
function isConnectionLostError(error: unknown): boolean {
22+
if (!(error instanceof Error)) return false;
23+
const msg = error.message.toLowerCase();
24+
return msg.includes('connection to indexed database server lost') || msg.includes('connection is closing');
25+
}
26+
1627
// This is a copy of the createStore function from idb-keyval, we need a custom implementation
1728
// because we need to create the database manually in order to ensure that the store exists before we use it.
1829
// If the store does not exist, idb-keyval will throw an error
@@ -129,9 +140,10 @@ function createStore(dbName: string, storeName: string): UseStore {
129140
return executeTransaction(txMode, callback).then(resetHealBudget);
130141
}
131142

132-
if (isBackingStoreError(error) && healAttemptsRemaining > 0) {
143+
if ((isBackingStoreError(error) || isConnectionLostError(error)) && healAttemptsRemaining > 0) {
133144
healAttemptsRemaining--;
134-
Logger.logInfo('IDB heal: backing store error, attempting drop cached connection and reopen', {
145+
const errorType = isBackingStoreError(error) ? 'backing store' : 'connection lost';
146+
Logger.logInfo(`IDB heal: ${errorType} error, attempting drop cached connection and reopen`, {
135147
dbName,
136148
storeName,
137149
healAttemptsRemaining,

tests/unit/storage/providers/createStoreTest.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,4 +415,134 @@ describe('createStore', () => {
415415
expect(logInfoSpy).not.toHaveBeenCalledWith(expect.stringContaining('IDB heal'), expect.anything());
416416
});
417417
});
418+
419+
describe('connection lost healing', () => {
420+
function connectionLostError() {
421+
return new DOMException(
422+
'Connection to Indexed Database server lost. Refresh the page to try again',
423+
'UnknownError',
424+
);
425+
}
426+
427+
it('should heal by dropping cached connection and reopening', async () => {
428+
const store = createStore(uniqueDBName(), STORE_NAME);
429+
430+
await store('readwrite', (s) => {
431+
s.put('value', 'key1');
432+
return IDB.promisifyRequest(s.transaction);
433+
});
434+
435+
const original = IDBDatabase.prototype.transaction;
436+
let callCount = 0;
437+
jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) {
438+
callCount++;
439+
if (callCount === 1) {
440+
throw connectionLostError();
441+
}
442+
return original.apply(this, args);
443+
});
444+
445+
const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1')));
446+
expect(result).toBe('value');
447+
expect(callCount).toBe(2);
448+
expect(logInfoSpy).toHaveBeenCalledWith(
449+
'IDB heal: connection lost error, attempting drop cached connection and reopen',
450+
expect.objectContaining({healAttemptsRemaining: expect.any(Number)}),
451+
);
452+
});
453+
454+
it('should stop healing after budget exhausts', async () => {
455+
const store = createStore(uniqueDBName(), STORE_NAME);
456+
457+
// All transaction calls fail permanently with connection lost.
458+
// The heal path clears dbp and calls indexedDB.open() again — mock that to
459+
// also fail so fake-indexeddb doesn't deadlock waiting for the old connection
460+
// to close (same pattern as the backing store budget exhaustion test).
461+
jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(() => {
462+
throw connectionLostError();
463+
});
464+
jest.spyOn(indexedDB, 'open').mockImplementation(() => {
465+
const req = {} as IDBOpenDBRequest;
466+
Promise.resolve().then(() => {
467+
Object.defineProperty(req, 'error', {value: connectionLostError(), configurable: true});
468+
req.onerror?.(new Event('error') as Event & {target: IDBOpenDBRequest});
469+
});
470+
return req;
471+
});
472+
473+
// Each call drains 1 heal attempt (initial fails, heal retry also fails)
474+
await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('k')))).rejects.toThrow('Connection to Indexed Database server lost');
475+
await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('k')))).rejects.toThrow('Connection to Indexed Database server lost');
476+
await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('k')))).rejects.toThrow('Connection to Indexed Database server lost');
477+
478+
// Budget exhausted — 4th call should NOT attempt healing
479+
logInfoSpy.mockClear();
480+
await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('k')))).rejects.toThrow('Connection to Indexed Database server lost');
481+
expect(logInfoSpy).not.toHaveBeenCalledWith(expect.stringContaining('IDB heal'), expect.anything());
482+
});
483+
484+
it('should also heal "connection is closing" variant', async () => {
485+
const store = createStore(uniqueDBName(), STORE_NAME);
486+
487+
await store('readwrite', (s) => {
488+
s.put('value', 'key1');
489+
return IDB.promisifyRequest(s.transaction);
490+
});
491+
492+
const original = IDBDatabase.prototype.transaction;
493+
let callCount = 0;
494+
jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(function (this: IDBDatabase, ...args) {
495+
callCount++;
496+
if (callCount === 1) {
497+
throw new DOMException('Connection is closing.', 'UnknownError');
498+
}
499+
return original.apply(this, args);
500+
});
501+
502+
const result = await store('readonly', (s) => IDB.promisifyRequest(s.get('key1')));
503+
expect(result).toBe('value');
504+
expect(callCount).toBe(2);
505+
});
506+
507+
it('should share heal budget with backing store errors', async () => {
508+
const store = createStore(uniqueDBName(), STORE_NAME);
509+
510+
// All transaction calls fail permanently, alternating error types.
511+
// The heal path clears dbp and calls indexedDB.open() again — mock that to
512+
// also fail so fake-indexeddb doesn't deadlock waiting for the old connection
513+
// to close.
514+
const txErrors = [
515+
new DOMException('Internal error opening backing store for indexedDB.open.', 'UnknownError'),
516+
connectionLostError(),
517+
new DOMException('Internal error opening backing store for indexedDB.open.', 'UnknownError'),
518+
];
519+
let txErrorIndex = 0;
520+
jest.spyOn(IDBDatabase.prototype, 'transaction').mockImplementation(() => {
521+
const err = txErrors[Math.min(txErrorIndex, txErrors.length - 1)];
522+
txErrorIndex++;
523+
throw err;
524+
});
525+
jest.spyOn(indexedDB, 'open').mockImplementation(() => {
526+
const req = {} as IDBOpenDBRequest;
527+
Promise.resolve().then(() => {
528+
Object.defineProperty(req, 'error', {
529+
value: new DOMException('Internal error opening backing store for indexedDB.open.', 'UnknownError'),
530+
configurable: true,
531+
});
532+
req.onerror?.(new Event('error') as Event & {target: IDBOpenDBRequest});
533+
});
534+
return req;
535+
});
536+
537+
// 3 calls drain the budget (each call: fail + heal retry fail = 1 budget)
538+
await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('k')))).rejects.toThrow();
539+
await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('k')))).rejects.toThrow();
540+
await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('k')))).rejects.toThrow();
541+
542+
// Budget exhausted — no more healing for either error type
543+
logInfoSpy.mockClear();
544+
await expect(store('readonly', (s) => IDB.promisifyRequest(s.get('k')))).rejects.toThrow();
545+
expect(logInfoSpy).not.toHaveBeenCalledWith(expect.stringContaining('IDB heal'), expect.anything());
546+
});
547+
});
418548
});

0 commit comments

Comments
 (0)