Use libsignal-client for parsing crash reports

This commit is contained in:
Fedor Indutny 2024-02-13 13:41:48 -08:00 committed by GitHub
parent d7f0978c6d
commit 9ad6d5b66b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 86 additions and 241 deletions

View file

@ -1,17 +1,34 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { app, clipboard, crashReporter, ipcMain as ipc } from 'electron';
import { realpath, readdir, readFile, unlink } from 'fs-extra';
import { app, crashReporter, ipcMain as ipc } from 'electron';
import { realpath, readdir, readFile, unlink, stat } from 'fs-extra';
import { basename, join } from 'path';
import { toJSONString as dumpToJSONString } from '@signalapp/libsignal-client/dist/Minidump';
import z from 'zod';
import type { LoggerType } from '../ts/types/Logging';
import * as Errors from '../ts/types/errors';
import { isAlpha } from '../ts/util/version';
import { upload as uploadDebugLog } from '../ts/logging/uploadDebugLog';
import { SignalService as Proto } from '../ts/protobuf';
import OS from '../ts/util/os/osMain';
const dumpSchema = z
.object({
crashing_thread: z
.object({
frames: z
.object({
registers: z.unknown(),
})
.passthrough()
.array()
.optional(),
})
.passthrough()
.optional(),
})
.passthrough();
async function getPendingDumps(): Promise<ReadonlyArray<string>> {
const crashDumpsPath = await realpath(app.getPath('crashDumps'));
let pendingDir: string;
@ -46,7 +63,11 @@ async function eraseDumps(
);
}
export function setup(getLogger: () => LoggerType, forceEnable = false): void {
export function setup(
getLogger: () => LoggerType,
showDebugLogWindow: () => Promise<void>,
forceEnable = false
): void {
const isEnabled = isAlpha(app.getVersion()) || forceEnable;
if (isEnabled) {
@ -68,7 +89,7 @@ export function setup(getLogger: () => LoggerType, forceEnable = false): void {
return pendingDumps.length;
});
ipc.handle('crash-reports:upload', async () => {
ipc.handle('crash-reports:write-to-log', async () => {
if (!isEnabled) {
return;
}
@ -79,17 +100,26 @@ export function setup(getLogger: () => LoggerType, forceEnable = false): void {
}
const logger = getLogger();
logger.warn(`crashReports: uploading ${pendingDumps.length} dumps`);
logger.warn(`crashReports: logging ${pendingDumps.length} dumps`);
const maybeDumps = await Promise.all(
await Promise.all(
pendingDumps.map(async fullPath => {
try {
return {
filename: basename(fullPath),
content: await readFile(fullPath),
};
} catch (error) {
const content = await readFile(fullPath);
const { mtime } = await stat(fullPath);
const dump = dumpSchema.parse(JSON.parse(dumpToJSONString(content)));
for (const frame of dump.crashing_thread?.frames ?? []) {
delete frame.registers;
}
logger.warn(
`crashReports: dump=${basename(fullPath)} ` +
`mtime=${JSON.stringify(mtime)}`,
JSON.stringify(dump, null, 2)
);
} catch (error) {
logger.error(
`crashReports: failed to read crash report ${fullPath} due to error`,
Errors.toLogFormat(error)
);
@ -98,30 +128,8 @@ export function setup(getLogger: () => LoggerType, forceEnable = false): void {
})
);
const content = Proto.CrashReportList.encode({
reports: maybeDumps.filter(
(dump): dump is { filename: string; content: Buffer } => {
return dump !== undefined;
}
),
}).finish();
try {
const url = await uploadDebugLog({
content,
appVersion: app.getVersion(),
logger,
extension: 'dmp',
contentType: 'application/octet-stream',
compress: false,
prefix: 'desktop-crash-',
});
logger.info(`crashReports: upload complete, ${url}`);
clipboard.writeText(url);
} finally {
await eraseDumps(logger, pendingDumps);
}
await eraseDumps(logger, pendingDumps);
await showDebugLogWindow();
});
ipc.handle('crash-reports:erase', async () => {

View file

@ -211,7 +211,7 @@ const FORCE_ENABLE_CRASH_REPORTS = process.argv.some(
const CLI_LANG = cliOptions.lang as string | undefined;
setupCrashReports(getLogger, FORCE_ENABLE_CRASH_REPORTS);
setupCrashReports(getLogger, showDebugLogWindow, FORCE_ENABLE_CRASH_REPORTS);
let sendDummyKeystroke: undefined | (() => void);
if (OS.isWindows()) {

View file

@ -97,7 +97,7 @@
"@react-aria/utils": "3.16.0",
"@react-spring/web": "9.5.5",
"@signalapp/better-sqlite3": "8.6.0",
"@signalapp/libsignal-client": "0.39.1",
"@signalapp/libsignal-client": "0.40.0",
"@signalapp/ringrtc": "2.37.1",
"@signalapp/windows-dummy-keystroke": "1.0.0",
"@types/fabric": "4.5.3",

View file

@ -1,15 +0,0 @@
syntax = "proto3";
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
package signalservice;
message CrashReport {
string filename = 1;
bytes content = 2;
}
message CrashReportList {
repeated CrashReport reports = 1;
}

View file

@ -1356,7 +1356,9 @@ export class ConversationController {
async _forgetE164(e164: string): Promise<void> {
const { server } = window.textsecure;
strictAssert(server, 'Server must be initialized');
const serviceIdMap = await getServiceIdsForE164s(server, [e164]);
const { entries: serviceIdMap } = await getServiceIdsForE164s(server, [
e164,
]);
const pni = serviceIdMap.get(e164)?.pni;

View file

@ -24,9 +24,9 @@ export function Basic(): JSX.Element {
<CrashReportDialog
i18n={i18n}
isPending={isPending}
uploadCrashReports={async () => {
writeCrashReportsToLog={async () => {
setIsPending(true);
action('uploadCrashReports')();
action('writeCrashReportsToLog')();
await sleep(5000);
setIsPending(false);
}}

View file

@ -9,7 +9,7 @@ import { Modal } from './Modal';
import { Spinner } from './Spinner';
type PropsActionsType = {
uploadCrashReports: () => void;
writeCrashReportsToLog: () => void;
eraseCrashReports: () => void;
};
@ -19,7 +19,7 @@ export type PropsType = {
} & PropsActionsType;
export function CrashReportDialog(props: Readonly<PropsType>): JSX.Element {
const { i18n, isPending, uploadCrashReports, eraseCrashReports } = props;
const { i18n, isPending, writeCrashReportsToLog, eraseCrashReports } = props;
const onEraseClick = (event: React.MouseEvent) => {
event.preventDefault();
@ -30,7 +30,7 @@ export function CrashReportDialog(props: Readonly<PropsType>): JSX.Element {
const onSubmitClick = (event: React.MouseEvent) => {
event.preventDefault();
uploadCrashReports();
writeCrashReportsToLog();
};
const footer = (

View file

@ -240,7 +240,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
<CrashReportDialog
i18n={i18n}
isPending={false}
uploadCrashReports={action('uploadCrashReports')}
writeCrashReportsToLog={action('writeCrashReportsToLog')}
eraseCrashReports={action('eraseCrashReports')}
/>
),

View file

@ -1,153 +0,0 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import fs from 'fs/promises';
import path from 'path';
import http from 'http';
import https from 'https';
import { tmpdir } from 'os';
import { execFile as rawExecFile } from 'child_process';
import { promisify } from 'util';
import { strictAssert } from '../util/assert';
import { wrapEventEmitterOnce } from '../util/wrapEventEmitterOnce';
import { SignalService as Proto } from '../protobuf';
const execFile = promisify(rawExecFile);
const TARGET_URL = 'https://symbols.electronjs.org';
const CLI_OPTIONS = [];
const CLI_ARGS = [];
for (const arg of process.argv.slice(2)) {
if (arg.startsWith('--')) {
CLI_OPTIONS.push(arg.slice(2));
} else {
CLI_ARGS.push(arg);
}
}
const [OUTPUT_DIR, ...CRASH_REPORTS] = CLI_ARGS;
main(OUTPUT_DIR, CRASH_REPORTS, CLI_OPTIONS).catch(error => {
console.error(error.stack);
process.exit(1);
});
async function main(
outDir: string,
fileNames: ReadonlyArray<string>,
options: ReadonlyArray<string>
): Promise<void> {
await fs.mkdir(outDir, { recursive: true });
const substitutions = new Map<string, Buffer>();
await Promise.all(
options.map(async option => {
const match = option.match(/^sub:(.*)=(.*)$/);
if (!match) {
return;
}
substitutions.set(match[1], await fs.readFile(match[2]));
})
);
const proxyServer = http
.createServer(async ({ method, url = '/' }, res) => {
console.log(`Proxy server got request ${method} ${url}`);
if (method !== 'GET') {
throw new Error('Unsupported');
}
for (const [name, buffer] of substitutions) {
if (url.includes(name)) {
console.log(`Providing substitution for ${url}`);
res.end(buffer);
return;
}
}
// eslint-disable-next-line no-useless-escape
const patchedURL = url.replace(/signal-desktop-[^\/.]+/g, 'electron');
https.get(`${TARGET_URL}${patchedURL}`, remoteRes => {
res.writeHead(remoteRes.statusCode ?? 500, remoteRes.headers);
remoteRes.pipe(res);
});
})
.unref();
proxyServer.listen(0);
await wrapEventEmitterOnce(proxyServer, 'listening');
const addr = proxyServer.address();
strictAssert(
typeof addr === 'object' && addr != null,
'Address has to be an object'
);
const { port: proxyPort } = addr;
console.log(`Proxy server listening on ${proxyPort}`);
await Promise.all(
fileNames.map(fileName => symbolicate(outDir, fileName, proxyPort))
);
proxyServer.close();
}
async function symbolicate(
outDir: string,
fileName: string,
proxyPort: number
): Promise<void> {
const tmpDir = await fs.mkdtemp(path.join(tmpdir(), 'signal-crashe'));
await fs.mkdir(tmpDir, { recursive: true });
const encoded = await fs.readFile(fileName);
let reports: ReadonlyArray<Proto.ICrashReport>;
if (fileName.endsWith('.raw')) {
reports = [
{
filename: 'report.dmp',
content: encoded,
},
];
} else {
({ reports } = Proto.CrashReportList.decode(encoded));
}
const { name: prefix } = path.parse(fileName);
await Promise.all(
reports.map(async ({ filename: reportName, content }) => {
if (!reportName || !content) {
return;
}
const { base, name, ext } = path.parse(reportName);
if (ext !== '.dmp') {
console.log(`Ignoring ${reportName}, wrong extension`);
return;
}
const dumpFile = path.join(tmpDir, `${prefix}-${base}`);
console.log(`Extracting to ${dumpFile}`);
await fs.writeFile(dumpFile, content);
const outFile = path.join(outDir, `${prefix}-${name}.txt`);
await execFile('minidump-stackwalk', [
'--symbols-url',
`http://127.0.0.1:${proxyPort}`,
'--output-file',
outFile,
dumpFile,
]);
console.log(`Symbolicating ${dumpFile} to ${outFile}`);
})
);
}

View file

@ -93,7 +93,7 @@ function checkForAccount(
log.info(`checkForAccount: looking ${phoneNumber} up on server`);
try {
const serviceIdLookup = await getServiceIdsForE164s(server, [
const { entries: serviceIdLookup } = await getServiceIdsForE164s(server, [
phoneNumber,
]);
const maybePair = serviceIdLookup.get(phoneNumber);

View file

@ -22,7 +22,7 @@ export type CrashReportsStateType = ReadonlyDeep<{
// Actions
const SET_COUNT = 'crashReports/SET_COUNT';
const UPLOAD = 'crashReports/UPLOAD';
const WRITE_TO_LOG = 'crashReports/WRITE_TO_LOG';
const ERASE = 'crashReports/ERASE';
type SetCrashReportCountActionType = ReadonlyDeep<{
@ -32,7 +32,7 @@ type SetCrashReportCountActionType = ReadonlyDeep<{
type CrashReportsActionType = ReadonlyDeep<
| SetCrashReportCountActionType
| PromiseAction<typeof UPLOAD>
| PromiseAction<typeof WRITE_TO_LOG>
| PromiseAction<typeof ERASE>
>;
@ -40,7 +40,7 @@ type CrashReportsActionType = ReadonlyDeep<
export const actions = {
setCrashReportCount,
uploadCrashReports,
writeCrashReportsToLog,
eraseCrashReports,
};
@ -48,23 +48,22 @@ function setCrashReportCount(count: number): SetCrashReportCountActionType {
return { type: SET_COUNT, payload: count };
}
function uploadCrashReports(): ThunkAction<
function writeCrashReportsToLog(): ThunkAction<
void,
RootStateType,
unknown,
PromiseAction<typeof UPLOAD> | ShowToastActionType
PromiseAction<typeof WRITE_TO_LOG> | ShowToastActionType
> {
return dispatch => {
async function run() {
try {
await window.IPC.crashReports.upload();
dispatch(showToast({ toastType: ToastType.LinkCopied }));
await window.IPC.crashReports.writeToLog();
} catch (error) {
dispatch(showToast({ toastType: ToastType.DebugLogError }));
throw error;
}
}
dispatch({ type: UPLOAD, payload: run() });
dispatch({ type: WRITE_TO_LOG, payload: run() });
};
}
@ -108,7 +107,7 @@ export function reducer(
}
if (
action.type === `${UPLOAD}_PENDING` ||
action.type === `${WRITE_TO_LOG}_PENDING` ||
action.type === `${ERASE}_PENDING`
) {
return {
@ -118,7 +117,7 @@ export function reducer(
}
if (
action.type === `${UPLOAD}_FULFILLED` ||
action.type === `${WRITE_TO_LOG}_FULFILLED` ||
action.type === `${ERASE}_FULFILLED`
) {
return {
@ -129,13 +128,13 @@ export function reducer(
}
if (
action.type === (`${UPLOAD}_REJECTED` as const) ||
action.type === (`${WRITE_TO_LOG}_REJECTED` as const) ||
action.type === (`${ERASE}_REJECTED` as const)
) {
const { error } = action;
log.error(
`Failed to upload crash report due to error ${Errors.toLogFormat(error)}`
`Failed to write crash report due to error ${Errors.toLogFormat(error)}`
);
return {

View file

@ -153,7 +153,9 @@ describe('updateConversationsWithUuidLookup', () => {
sinonSandbox.stub(window.Signal.Data, 'updateConversation');
fakeCdsLookup = sinonSandbox.stub().resolves(new Map());
fakeCdsLookup = sinonSandbox.stub().resolves({
entries: new Map(),
});
fakeCheckAccountExistence = sinonSandbox.stub().resolves(false);
fakeServer = {
cdsLookup: fakeCdsLookup,
@ -198,12 +200,12 @@ describe('updateConversationsWithUuidLookup', () => {
const aci1 = generateAci();
const aci2 = generateAci();
fakeCdsLookup.resolves(
new Map([
fakeCdsLookup.resolves({
entries: new Map([
['+13215559876', { aci: aci1, pni: undefined }],
['+16545559876', { aci: aci2, pni: undefined }],
])
);
]),
});
await updateConversationsWithUuidLookup({
conversationController: new FakeConversationController([

View file

@ -122,7 +122,7 @@ export abstract class CDSSocketBase<
}
log.info('CDSSocket.request(): done');
return resultMap;
return { debugPermitsUsed: 0, entries: resultMap };
}
// Abstract methods

View file

@ -27,7 +27,7 @@ export async function updateConversationsWithUuidLookup({
return;
}
const serverLookup = await getServiceIdsForE164s(server, e164s);
const { entries: serverLookup } = await getServiceIdsForE164s(server, e164s);
await Promise.all(
conversations.map(async conversation => {

View file

@ -69,7 +69,9 @@ export async function lookupConversationWithoutServiceId(
try {
let conversationId: string | undefined;
if (options.type === 'e164') {
const serverLookup = await getServiceIdsForE164s(server, [options.e164]);
const { entries: serverLookup } = await getServiceIdsForE164s(server, [
options.e164,
]);
const maybePair = serverLookup.get(options.e164);

2
ts/window.d.ts vendored
View file

@ -61,7 +61,7 @@ export type IPCType = {
closeAbout: () => void;
crashReports: {
getCount: () => Promise<number>;
upload: () => Promise<void>;
writeToLog: () => Promise<void>;
erase: () => Promise<void>;
};
drawAttention: () => void;

View file

@ -78,7 +78,7 @@ const IPC: IPCType = {
closeAbout: () => ipc.send('close-about'),
crashReports: {
getCount: () => ipc.invoke('crash-reports:get-count'),
upload: () => ipc.invoke('crash-reports:upload'),
writeToLog: () => ipc.invoke('crash-reports:write-to-log'),
erase: () => ipc.invoke('crash-reports:erase'),
},
drawAttention: () => {

View file

@ -3955,10 +3955,10 @@
bindings "^1.5.0"
tar "^6.1.0"
"@signalapp/libsignal-client@0.39.1":
version "0.39.1"
resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.39.1.tgz#15b41f15c516ae3eecf8a098a9c9c7aac00444d7"
integrity sha512-Drna/0rQTa/jB475KssoBA86Da/DLdJYDznkbiFG2YD/OeWEKoDpi64bp+BIpnc2o16GnVhGLFzNvMfVkI41eQ==
"@signalapp/libsignal-client@0.40.0":
version "0.40.0"
resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.40.0.tgz#2997f44b69d4d73aa474550243998b628d0d3a1a"
integrity sha512-RrVe46KDHSfspvz+rmqkP2KMrlctWb6voejEFzhxSB8yXbnmEQuwEu7bqJj+uYsLhv8nKpIfC1qniNQc0SDPeA==
dependencies:
node-gyp-build "^4.2.3"
type-fest "^3.5.0"