mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
fix(electron): recover from IndexedDB backing-store errors on Linux autostart
Three-layer defense for issue #7191 (Flatpak/Snap/autostart startup race): 1. Increase IDB_OPEN_RETRIES 3→4 (total retry window 3.5s→7.5s) to give OS/Flatpak storage more time to initialize before giving up. 2. Silent auto-reload on first backing-store error: instead of a blocking alert that requires user interaction before the reload (bad for autostart where nobody is watching), reload immediately and only show the manual recovery dialog if the reload does not fix the problem. 3. Clear stale LevelDB LOCK files in the Electron main process before the renderer opens IndexedDB. Orphaned locks from unclean session shutdowns block the backing store open; safe to remove because requestSingleInstanceLock() already confirmed we are the only running instance. Fixes https://github.com/super-productivity/super-productivity/issues/7191
This commit is contained in:
parent
5ce78a5b63
commit
e8a3e156eb
5 changed files with 186 additions and 3 deletions
57
electron/clear-stale-idb-locks.ts
Normal file
57
electron/clear-stale-idb-locks.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { join } from 'path';
|
||||
import { readdir, unlink } from 'fs/promises';
|
||||
import { log, warn } from 'electron-log/main';
|
||||
|
||||
/**
|
||||
* Deletes stale LevelDB LOCK files left in the IndexedDB directory.
|
||||
*
|
||||
* When Electron (Chromium) exits uncleanly (e.g., session logout with autostart),
|
||||
* the LevelDB LOCK files inside the IndexedDB backing stores are sometimes not
|
||||
* cleaned up. On the next launch these orphaned files block IndexedDB from opening,
|
||||
* producing the "Internal error opening backing store for indexedDB.open" error.
|
||||
*
|
||||
* This is safe to call because by the time it runs, `requestSingleInstanceLock()`
|
||||
* has already ensured we are the only running instance, so no legitimate process
|
||||
* can hold those locks.
|
||||
*
|
||||
* Only runs on Linux, where this startup race condition has been observed.
|
||||
*
|
||||
* @see https://github.com/electron/electron/issues/18263
|
||||
* @see https://github.com/super-productivity/super-productivity/issues/7191
|
||||
*/
|
||||
export const clearStaleLevelDbLocks = async (userDataPath: string): Promise<void> => {
|
||||
if (process.platform !== 'linux') {
|
||||
return;
|
||||
}
|
||||
|
||||
const idbDir = join(userDataPath, 'IndexedDB');
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(idbDir);
|
||||
} catch (e: unknown) {
|
||||
// Directory doesn't exist yet (fresh install) — nothing to clean up.
|
||||
// Any other error (e.g., EACCES, EPERM) is unexpected and worth logging.
|
||||
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
warn(`[clearStaleLevelDbLocks] Could not read IndexedDB directory:`, e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const leveldbDirs = entries.filter((e) => e.endsWith('.leveldb'));
|
||||
|
||||
await Promise.all(
|
||||
leveldbDirs.map(async (dir) => {
|
||||
const lockPath = join(idbDir, dir, 'LOCK');
|
||||
try {
|
||||
await unlink(lockPath);
|
||||
log(`[clearStaleLevelDbLocks] Removed stale lock: ${lockPath}`);
|
||||
} catch (e: unknown) {
|
||||
// LOCK file doesn't exist or is already released — this is the normal case
|
||||
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
warn(`[clearStaleLevelDbLocks] Could not remove ${lockPath}:`, e);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
@ -29,6 +29,7 @@ import {
|
|||
processPendingProtocolUrls,
|
||||
} from './protocol-handler';
|
||||
import { getIsQuiting, setIsQuiting, setIsLocked } from './shared-state';
|
||||
import { clearStaleLevelDbLocks } from './clear-stale-idb-locks';
|
||||
|
||||
const ICONS_FOLDER = __dirname + '/assets/icons/';
|
||||
const IS_MAC = process.platform === 'darwin';
|
||||
|
|
@ -382,6 +383,10 @@ export const startApp = (): void => {
|
|||
|
||||
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
|
||||
async function createMainWin(): Promise<void> {
|
||||
// Remove stale LevelDB LOCK files before the renderer opens IndexedDB.
|
||||
// Orphaned locks from unclean session shutdowns block the backing store open.
|
||||
await clearStaleLevelDbLocks(app.getPath('userData'));
|
||||
|
||||
mainWin = await createWindow({
|
||||
app,
|
||||
IS_DEV,
|
||||
|
|
|
|||
|
|
@ -270,10 +270,12 @@ export const POST_SYNC_COOLDOWN_MS = 2000;
|
|||
* Transient failures (file locks, temporary I/O issues) may resolve on retry.
|
||||
* @see https://github.com/johannesjo/super-productivity/issues/6255
|
||||
*/
|
||||
export const IDB_OPEN_RETRIES = 3;
|
||||
export const IDB_OPEN_RETRIES = 4;
|
||||
|
||||
/**
|
||||
* Base delay for IndexedDB open retry exponential backoff (milliseconds).
|
||||
* With IDB_OPEN_RETRIES=3: delays are 500ms, 1000ms, 2000ms for retries 1, 2, 3.
|
||||
* With IDB_OPEN_RETRIES=4: delays are 500ms, 1000ms, 2000ms, 4000ms for retries 1-4.
|
||||
* Extended from 3 to 4 to give more time for OS/Flatpak storage to initialize on session login.
|
||||
* @see https://github.com/super-productivity/super-productivity/issues/7191
|
||||
*/
|
||||
export const IDB_OPEN_RETRY_BASE_DELAY_MS = 500;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import { loadAllData } from '../../root-store/meta/load-all-data.action';
|
|||
import { bulkApplyHydrationOperations } from '../apply/bulk-hydration.action';
|
||||
import { CLIENT_ID_PROVIDER, ClientIdProvider } from '../util/client-id.provider';
|
||||
import { MAX_VECTOR_CLOCK_SIZE } from '@sp/shared-schema';
|
||||
import { IndexedDBOpenError } from '../core/errors/indexed-db-open.error';
|
||||
import { IDB_OPEN_ERROR_RELOAD_KEY } from './operation-log-hydrator.service';
|
||||
|
||||
describe('OperationLogHydratorService', () => {
|
||||
let service: OperationLogHydratorService;
|
||||
|
|
@ -1319,4 +1321,65 @@ describe('OperationLogHydratorService', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IndexedDB open error handling', () => {
|
||||
let reloadSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.removeItem(IDB_OPEN_ERROR_RELOAD_KEY);
|
||||
if (!jasmine.isSpy(window.alert)) {
|
||||
spyOn(window, 'alert');
|
||||
}
|
||||
(window.alert as jasmine.Spy).calls.reset();
|
||||
reloadSpy = spyOn(service as any, '_triggerReload');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.removeItem(IDB_OPEN_ERROR_RELOAD_KEY);
|
||||
});
|
||||
|
||||
it('should silently auto-reload for backing store error on first occurrence', async () => {
|
||||
const err = new IndexedDBOpenError(
|
||||
new Error('Internal error opening backing store for indexedDB.open'),
|
||||
);
|
||||
mockRecoveryService.recoverPendingRemoteOps.and.rejectWith(err);
|
||||
|
||||
await expectAsync(service.hydrateStore()).toBeRejected();
|
||||
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(1);
|
||||
expect(sessionStorage.getItem(IDB_OPEN_ERROR_RELOAD_KEY)).toBe('1');
|
||||
// No dialog on first attempt — autostart users aren't watching
|
||||
expect(window.alert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show recovery dialog and NOT auto-reload when already reloaded once', async () => {
|
||||
sessionStorage.setItem(IDB_OPEN_ERROR_RELOAD_KEY, '1');
|
||||
const err = new IndexedDBOpenError(
|
||||
new Error('Internal error opening backing store for indexedDB.open'),
|
||||
);
|
||||
mockRecoveryService.recoverPendingRemoteOps.and.rejectWith(err);
|
||||
|
||||
await expectAsync(service.hydrateStore()).toBeRejected();
|
||||
|
||||
expect(reloadSpy).not.toHaveBeenCalled();
|
||||
expect(window.alert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should NOT auto-reload for non-backing-store IDB open errors', async () => {
|
||||
const err = new IndexedDBOpenError(new Error('QuotaExceededError'));
|
||||
mockRecoveryService.recoverPendingRemoteOps.and.rejectWith(err);
|
||||
|
||||
await expectAsync(service.hydrateStore()).toBeRejected();
|
||||
|
||||
expect(reloadSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear the reload key after successful hydration', async () => {
|
||||
sessionStorage.setItem(IDB_OPEN_ERROR_RELOAD_KEY, '1');
|
||||
// Successful hydration — no errors thrown
|
||||
await service.hydrateStore();
|
||||
|
||||
expect(sessionStorage.getItem(IDB_OPEN_ERROR_RELOAD_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,6 +27,13 @@ import { MAX_CONFLICT_RETRY_ATTEMPTS } from '../core/operation-log.const';
|
|||
import { AppDataComplete } from '../model/model-config';
|
||||
import { CLIENT_ID_PROVIDER, ClientIdProvider } from '../util/client-id.provider';
|
||||
import { limitVectorClockSize } from '../../core/util/vector-clock';
|
||||
import { IS_ELECTRON } from '../../app.constants';
|
||||
|
||||
/**
|
||||
* sessionStorage key used to track auto-reload attempts after IndexedDB backing store errors.
|
||||
* Exported for use in tests.
|
||||
*/
|
||||
export const IDB_OPEN_ERROR_RELOAD_KEY = 'sp_idb_open_reload_attempt';
|
||||
|
||||
/**
|
||||
* Handles the hydration (loading) of the application state from the operation log
|
||||
|
|
@ -117,6 +124,7 @@ export class OperationLogHydratorService {
|
|||
'OperationLogHydratorService: Snapshot is invalid/corrupted. Attempting recovery...',
|
||||
);
|
||||
await this.recoveryService.attemptRecovery();
|
||||
sessionStorage.removeItem(IDB_OPEN_ERROR_RELOAD_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -280,6 +288,7 @@ export class OperationLogHydratorService {
|
|||
OpLog.normal(
|
||||
'OperationLogHydratorService: Fresh install detected. No data to load.',
|
||||
);
|
||||
sessionStorage.removeItem(IDB_OPEN_ERROR_RELOAD_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -362,6 +371,11 @@ export class OperationLogHydratorService {
|
|||
// Retry any failed remote ops from previous conflict resolution attempts
|
||||
// Now that state is fully hydrated, dependencies might be resolved
|
||||
await this.retryFailedRemoteOps();
|
||||
|
||||
// Clear the auto-reload guard so that a fresh backing-store error in the same
|
||||
// tab session gets the auto-reload treatment again rather than going straight
|
||||
// to the manual recovery dialog.
|
||||
sessionStorage.removeItem(IDB_OPEN_ERROR_RELOAD_KEY);
|
||||
} catch (e) {
|
||||
OpLog.err('OperationLogHydratorService: Error during hydration', e);
|
||||
|
||||
|
|
@ -658,6 +672,32 @@ export class OperationLogHydratorService {
|
|||
? error.originalError.message
|
||||
: String(error.originalError);
|
||||
|
||||
// Hoist platform detection — used in both branches below to avoid computing twice
|
||||
const isFlatpak = IS_ELECTRON && window.ea?.isFlatpak?.();
|
||||
const isSnap = !isFlatpak && IS_ELECTRON && window.ea?.isSnap?.();
|
||||
|
||||
// For backing-store errors (common during Linux session startup with autostart),
|
||||
// auto-reload once after the user dismisses the dialog. By the time the dialog
|
||||
// is dismissed the OS / Flatpak sandbox will usually have finished initializing.
|
||||
// A sessionStorage counter prevents an infinite reload loop on genuine errors.
|
||||
if (error.isBackingStoreError) {
|
||||
const reloadCount = +(sessionStorage.getItem(IDB_OPEN_ERROR_RELOAD_KEY) || '0');
|
||||
if (reloadCount === 0) {
|
||||
// Silent auto-reload on first occurrence — most likely a transient startup
|
||||
// timing issue (Flatpak sandbox not ready, stale LOCK file). The user is
|
||||
// typically not watching (autostart scenario), so a blocking dialog requiring
|
||||
// a click before the reload is unnecessary friction. If the reload fixes it,
|
||||
// the user never needs to know. If it fails again, the dialog below runs.
|
||||
OpLog.warn(
|
||||
'IndexedDB backing-store error on first attempt — triggering silent auto-reload',
|
||||
);
|
||||
sessionStorage.setItem(IDB_OPEN_ERROR_RELOAD_KEY, '1');
|
||||
this._triggerReload();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Second failure, or non-backing-store error: show full manual recovery instructions.
|
||||
let message =
|
||||
'Database Error - Cannot Load Data\n\n' +
|
||||
'Super Productivity cannot open its database. ' +
|
||||
|
|
@ -671,7 +711,11 @@ export class OperationLogHydratorService {
|
|||
'Recovery steps:\n' +
|
||||
'1. Close ALL browser tabs and windows\n' +
|
||||
'2. Restart the app\n' +
|
||||
'3. If using Linux Snap, try: snap set core experimental.refresh-app-awareness=true\n' +
|
||||
(isFlatpak
|
||||
? '3. If using Linux Flatpak with autostart, try disabling autostart and launching manually\n'
|
||||
: isSnap
|
||||
? '3. If using Linux Snap, try: snap set core experimental.refresh-app-awareness=true\n'
|
||||
: '3. If using Linux with autostart, try disabling autostart and launching manually\n') +
|
||||
'4. If issue persists, check available disk space\n\n';
|
||||
}
|
||||
|
||||
|
|
@ -682,4 +726,16 @@ export class OperationLogHydratorService {
|
|||
|
||||
alertDialog(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers an app reload. Uses Electron IPC in Electron context, browser reload otherwise.
|
||||
* Extracted as a method to allow spying in unit tests.
|
||||
*/
|
||||
private _triggerReload(): void {
|
||||
if (IS_ELECTRON) {
|
||||
window.ea.reloadMainWin();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue