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(); + } + } }