From e8a3e156eb256bd07386054e1a3471287b6dffc2 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Wed, 15 Apr 2026 16:31:56 +0200 Subject: [PATCH] fix(electron): recover from IndexedDB backing-store errors on Linux autostart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- electron/clear-stale-idb-locks.ts | 57 +++++++++++++++++ electron/start-app.ts | 5 ++ src/app/op-log/core/operation-log.const.ts | 6 +- .../operation-log-hydrator.service.spec.ts | 63 +++++++++++++++++++ .../operation-log-hydrator.service.ts | 58 ++++++++++++++++- 5 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 electron/clear-stale-idb-locks.ts diff --git a/electron/clear-stale-idb-locks.ts b/electron/clear-stale-idb-locks.ts new file mode 100644 index 0000000000..47138c52fa --- /dev/null +++ b/electron/clear-stale-idb-locks.ts @@ -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 => { + 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); + } + } + }), + ); +}; diff --git a/electron/start-app.ts b/electron/start-app.ts index 774541e97a..ff7b1d215c 100644 --- a/electron/start-app.ts +++ b/electron/start-app.ts @@ -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 { + // 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, diff --git a/src/app/op-log/core/operation-log.const.ts b/src/app/op-log/core/operation-log.const.ts index 42fd09be31..7d1b8d7367 100644 --- a/src/app/op-log/core/operation-log.const.ts +++ b/src/app/op-log/core/operation-log.const.ts @@ -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; diff --git a/src/app/op-log/persistence/operation-log-hydrator.service.spec.ts b/src/app/op-log/persistence/operation-log-hydrator.service.spec.ts index 7d5e76ba7e..69f8ffcaa3 100644 --- a/src/app/op-log/persistence/operation-log-hydrator.service.spec.ts +++ b/src/app/op-log/persistence/operation-log-hydrator.service.spec.ts @@ -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(); + }); + }); }); diff --git a/src/app/op-log/persistence/operation-log-hydrator.service.ts b/src/app/op-log/persistence/operation-log-hydrator.service.ts index 0bdad329d6..7159c93895 100644 --- a/src/app/op-log/persistence/operation-log-hydrator.service.ts +++ b/src/app/op-log/persistence/operation-log-hydrator.service.ts @@ -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(); + } + } }