From 69fcd672d8bb3fa8b5ffc2c9687e6f4e8415174a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:40:21 -0700 Subject: [PATCH] fix(profile-state): keep the SQLite failure behind a startup open error across the writer worker (#29280) --- .../profile-state-writer-errors.ts | 28 ++++++++- ...file-state-writer-open-error-cause.test.ts | 63 +++++++++++++++++++ .../profile-state-writer-protocol.ts | 14 ++++- 3 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 src/main/persistence/profile-state/profile-state-writer-open-error-cause.test.ts diff --git a/src/main/persistence/profile-state/profile-state-writer-errors.ts b/src/main/persistence/profile-state/profile-state-writer-errors.ts index afb30d520c8..a44c57db927 100644 --- a/src/main/persistence/profile-state/profile-state-writer-errors.ts +++ b/src/main/persistence/profile-state/profile-state-writer-errors.ts @@ -56,8 +56,11 @@ export function encodeProfileStateWriterError( if (error instanceof ProfileStateDocumentCorruptionError) { return { code: error.code, message: error.message, outcome, domain: error.domain } } + if (error instanceof ProfileStateDatabaseOpenError) { + const cause = encodeOpenErrorCause(error.cause) + return { code: error.code, message: error.message, outcome, ...(cause ? { cause } : {}) } + } if ( - error instanceof ProfileStateDatabaseOpenError || error instanceof ProfileStateWriterError || error instanceof ProfileStateIndeterminateWriteError || error instanceof ProfileStateReadRollbackError @@ -88,8 +91,29 @@ export function decodeProfileStateWriterError(data: ProfileStateWriterErrorData) data.code === 'identity-mismatch' || data.code === 'invalid-profile-id' ) { - return new ProfileStateDatabaseOpenError(data.code, data.message) + return new ProfileStateDatabaseOpenError( + data.code, + data.message, + data.cause ? Object.assign(new Error(data.cause.message), data.cause) : undefined + ) } } return new ProfileStateWriterError(data.code, data.message, data.outcome) } + +// Why: open failures are rare and fatal to startup; without the SQLite code the log cannot tell +// a lock race from real damage (#27333). Messages are SQLite's or our validators', not user data. +function encodeOpenErrorCause(cause: unknown): ProfileStateWriterErrorData['cause'] { + if (!(cause instanceof Error)) { + return undefined + } + const code = 'code' in cause && typeof cause.code === 'string' ? cause.code : undefined + // node:sqlite's extended result code (e.g. 261 = SQLITE_BUSY_RECOVERY) is the precise verdict. + const errcode = + 'errcode' in cause && typeof cause.errcode === 'number' ? cause.errcode : undefined + return { + message: cause.message.slice(0, 500), + ...(code ? { code } : {}), + ...(errcode !== undefined ? { errcode } : {}) + } +} diff --git a/src/main/persistence/profile-state/profile-state-writer-open-error-cause.test.ts b/src/main/persistence/profile-state/profile-state-writer-open-error-cause.test.ts new file mode 100644 index 00000000000..c454bab785d --- /dev/null +++ b/src/main/persistence/profile-state/profile-state-writer-open-error-cause.test.ts @@ -0,0 +1,63 @@ +import { expect, it } from 'vitest' +import Database from '../../sqlite/sync-database' +import { + decodeProfileStateWriterError, + encodeProfileStateWriterError +} from './profile-state-writer-errors' +import { ProfileStateDatabaseOpenError } from './profile-state-database-errors' + +function acrossWorker(error: unknown): Error { + return decodeProfileStateWriterError( + JSON.parse(JSON.stringify(encodeProfileStateWriterError(error))) + ) +} + +// #27333: the startup failure logged `cause: undefined`, hiding which SQLite call failed. +it('keeps the SQLite failure behind a database open error across the writer boundary', () => { + const sqliteFailure = Object.assign(new Error('database is locked'), { + code: 'ERR_SQLITE_ERROR', + errcode: 261 + }) + const decoded = acrossWorker( + new ProfileStateDatabaseOpenError( + 'unreadable', + 'Unable to initialize profile state database', + sqliteFailure + ) + ) + + expect(decoded).toBeInstanceOf(ProfileStateDatabaseOpenError) + expect(decoded.cause).toBeInstanceOf(Error) + expect(decoded.cause).toMatchObject({ + code: 'ERR_SQLITE_ERROR', + errcode: 261, + message: 'database is locked' + }) +}) + +it('carries a real SQLite error code from a failed open', () => { + const db = new Database(':memory:') + let failure: unknown + try { + db.exec('SELECT * FROM missing_table') + } catch (error) { + failure = error + } finally { + db.close() + } + const decoded = acrossWorker( + new ProfileStateDatabaseOpenError('unreadable', 'open failed', failure) + ) + + expect(decoded.cause).toMatchObject({ + code: 'ERR_SQLITE_ERROR', + errcode: 1, + message: expect.stringContaining('missing_table') + }) +}) + +it('keeps a cause-less open error cause-less', () => { + const decoded = acrossWorker(new ProfileStateDatabaseOpenError('unreadable', 'gone')) + expect(decoded).toBeInstanceOf(ProfileStateDatabaseOpenError) + expect(decoded.cause).toBeUndefined() +}) diff --git a/src/main/persistence/profile-state/profile-state-writer-protocol.ts b/src/main/persistence/profile-state/profile-state-writer-protocol.ts index 855b5510344..62b26607944 100644 --- a/src/main/persistence/profile-state/profile-state-writer-protocol.ts +++ b/src/main/persistence/profile-state/profile-state-writer-protocol.ts @@ -30,6 +30,8 @@ export type ProfileStateWriterErrorData = { expectedRevision?: number actualRevision?: number domain?: string | null + /** The SQLite failure behind a database open error, which the error class alone cannot name. */ + cause?: { code?: string; errcode?: number; message: string } } export type ProfileStateWriterResponse = | { id: number; ok: true; revision: number; exportedRevision?: number | null } @@ -97,7 +99,17 @@ function isErrorData(value: unknown): value is ProfileStateWriterErrorData { (value.outcome === 'known-failure' || value.outcome === 'indeterminate') && (value.expectedRevision === undefined || isProfileStateRevision(value.expectedRevision)) && (value.actualRevision === undefined || isProfileStateRevision(value.actualRevision)) && - (value.domain === undefined || value.domain === null || typeof value.domain === 'string') + (value.domain === undefined || value.domain === null || typeof value.domain === 'string') && + (value.cause === undefined || isErrorCauseData(value.cause)) + ) +} + +function isErrorCauseData(value: unknown): boolean { + return ( + isRecord(value) && + typeof value.message === 'string' && + (value.code === undefined || typeof value.code === 'string') && + (value.errcode === undefined || typeof value.errcode === 'number') ) }