signal-desktop/app/crashReports.ts

231 lines
6.1 KiB
TypeScript
Raw Normal View History

2022-01-11 20:02:46 +00:00
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { app, crashReporter, ipcMain as ipc } from 'electron';
import { realpath, readdir, readFile, unlink, stat } from 'fs-extra';
2022-01-11 20:02:46 +00:00
import { basename, join } from 'path';
import { toJSONString as dumpToJSONString } from '@signalapp/libsignal-client/dist/Minidump';
import z from 'zod';
2022-01-11 20:02:46 +00:00
import type { LoggerType } from '../ts/types/Logging';
import * as Errors from '../ts/types/errors';
2024-05-02 19:52:53 +00:00
import { isProduction } from '../ts/util/version';
2024-08-07 16:17:28 +00:00
import { isNotNil } from '../ts/util/isNotNil';
import OS from '../ts/util/os/osMain';
2024-10-02 19:03:10 +00:00
import { parseUnknown } from '../ts/util/schemas';
2022-01-11 20:02:46 +00:00
2024-08-07 16:17:28 +00:00
// See https://github.com/rust-minidump/rust-minidump/blob/main/minidump-processor/json-schema.md
const dumpString = z.string().or(z.null()).optional();
const dumpNumber = z.number().or(z.null()).optional();
const threadSchema = z.object({
thread_name: dumpString,
frames: z
.object({
offset: dumpString,
module: dumpString,
module_offset: dumpString,
})
.array()
.or(z.null())
.optional(),
});
const dumpSchema = z.object({
crash_info: z
.object({
type: dumpString,
crashing_thread: dumpNumber,
address: dumpString,
})
.optional()
.or(z.null()),
crashing_thread: threadSchema.or(z.null()).optional(),
threads: threadSchema.array().or(z.null()).optional(),
modules: z
.object({
filename: dumpString,
debug_file: dumpString,
debug_id: dumpString,
base_addr: dumpString,
end_addr: dumpString,
version: dumpString,
})
.array()
.or(z.null())
.optional(),
system_info: z
.object({
cpu_arch: dumpString,
os: dumpString,
os_ver: dumpString,
})
.or(z.null())
.optional(),
});
2022-01-11 20:02:46 +00:00
async function getPendingDumps(): Promise<ReadonlyArray<string>> {
const crashDumpsPath = await realpath(app.getPath('crashDumps'));
2022-01-20 01:50:16 +00:00
let pendingDir: string;
if (OS.isWindows()) {
pendingDir = join(crashDumpsPath, 'reports');
} else {
// macOS and Linux
pendingDir = join(crashDumpsPath, 'pending');
}
2022-01-11 20:02:46 +00:00
const files = await readdir(pendingDir);
return files.map(file => join(pendingDir, file));
}
async function eraseDumps(
logger: LoggerType,
files: ReadonlyArray<string>
): Promise<void> {
logger.warn(`crashReports: erasing ${files.length} pending dumps`);
await Promise.all(
files.map(async fullPath => {
try {
await unlink(fullPath);
} catch (error) {
logger.warn(
`crashReports: failed to unlink crash report ${fullPath} due to error`,
Errors.toLogFormat(error)
);
}
})
);
}
export function setup(
getLogger: () => LoggerType,
2024-05-02 19:52:53 +00:00
showDebugLogWindow: () => Promise<void>,
forceEnable = false
): void {
2024-05-02 19:52:53 +00:00
const isEnabled = !isProduction(app.getVersion()) || forceEnable;
if (isEnabled) {
getLogger().info(`crashReporter: ${forceEnable ? 'force ' : ''}enabled`);
crashReporter.start({ uploadToServer: false });
}
2022-01-11 20:02:46 +00:00
ipc.handle('crash-reports:get-count', async () => {
2024-05-02 19:52:53 +00:00
if (!isEnabled) {
return 0;
}
2022-01-11 20:02:46 +00:00
const pendingDumps = await getPendingDumps();
2024-08-07 16:17:28 +00:00
const filteredDumps = (
await Promise.all(
pendingDumps.map(async fullPath => {
const content = await readFile(fullPath);
try {
2024-10-02 19:03:10 +00:00
const json: unknown = JSON.parse(dumpToJSONString(content));
const dump = parseUnknown(dumpSchema, json);
2024-08-07 16:17:28 +00:00
if (dump.crash_info?.type !== 'Simulated Exception') {
return fullPath;
}
} catch (error) {
getLogger().error(
`crashReports: failed to read crash report ${fullPath} due to error`,
Errors.toLogFormat(error)
);
}
try {
await unlink(fullPath);
} catch (error) {
getLogger().error(
`crashReports: failed to unlink crash report ${fullPath}`,
Errors.toLogFormat(error)
);
}
return undefined;
})
)
).filter(isNotNil);
if (filteredDumps.length !== 0) {
2022-01-11 20:02:46 +00:00
getLogger().warn(
2024-08-07 16:17:28 +00:00
`crashReports: ${filteredDumps.length} pending dumps found`
2022-01-11 20:02:46 +00:00
);
}
2024-08-07 16:17:28 +00:00
return filteredDumps.length;
2022-01-11 20:02:46 +00:00
});
ipc.handle('crash-reports:write-to-log', async () => {
2024-05-02 19:52:53 +00:00
if (!isEnabled) {
return;
}
2022-01-11 20:02:46 +00:00
const pendingDumps = await getPendingDumps();
if (pendingDumps.length === 0) {
return;
}
const logger = getLogger();
logger.warn(`crashReports: logging ${pendingDumps.length} dumps`);
2022-01-11 20:02:46 +00:00
await Promise.all(
2022-01-11 20:02:46 +00:00
pendingDumps.map(async fullPath => {
try {
const content = await readFile(fullPath);
const { mtime } = await stat(fullPath);
2024-10-02 19:03:10 +00:00
const json: unknown = JSON.parse(dumpToJSONString(content));
const dump = parseUnknown(dumpSchema, json);
2024-08-07 16:17:28 +00:00
if (dump.crash_info?.type === 'Simulated Exception') {
return undefined;
}
2024-08-07 16:17:28 +00:00
dump.modules = dump.modules?.filter(({ filename }) => {
if (filename == null) {
return false;
}
// Node.js Addons are useful
if (/\.node$/.test(filename)) {
return true;
}
// So is Electron
if (/electron|signal/i.test(filename)) {
return true;
}
// Rest are not relevant
return false;
});
2022-01-11 20:02:46 +00:00
logger.warn(
`crashReports: dump=${basename(fullPath)} ` +
`mtime=${JSON.stringify(mtime)}`,
JSON.stringify(dump, null, 2)
);
} catch (error) {
logger.error(
2022-01-11 20:02:46 +00:00
`crashReports: failed to read crash report ${fullPath} due to error`,
Errors.toLogFormat(error)
);
return undefined;
}
})
);
await eraseDumps(logger, pendingDumps);
await showDebugLogWindow();
2022-01-11 20:02:46 +00:00
});
ipc.handle('crash-reports:erase', async () => {
2024-05-02 19:52:53 +00:00
if (!isEnabled) {
return;
}
2022-01-11 20:02:46 +00:00
const pendingDumps = await getPendingDumps();
await eraseDumps(getLogger(), pendingDumps);
});
}