From 7510be0cafdc550e4b7dbac4355e89828b72f11a Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Wed, 8 Sep 2021 13:39:14 -0700 Subject: [PATCH] Detect database corruption consistently --- main.js | 40 ++++++++++++++++++++------ ts/sql/Client.ts | 59 +++++++++++++++++++------------------- ts/sql/Server.ts | 44 ++++------------------------ ts/sql/errors.ts | 10 +++++++ ts/sql/main.ts | 24 +++++++++++++++- ts/util/createIPCEvents.ts | 5 ++++ ts/windows/preload.ts | 1 + 7 files changed, 106 insertions(+), 77 deletions(-) create mode 100644 ts/sql/errors.ts diff --git a/main.js b/main.js index 4fbbfd365..899ead8f2 100644 --- a/main.js +++ b/main.js @@ -313,7 +313,7 @@ function handleCommonWindowEvents(window) { // Works only for mainWindow because it has `enablePreferredSizeMode` let lastZoomFactor = window.webContents.getZoomFactor(); const onZoomChanged = () => { - const zoomFactor = mainWindow.webContents.getZoomFactor(); + const zoomFactor = window.webContents.getZoomFactor(); if (lastZoomFactor === zoomFactor) { return; } @@ -1157,9 +1157,16 @@ async function initializeSQL() { return { ok: true }; } -const sqlInitPromise = initializeSQL(); - const onDatabaseError = async error => { + // Prevent window from re-opening + ready = false; + + if (mainWindow) { + mainWindow.webContents.send('callbacks:call:closeDB', []); + mainWindow.close(); + } + mainWindow = undefined; + const buttonIndex = dialog.showMessageBoxSync({ buttons: [ locale.messages.copyErrorAndQuit.message, @@ -1183,15 +1190,30 @@ const onDatabaseError = async error => { app.exit(1); }; -ipc.on('database-error', (event, error) => { - if (mainWindow) { - mainWindow.close(); +const runSQLCorruptionHandler = async () => { + // This is a glorified event handler. Normally, this promise never resolves, + // but if there is a corruption error triggered by any query that we run + // against the database - the promise will resolve and we will call + // `onDatabaseError`. + const error = await sql.whenCorrupted(); + + const message = + 'Detected sql corruption in main process. ' + + `Restarting the application immediately. Error: ${error.message}`; + if (logger) { + logger.error(message); + } else { + console.error(message); } - mainWindow = undefined; - // Prevent window from re-opening - ready = false; + await onDatabaseError(error.stack); +}; +runSQLCorruptionHandler(); + +const sqlInitPromise = initializeSQL(); + +ipc.on('database-error', (event, error) => { onDatabaseError(error); }); diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 191fb7470..b31780366 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -74,6 +74,7 @@ import { UnprocessedUpdateType, } from './Interface'; import Server from './Server'; +import { isCorruptionError } from './errors'; import { MessageModel } from '../models/messages'; import { ConversationModel } from '../models/conversations'; @@ -446,18 +447,6 @@ function _updateJob(id: number, data: ClientJobUpdateType) { `SQL channel job ${id} (${fnName}) failed in ${end - start}ms` ); - if ( - error && - error.message && - (error.message.includes('SQLITE_CORRUPT') || - error.message.includes('database disk image is malformed')) - ) { - window.log.error( - `Detected corruption. Restarting the application immediately. Error: ${error.message}` - ); - ipcRenderer?.send('database-error', error.message); - } - return reject(error); }, }; @@ -528,25 +517,37 @@ function makeChannel(fnName: string) { const serverFnName = fnName as keyof ServerInterface; const start = Date.now(); - // Ignoring this error TS2556: Expected 3 arguments, but got 0 or more. - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const result = Server[serverFnName](...args); - - const duration = Date.now() - start; - - startupQueries.set( - serverFnName, - (startupQueries.get(serverFnName) || 0) + duration - ); - - if (duration > MIN_TRACE_DURATION || _DEBUG) { - window.log.info( - `Renderer SQL channel job (${fnName}) succeeded in ${duration}ms` + try { + // Ignoring this error TS2556: Expected 3 arguments, but got 0 or more. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return await Server[serverFnName](...args); + } catch (error) { + if (isCorruptionError(error)) { + window.log.error( + 'Detected sql corruption in renderer process. ' + + `Restarting the application immediately. Error: ${error.message}` + ); + ipcRenderer?.send('database-error', error.stack); + } + window.log.error( + `Renderer SQL channel job (${fnName}) error ${error.message}` ); - } + throw error; + } finally { + const duration = Date.now() - start; - return result; + startupQueries.set( + serverFnName, + (startupQueries.get(serverFnName) || 0) + duration + ); + + if (duration > MIN_TRACE_DURATION || _DEBUG) { + window.log.info( + `Renderer SQL channel job (${fnName}) completed in ${duration}ms` + ); + } + } } const jobId = _makeJob(fnName); diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index c904af4bf..6e03ba011 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -374,25 +374,6 @@ function getSQLCipherVersion(db: Database): string | undefined { return db.pragma('cipher_version', { simple: true }); } -function getSQLCipherIntegrityCheck(db: Database): Array | undefined { - const rows: Array<{ cipher_integrity_check: string }> = db.pragma( - 'cipher_integrity_check' - ); - if (rows.length === 0) { - return undefined; - } - return rows.map(row => row.cipher_integrity_check); -} - -function getSQLIntegrityCheck(db: Database): string | undefined { - const checkResult = db.pragma('quick_check', { simple: true }); - if (checkResult !== 'ok') { - return checkResult; - } - - return undefined; -} - function migrateSchemaVersion(db: Database): void { const userVersion = getUserVersion(db); if (userVersion > 0) { @@ -2232,24 +2213,6 @@ async function initialize({ updateSchema(db); - // test database - - const cipherIntegrityResult = getSQLCipherIntegrityCheck(db); - if (cipherIntegrityResult) { - console.log( - 'Database cipher integrity check failed:', - cipherIntegrityResult - ); - throw new Error( - `Cipher integrity check failed: ${cipherIntegrityResult}` - ); - } - const integrityResult = getSQLIntegrityCheck(db); - if (integrityResult) { - console.log('Database integrity check failed:', integrityResult); - throw new Error(`Integrity check failed: ${integrityResult}`); - } - // At this point we can allow general access to the database globalInstance = db; @@ -2325,7 +2288,12 @@ async function close(): Promise { async function removeDB(): Promise { if (globalInstance) { - throw new Error('removeDB: Cannot erase database when it is open!'); + try { + globalInstance.close(); + } catch (error) { + console.log('removeDB: Failed to close database:', error.stack); + } + globalInstance = undefined; } if (!databaseFilePath) { throw new Error( diff --git a/ts/sql/errors.ts b/ts/sql/errors.ts new file mode 100644 index 000000000..9dc00d6f4 --- /dev/null +++ b/ts/sql/errors.ts @@ -0,0 +1,10 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export function isCorruptionError(error?: Error): boolean { + return ( + error?.message?.includes('SQLITE_CORRUPT') || + error?.message?.includes('database disk image is malformed') || + false + ); +} diff --git a/ts/sql/main.ts b/ts/sql/main.ts index 55fb927bc..05ad1bf83 100644 --- a/ts/sql/main.ts +++ b/ts/sql/main.ts @@ -6,6 +6,9 @@ import { join } from 'path'; import { Worker } from 'worker_threads'; +import { explodePromise } from '../util/explodePromise'; +import { isCorruptionError } from './errors'; + const ASAR_PATTERN = /app\.asar$/; const MIN_TRACE_DURATION = 40; @@ -58,6 +61,10 @@ export class MainSQL { private readonly onExit: Promise; + // This promise is resolved when any of the queries that we run against the + // database reject with a corruption error (see `isCorruptionError`) + private readonly onCorruption: Promise; + private seq = 0; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -77,6 +84,12 @@ export class MainSQL { join(scriptDir, isBundled ? 'mainWorker.bundle.js' : 'mainWorker.js') ); + const { + promise: onCorruption, + resolve: resolveCorruption, + } = explodePromise(); + this.onCorruption = onCorruption; + this.worker.on('message', (wrappedResponse: WrappedWorkerResponse) => { const { seq, error, response } = wrappedResponse; @@ -87,7 +100,12 @@ export class MainSQL { } if (error) { - pair.reject(new Error(error)); + const errorObj = new Error(error); + if (isCorruptionError(errorObj)) { + resolveCorruption(errorObj); + } + + pair.reject(errorObj); } else { pair.resolve(response); } @@ -111,6 +129,10 @@ export class MainSQL { this.isReady = true; } + public whenCorrupted(): Promise { + return this.onCorruption; + } + public async close(): Promise { if (!this.isReady) { throw new Error('Not initialized'); diff --git a/ts/util/createIPCEvents.ts b/ts/util/createIPCEvents.ts index 833b9bee7..cbee801bc 100644 --- a/ts/util/createIPCEvents.ts +++ b/ts/util/createIPCEvents.ts @@ -84,6 +84,7 @@ export type IPCEventsCallbacksType = { addCustomColor: (customColor: CustomColorType) => void; addDarkOverlay: () => void; deleteAllData: () => Promise; + closeDB: () => Promise; editCustomColor: (colorId: string, customColor: CustomColorType) => void; getConversationsWithCustomColor: (x: string) => Array; installStickerPack: (packId: string, key: string) => Promise; @@ -383,6 +384,10 @@ export function createIPCEvents( renderClearingDataView(); }, + closeDB: async () => { + await window.sqlInitializer.goBackToMainProcess(); + }, + showStickerPack: (packId, key) => { // We can get these events even if the user has never linked this instance. if (!window.Signal.Util.Registration.everDone()) { diff --git a/ts/windows/preload.ts b/ts/windows/preload.ts index 5b1547ead..8704c8497 100644 --- a/ts/windows/preload.ts +++ b/ts/windows/preload.ts @@ -17,6 +17,7 @@ installCallback('resetDefaultChatColor'); installCallback('setGlobalDefaultConversationColor'); installCallback('getDefaultConversationColor'); installCallback('persistZoomFactor'); +installCallback('closeDB'); // Getters only. These are set by the primary device installSetting('blockedCount', {