Detect database corruption consistently

This commit is contained in:
Fedor Indutny 2021-09-08 13:39:14 -07:00 committed by GitHub
parent 1184098b42
commit 7510be0caf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 106 additions and 77 deletions

40
main.js
View File

@ -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);
});

View File

@ -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);

View File

@ -374,25 +374,6 @@ function getSQLCipherVersion(db: Database): string | undefined {
return db.pragma('cipher_version', { simple: true });
}
function getSQLCipherIntegrityCheck(db: Database): Array<string> | 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<void> {
async function removeDB(): Promise<void> {
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(

10
ts/sql/errors.ts Normal file
View File

@ -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
);
}

View File

@ -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<void>;
// 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<Error>;
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<Error>();
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<Error> {
return this.onCorruption;
}
public async close(): Promise<void> {
if (!this.isReady) {
throw new Error('Not initialized');

View File

@ -84,6 +84,7 @@ export type IPCEventsCallbacksType = {
addCustomColor: (customColor: CustomColorType) => void;
addDarkOverlay: () => void;
deleteAllData: () => Promise<void>;
closeDB: () => Promise<void>;
editCustomColor: (colorId: string, customColor: CustomColorType) => void;
getConversationsWithCustomColor: (x: string) => Array<ConversationType>;
installStickerPack: (packId: string, key: string) => Promise<void>;
@ -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()) {

View File

@ -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', {