Detect database corruption consistently
This commit is contained in:
parent
1184098b42
commit
7510be0caf
7 changed files with 106 additions and 77 deletions
40
main.js
40
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);
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
10
ts/sql/errors.ts
Normal 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
|
||||
);
|
||||
}
|
|
@ -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');
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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', {
|
||||
|
|
Loading…
Add table
Reference in a new issue