Move crash reporting listener to the server

Summary: Changelog: Move crash watcher to the server. Add 'device-crash' event. Add 'device-start-crash-watcher', 'device-stop-crash-watcher' commands. Add 'onDeviceCrash' method to Plugin Client.

Reviewed By: mweststrate

Differential Revision: D33089810

fbshipit-source-id: ed62ee7c1129e5e25af18b444744b0796f567b72
This commit is contained in:
Andrey Goncharov
2021-12-20 11:37:25 -08:00
committed by Facebook GitHub Bot
parent 9fd45b96d2
commit 731749b41f
21 changed files with 519 additions and 426 deletions

View File

@@ -289,6 +289,10 @@ export class FlipperServerImpl implements FlipperServer {
this.getDevice(serial).startLogging(),
'device-stop-logging': async (serial: string) =>
this.getDevice(serial).stopLogging(),
'device-start-crash-watcher': async (serial: string) =>
this.getDevice(serial).startCrashWatcher(),
'device-stop-crash-watcher': async (serial: string) =>
this.getDevice(serial).stopCrashWatcher(),
'device-supports-screenshot': async (serial: string) =>
this.getDevice(serial).screenshotAvailable(),
'device-supports-screencapture': async (serial: string) =>

View File

@@ -15,6 +15,8 @@ export abstract class ServerDevice {
readonly flipperServer: FlipperServerImpl;
connected = true;
protected stopCrashWatcherCb?: () => void;
constructor(flipperServer: FlipperServerImpl, info: DeviceDescription) {
this.flipperServer = flipperServer;
this.info = info;
@@ -46,6 +48,20 @@ export abstract class ServerDevice {
// to be subclassed
}
startCrashWatcher() {
this.stopCrashWatcherCb = this.startCrashWatcherImpl?.();
}
protected startCrashWatcherImpl(): () => void {
// to be subclassed
return () => {};
}
stopCrashWatcher() {
this.stopCrashWatcherCb?.();
this.stopCrashWatcherCb = undefined;
}
async screenshotAvailable(): Promise<boolean> {
return false;
}

View File

@@ -0,0 +1,119 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Entry, Priority} from 'adbkit-logcat';
import type {CrashLog} from 'flipper-common';
import AndroidDevice from './AndroidDevice';
export function parseAndroidCrash(content: string, logDate?: Date) {
const regForName = /.*\n/;
const nameRegArr = regForName.exec(content);
let name = nameRegArr ? nameRegArr[0] : 'Unknown';
const regForCallStack = /\tat[\w\s\n\.$&+,:;=?@#|'<>.^*()%!-]*$/;
const callStackArray = regForCallStack.exec(content);
const callStack = callStackArray ? callStackArray[0] : '';
let remainingString =
callStack.length > 0 ? content.replace(callStack, '') : '';
if (remainingString[remainingString.length - 1] === '\n') {
remainingString = remainingString.slice(0, -1);
}
const reasonText =
remainingString.length > 0 ? remainingString.split('\n').pop() : 'Unknown';
const reason = reasonText ? reasonText : 'Unknown';
if (name[name.length - 1] === '\n') {
name = name.slice(0, -1);
}
const crash: CrashLog = {
callstack: content,
name: name,
reason: reason,
date: logDate?.getTime(),
};
return crash;
}
export function shouldParseAndroidLog(entry: Entry, date: Date): boolean {
return (
entry.date.getTime() - date.getTime() > 0 && // The log should have arrived after the device has been registered
((entry.priority === Priority.ERROR && entry.tag === 'AndroidRuntime') ||
entry.priority === Priority.FATAL)
);
}
/**
* Starts listening ADB logs. Emits 'device-crash' on "error" and "fatal" entries.
* Listens to the logs in a separate stream.
* We can't leverage teh existing log listener mechanism (see `startLogging`)
* it is started externally (by the client). Refactoring how we start log listeners is a bit too much.
* It is easier to start its own stream for crash watcher and manage it independently.
*/
export function startAndroidCrashWatcher(device: AndroidDevice) {
const referenceDate = new Date();
let androidLog: string = '';
let androidLogUnderProcess = false;
let timer: null | NodeJS.Timeout = null;
let gracefulShutdown = false;
const readerPromise = device.adb
.openLogcat(device.serial, {clear: true})
.then((reader) =>
reader
.on('entry', (entry) => {
if (shouldParseAndroidLog(entry, referenceDate)) {
if (androidLogUnderProcess) {
androidLog += '\n' + entry.message;
androidLog = androidLog.trim();
if (timer) {
clearTimeout(timer);
}
} else {
androidLog = entry.message;
androidLogUnderProcess = true;
}
timer = setTimeout(() => {
if (androidLog.length > 0) {
device.flipperServer.emit('device-crash', {
crash: parseAndroidCrash(androidLog, entry.date),
serial: device.info.serial,
});
}
androidLogUnderProcess = false;
androidLog = '';
}, 50);
}
})
.on('end', () => {
if (!gracefulShutdown) {
// logs didn't stop gracefully
setTimeout(() => {
if (device.connected) {
console.warn(
`Log stream broken: ${device.serial} - restarting`,
);
device.startCrashWatcher();
}
}, 100);
}
})
.on('error', (e) => {
console.warn('Failed to read from adb logcat: ', e);
}),
)
.catch((e) => {
console.warn('Failed to open log stream: ', e);
});
return () => {
gracefulShutdown = true;
readerPromise
.then((reader) => reader?.end())
.catch((e) => {
console.error('Failed to close adb logcat stream: ', e);
});
};
}

View File

@@ -17,6 +17,7 @@ import {dirname, join} from 'path';
import {DeviceSpec} from 'flipper-common';
import {ServerDevice} from '../ServerDevice';
import {FlipperServerImpl} from '../../FlipperServerImpl';
import {startAndroidCrashWatcher} from './AndroidCrashUtils';
const DEVICE_RECORDING_DIR = '/sdcard/flipper_recorder';
@@ -49,6 +50,7 @@ export default class AndroidDevice extends ServerDevice {
this.adb = adb;
}
// TODO: Prevent starting logging multiple times
startLogging() {
this.adb
.openLogcat(this.serial, {clear: true})
@@ -112,6 +114,10 @@ export default class AndroidDevice extends ServerDevice {
this.reader = undefined;
}
protected startCrashWatcherImpl(): () => void {
return startAndroidCrashWatcher(this);
}
reverse(ports: number[]): Promise<void> {
return Promise.all(
ports.map((port) =>

View File

@@ -0,0 +1,103 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Entry, Priority} from 'adbkit-logcat';
import {parseAndroidCrash, shouldParseAndroidLog} from '../AndroidCrashUtils';
function getAndroidLog(
date: Date,
priority: number,
tag: string,
message: string,
) {
return {date, priority, tag, message, pid: 0, tid: 0} as Entry;
}
test('test shouldParseAndroidLog function for type error and tag is AndroidRuntime', () => {
const referenceDate = new Date();
const log = getAndroidLog(
new Date(referenceDate.getTime() + 10000), //This log arrives 10 secs after the refernce time
Priority.ERROR,
'AndroidRuntime',
'Possible runtime crash',
);
const shouldParseTheLog = shouldParseAndroidLog(log, referenceDate);
expect(shouldParseTheLog).toEqual(true);
});
test('test shouldParseAndroidLog function for type non-error', () => {
const referenceDate = new Date();
const log = getAndroidLog(
new Date(referenceDate.getTime() + 10000), //This log arrives 10 secs after the refernce time
Priority.DEBUG,
'fb4a.activitymanager',
'Possible debug info in activitymanager',
);
const shouldParseTheLog = shouldParseAndroidLog(log, referenceDate);
expect(shouldParseTheLog).toEqual(false);
});
test('test shouldParseAndroidLog function for the older android log', () => {
const referenceDate = new Date();
const log = getAndroidLog(
new Date(referenceDate.getTime() - 10000), //This log arrives 10 secs before the refernce time
Priority.ERROR,
'fb4a.activitymanager',
'Possible error info in activitymanager',
);
const shouldParseTheLog = shouldParseAndroidLog(log, referenceDate);
expect(shouldParseTheLog).toEqual(false);
});
test('test shouldParseAndroidLog function for the fatal log', () => {
const referenceDate = new Date();
const log = getAndroidLog(
new Date(referenceDate.getTime() + 10000), //This log arrives 10 secs after the refernce time
Priority.FATAL,
'arbitrary tag',
'Possible error info in activitymanager',
);
const shouldParseTheLog = shouldParseAndroidLog(log, referenceDate);
expect(shouldParseTheLog).toEqual(true);
});
test('test shouldParseAndroidLog function for the error log which does not staisfy our tags check', () => {
const referenceDate = new Date();
const log = getAndroidLog(
new Date(referenceDate.getTime() + 10000), //This log arrives 10 secs after the refernce time
Priority.ERROR,
'arbitrary tag',
'Possible error info in fb4a',
);
const shouldParseTheLog = shouldParseAndroidLog(log, referenceDate);
expect(shouldParseTheLog).toEqual(false);
});
test('test the parsing of the Android crash log for the proper android crash format', () => {
const log =
'FATAL EXCEPTION: main\nProcess: com.facebook.flipper.sample, PID: 27026\njava.lang.IndexOutOfBoundsException: Index: 190, Size: 0\n\tat java.util.ArrayList.get(ArrayList.java:437)\n\tat com.facebook.flipper.sample.RootComponentSpec.hitGetRequest(RootComponentSpec.java:72)\n\tat com.facebook.flipper.sample.RootComponent.hitGetRequest(RootComponent.java:46)\n';
const date = new Date();
const crash = parseAndroidCrash(log, date);
expect(crash.callstack).toEqual(log);
expect(crash.reason).toEqual(
'java.lang.IndexOutOfBoundsException: Index: 190, Size: 0',
);
expect(crash.name).toEqual('FATAL EXCEPTION: main');
expect(crash.date).toEqual(date.getTime());
});
test('test the parsing of the Android crash log for the unknown crash format and no date', () => {
const log = 'Blaa Blaa Blaa';
const crash = parseAndroidCrash(log, undefined);
expect(crash.callstack).toEqual(log);
expect(crash.reason).toEqual('Unknown');
expect(crash.name).toEqual('Unknown');
expect(crash.date).toBeUndefined();
});
test('test the parsing of the Android crash log for the partial format matching the crash format', () => {
const log = 'First Line Break \n Blaa Blaa \n Blaa Blaa ';
const crash = parseAndroidCrash(log, undefined);
expect(crash.callstack).toEqual(log);
expect(crash.reason).toEqual('Unknown');
expect(crash.name).toEqual('First Line Break ');
});

View File

@@ -20,6 +20,7 @@ import {ERR_PHYSICAL_DEVICE_LOGS_WITHOUT_IDB, IOSBridge} from './IOSBridge';
import split2 from 'split2';
import {ServerDevice} from '../ServerDevice';
import {FlipperServerImpl} from '../../FlipperServerImpl';
import {addFileWatcherForiOSCrashLogs} from './iOSCrashUtils';
type IOSLogLevel = 'Default' | 'Info' | 'Debug' | 'Error' | 'Fault';
@@ -153,6 +154,10 @@ export default class IOSDevice extends ServerDevice {
}
}
protected startCrashWatcherImpl(): () => void {
return addFileWatcherForiOSCrashLogs(this);
}
static getLogLevel(level: string): DeviceLogLevel {
switch (level) {
case 'Default':

View File

@@ -0,0 +1,147 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
parseIosCrash,
parsePath,
shouldShowiOSCrashNotification,
} from '../iOSCrashUtils';
test('test the parsing of the date and crash info for the log which matches the predefined regex', () => {
const log =
'Blaa Blaaa \n Blaa Blaaa \n Exception Type: SIGSEGV \n Blaa Blaa \n Blaa Blaa Date/Time: 2019-03-21 12:07:00.861 +0000 \n Blaa balaaa';
const crash = parseIosCrash(log);
expect(crash.callstack).toEqual(log);
expect(crash.reason).toEqual('SIGSEGV');
expect(crash.name).toEqual('SIGSEGV');
expect(crash.date).toEqual(new Date('2019-03-21 12:07:00.861').getTime());
});
test('test the parsing of the reason for crash when log matches the crash regex, but there is no mention of date', () => {
const log =
'Blaa Blaaa \n Blaa Blaaa \n Exception Type: SIGSEGV \n Blaa Blaa \n Blaa Blaa';
const crash = parseIosCrash(log);
expect(crash.callstack).toEqual(log);
expect(crash.reason).toEqual('SIGSEGV');
expect(crash.name).toEqual('SIGSEGV');
});
test('test the parsing of the crash log when log does not match the predefined regex but is alphanumeric', () => {
const log = 'Blaa Blaaa \n Blaa Blaaa \n Blaa Blaaa';
const crash = parseIosCrash(log);
expect(crash.callstack).toEqual(log);
expect(crash.reason).toEqual('Unknown');
expect(crash.name).toEqual('Unknown');
});
test('test the parsing of the reason for crash when log does not match the predefined regex contains unicode character', () => {
const log =
'Blaa Blaaa \n Blaa Blaaa \n Exception Type: 🍕🐬 \n Blaa Blaa \n Blaa Blaa';
const crash = parseIosCrash(log);
expect(crash.callstack).toEqual(log);
expect(crash.reason).toEqual('Unknown');
expect(crash.name).toEqual('Unknown');
});
test('test the parsing of the reason for crash when log is empty', () => {
const log = '';
const crash = parseIosCrash(log);
expect(crash.callstack).toEqual(log);
expect(crash.reason).toEqual('Unknown');
expect(crash.name).toEqual('Unknown');
});
test('test the parsing of the Android crash log with os being iOS', () => {
const log =
'FATAL EXCEPTION: main\nProcess: com.facebook.flipper.sample, PID: 27026\njava.lang.IndexOutOfBoundsException: Index: 190, Size: 0\n\tat java.util.ArrayList.get(ArrayList.java:437)\n\tat com.facebook.flipper.sample.RootComponentSpec.hitGetRequest(RootComponentSpec.java:72)\n\tat com.facebook.flipper.sample.RootComponent.hitGetRequest(RootComponent.java:46)\n';
const crash = parseIosCrash(log);
expect(crash.callstack).toEqual(log);
expect(crash.reason).toEqual('Unknown');
expect(crash.name).toEqual('Unknown');
});
test('test parsing of path when inputs are correct', () => {
const content =
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-15DEV1CE-1D/AppName.app/AppName \n Blaa Blaa \n Blaa Blaa';
const id = parsePath(content);
expect(id).toEqual('path/to/simulator/TH1S-15DEV1CE-1D/AppName.app/AppName');
});
test('test parsing of path when path has special characters in it', () => {
let content =
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-15DEV1CE-1D/App Name.app/App Name \n Blaa Blaa \n Blaa Blaa';
let id = parsePath(content);
expect(id).toEqual(
'path/to/simulator/TH1S-15DEV1CE-1D/App Name.app/App Name',
);
content =
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-15DEV1CE-1D/App_Name.app/App_Name \n Blaa Blaa \n Blaa Blaa';
id = parsePath(content);
expect(id).toEqual(
'path/to/simulator/TH1S-15DEV1CE-1D/App_Name.app/App_Name',
);
content =
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-15DEV1CE-1D/App%20Name.app/App%20Name \n Blaa Blaa \n Blaa Blaa';
id = parsePath(content);
expect(id).toEqual(
'path/to/simulator/TH1S-15DEV1CE-1D/App%20Name.app/App%20Name',
);
});
test('test parsing of path when a regex is not present', () => {
const content = 'Blaa Blaaa \n Blaa Blaaa \n Blaa Blaa \n Blaa Blaa';
const id = parsePath(content);
expect(id).toEqual(null);
});
test('test shouldShowCrashNotification function for all correct inputs', () => {
const content =
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-15DEV1CE-1D/App Name.app/App Name \n Blaa Blaa \n Blaa Blaa';
const shouldShowNotification = shouldShowiOSCrashNotification(
'TH1S-15DEV1CE-1D',
content,
);
expect(shouldShowNotification).toEqual(true);
});
test('test shouldShowiOSCrashNotification function for all correct inputs but incorrect id', () => {
const content =
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-1598DEV1CE-2D/App Name.app/App Name \n Blaa Blaa \n Blaa Blaa';
const shouldShowNotification = shouldShowiOSCrashNotification(
'TH1S-15DEV1CE-1D',
content,
);
expect(shouldShowNotification).toEqual(false);
});
test('test shouldShowiOSCrashNotification function for undefined device', () => {
const content =
'Blaa Blaaa \n Blaa Blaaa \n Path: path/to/simulator/TH1S-1598DEV1CE-2D/App Name.app/App Name \n Blaa Blaa \n Blaa Blaa';
const shouldShowNotification = shouldShowiOSCrashNotification(
null as any,
content,
);
expect(shouldShowNotification).toEqual(false);
});
test('only crashes from the correct device are picked up', () => {
const serial = 'AC9482A2-26A4-404F-A179-A9FB60B077F6';
const crash = `Process: Sample [87361]
Path: /Users/USER/Library/Developer/CoreSimulator/Devices/AC9482A2-26A4-404F-A179-A9FB60B077F6/data/Containers/Bundle/Application/9BF91EF9-F915-4745-BE91-EBA397451850/Sample.app/Sample
Identifier: Sample
Version: 1.0 (1)
Code Type: X86-64 (Native)
Parent Process: launchd_sim [70150]
Responsible: SimulatorTrampoline [1246]
User ID: 501`;
expect(shouldShowiOSCrashNotification(serial, crash)).toBe(true);
// wrong serial
expect(
shouldShowiOSCrashNotification(
'XC9482A2-26A4-404F-A179-A9FB60B077F6',
crash,
),
).toBe(false);
});

View File

@@ -0,0 +1,99 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import type {CrashLog} from 'flipper-common';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import {ServerDevice} from '../ServerDevice';
export function parseIosCrash(content: string) {
const regex = /Exception Type: *\w*/;
const arr = regex.exec(content);
const exceptionString = arr ? arr[0] : '';
const exceptionRegex = /\w*$/;
const tmp = exceptionRegex.exec(exceptionString);
const exception = tmp && tmp[0].length ? tmp[0] : 'Unknown';
const dateRegex = /Date\/Time: *[\w\s\.:-]*/;
const dateArr = dateRegex.exec(content);
const dateString = dateArr ? dateArr[0] : '';
const dateRegex2 = /[\w\s\.:-]*$/;
const tmp1 = dateRegex2.exec(dateString);
const extractedDateString: string | null =
tmp1 && tmp1[0].length ? tmp1[0] : null;
const date = extractedDateString
? new Date(extractedDateString).getTime()
: Date.now();
const crash: CrashLog = {
callstack: content,
name: exception,
reason: exception,
date,
};
return crash;
}
export function shouldShowiOSCrashNotification(
serial: string,
content: string,
): boolean {
const appPath = parsePath(content);
if (!appPath || !appPath.includes(serial)) {
// Do not show notifications for the app which are not running on this device
return false;
}
return true;
}
export function parsePath(content: string): string | null {
const regex = /(?<=.*Path: *)[^\n]*/;
const arr = regex.exec(content);
if (!arr || arr.length <= 0) {
return null;
}
const path = arr[0];
return path.trim();
}
export function addFileWatcherForiOSCrashLogs(device: ServerDevice) {
const dir = path.join(os.homedir(), 'Library', 'Logs', 'DiagnosticReports');
// eslint-disable-next-line node/no-sync
if (!fs.pathExistsSync(dir)) {
console.warn('Failed to start iOS crash watcher');
return () => {};
}
const watcher = fs.watch(dir, async (_eventType, filename) => {
// We just parse the crash logs with extension `.crash`
const checkFileExtension = /.crash$/.exec(filename);
if (!filename || !checkFileExtension) {
return;
}
const filepath = path.join(dir, filename);
const exists = await fs.pathExists(filepath);
if (!exists) {
return;
}
fs.readFile(filepath, 'utf8', function (err, data) {
if (err) {
console.warn('Failed to read crash file', err);
return;
}
if (shouldShowiOSCrashNotification(device.info.serial, data)) {
device.flipperServer.emit('device-crash', {
crash: parseIosCrash(data),
serial: device.info.serial,
});
}
});
});
return () => watcher.close();
}