Create open source LeakCanary plugin

Summary: Adds a new plugin to support [LeakCanary](https://github.com/square/leakcanary), displaying memory leaks as they are detected. Each leak shows a hierarchical path from the GC root to the leaked object, and allows inspection of these objects' fields.

Reviewed By: jknoxville

Differential Revision: D8865149

fbshipit-source-id: 99bcf216578b9d6660ead7d48b9bafe0d20a6c08
This commit is contained in:
Benjamin Pankow
2018-08-02 10:20:16 -07:00
committed by Facebook Github Bot
parent ff0b045bde
commit 1008dbb283
6 changed files with 501 additions and 1 deletions

View File

@@ -0,0 +1,224 @@
/**
* Copyright 2018-present Facebook.
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* @format
*/
import {
Panel,
FlexRow,
ElementsInspector,
FlexColumn,
ManagedDataInspector,
Sidebar,
Toolbar,
Checkbox,
SonarPlugin,
Button,
} from 'sonar';
import type {ElementID, Element} from 'sonar';
import {processLeaks} from './processLeakString';
type State = {
leaks: Leak[],
selectedIdx: ?number,
selectedEid: ?string,
showFullClassPaths: boolean,
leaksCount: number,
};
type LeakReport = {
leaks: string[],
};
export type Leak = {
title: string,
root: string,
elements: {[key: ElementID]: Element},
elementsSimple: {[key: ElementID]: Element},
instanceFields: {},
staticFields: {},
retainedSize: string,
};
const Window = FlexRow.extends({
height: '100%',
flex: 1,
});
const ToolbarItem = FlexRow.extends({
alignItems: 'center',
marginLeft: '8px',
});
export default class LeakCanary extends SonarPlugin<State> {
static title = 'LeakCanary';
static id = 'LeakCanary';
static icon = 'bird';
state = {
leaks: [],
selectedIdx: null,
selectedEid: null,
showFullClassPaths: false,
leaksCount: 0,
};
init() {
this.client.subscribe('reportLeak', (results: LeakReport) => {
// We only process new leaks instead of replacing the whole list in order
// to both avoid redundant processing and to preserve the expanded/
// collapsed state of the tree view
const newLeaks = processLeaks(results.leaks.slice(this.state.leaksCount));
let leaks = this.state.leaks;
for (let i = 0; i < newLeaks.length; i++) {
leaks.push(newLeaks[i]);
}
this.setState({
leaks: leaks,
leaksCount: results.leaks.length,
});
});
}
_clearLeaks = () => {
this.setState({
leaks: [],
leaksCount: 0,
selectedIdx: null,
selectedEid: null,
});
this.client.send('clear');
};
_selectElement = (leakIdx: number, eid: string) => {
this.setState({
selectedIdx: leakIdx,
selectedEid: eid,
});
};
_toggleElement = (leakIdx: number, eid: string) => {
const leaks = this.state.leaks;
const leak = leaks[leakIdx];
const element = leak.elements[eid];
element.expanded = !element.expanded;
const elementSimple = leak.elementsSimple[eid];
elementSimple.expanded = !elementSimple.expanded;
this.setState({
leaks: leaks,
});
};
/**
* Given a specific string value, determines what DataInspector type to treat
* it as. Ensures that numbers, bools, etc render correctly.
*/
_extractValue(
value: any,
depth: number,
): {|mutable: boolean, type: string, value: any|} {
if (!isNaN(value)) {
return {mutable: false, type: 'number', value: value};
} else if (value == 'true' || value == 'false') {
return {mutable: false, type: 'boolean', value: value};
} else if (value == 'null') {
return {mutable: false, type: 'null', value: value};
}
return {mutable: false, type: 'enum', value: value};
}
renderSidebar() {
const {selectedIdx, selectedEid, leaks} = this.state;
if (selectedIdx == null || selectedEid == null) {
return null;
}
const leak = leaks[selectedIdx];
const staticFields = leak.staticFields[selectedEid];
const instanceFields = leak.instanceFields[selectedEid];
return (
<Sidebar position="right" width={600} minWidth={300} maxWidth={900}>
<Panel heading={'Instance'} floating={false} fill={false}>
<ManagedDataInspector
data={instanceFields}
expandRoot={true}
extractValue={this._extractValue}
/>
</Panel>
<Panel heading={'Static'} floating={false} fill={false}>
<ManagedDataInspector
data={staticFields}
expandRoot={true}
extractValue={this._extractValue}
/>
</Panel>
</Sidebar>
);
}
render() {
const {selectedIdx, selectedEid, showFullClassPaths} = this.state;
const sidebar = this.renderSidebar();
return (
<Window>
<FlexColumn fill={true}>
<FlexColumn fill={true} scrollable={true}>
{this.state.leaks.map((leak: Leak, idx: number) => {
const elements = showFullClassPaths
? leak.elements
: leak.elementsSimple;
const selected = selectedIdx == idx ? selectedEid : null;
return (
<Panel
collapsable={false}
padded={false}
heading={leak.title}
floating={false}
accessory={leak.retainedSize}>
<ElementsInspector
onElementSelected={eid => {
this._selectElement(idx, eid);
}}
onElementHovered={() => {}}
onElementExpanded={(eid, deep) => {
this._toggleElement(idx, eid);
}}
onValueChanged={() => {}}
selected={selected}
searchResults={null}
root={leak.root}
elements={elements}
/>
</Panel>
);
})}
</FlexColumn>
<Toolbar>
<ToolbarItem>
<Button onClick={this._clearLeaks}>Clear</Button>
</ToolbarItem>
<ToolbarItem>
<Checkbox
checked={showFullClassPaths}
onChange={(checked: boolean) => {
this.setState({showFullClassPaths: checked});
}}
/>
Show full class path
</ToolbarItem>
</Toolbar>
</FlexColumn>
{sidebar}
</Window>
);
}
}

View File

@@ -0,0 +1,9 @@
{
"name": "sonar-plugin-leakcanary",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.5"
}
}

View File

@@ -0,0 +1,226 @@
/**
* Copyright 2018-present Facebook.
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* @format
* @flow
*/
import type {Leak} from './index.js';
import type {Element} from 'sonar';
/**
* Creates an Element (for ElementsInspector) representing a single Object in
* the path to GC root view.
*/
function getElementSimple(str: string, id: string): Element {
// Below regex can handle both older and newer versions of LeakCanary
const match = str.match(
/\* (GC ROOT )?(\u21B3 )?([a-z]* )?([^A-Z]*.)?([A-Z].*)/,
);
let name = 'N/A';
if (match) {
name = match[5];
}
return {
id: id,
name: name,
expanded: true,
children: [],
attributes: [],
data: {},
decoration: '',
extraInfo: {},
};
}
// Line marking the start of Details section
const BEGIN_DETAILS_SECTION_INDICATOR = '* Details:';
// Line following the end of the Details section
const END_DETAILS_SECTION_INDICATOR = '* Excluded Refs:';
const STATIC_PREFIX = 'static ';
// Text that begins the line of the Object at GC root
const LEAK_BEGIN_INDICATOR = 'has leaked:';
const RETAINED_SIZE_INDICATOR = '* Retaining: ';
/**
* Parses the lines given (at the given index) to extract information about both
* static and instance fields of each class in the path to GC root. Returns three
* objects, each one mapping the element ID of a specific element to the
* corresponding static fields, instance fields, or package name of the class
*/
function generateFieldsList(
lines: string[],
i: number,
): {|staticFields: {}, instanceFields: {}, packages: {}|} {
let staticFields = {};
let instanceFields = {};
let staticValues = {};
let instanceValues = {};
let elementId = -1;
let elementIdStr = String(-1);
let packages = {};
// Process everything between Details and Excluded Refs
while (
i < lines.length &&
!lines[i].startsWith(END_DETAILS_SECTION_INDICATOR)
) {
const line = lines[i];
if (line.startsWith('*')) {
if (elementId != -1) {
staticFields[elementIdStr] = staticValues;
instanceFields[elementIdStr] = instanceValues;
staticValues = {};
instanceValues = {};
}
elementId++;
elementIdStr = String(elementId);
// Extract package for each class
let pkg = 'unknown';
const match = line.match(/\* (.*)(of|Class) (.*)/);
if (match) {
pkg = match[3];
}
packages[elementIdStr] = pkg;
} else {
// Field/value pairs represented in input lines as
// | fieldName = value
const match = line.match(/\|\s+(.*) = (.*)/);
if (match) {
const fieldName = match[1];
const fieldValue = match[2];
if (fieldName.startsWith(STATIC_PREFIX)) {
const strippedFieldName = fieldName.substr(7);
staticValues[strippedFieldName] = fieldValue;
} else {
instanceValues[fieldName] = fieldValue;
}
}
}
i++;
}
staticFields[elementIdStr] = staticValues;
instanceFields[elementIdStr] = instanceValues;
return {
staticFields: staticFields,
instanceFields: instanceFields,
packages: packages,
};
}
/**
* Processes a LeakCanary string containing data from a single leak. If the
* string represents a valid leak, the function appends parsed data to the given
* output list. If not, the list is returned as-is. This parsed data contains
* the path to GC root, static/instance fields for each Object in the path, the
* leak's retained size, and a title for the leak.
*/
function processLeak(output: Leak[], leakInfo: string): Leak[] {
const lines = leakInfo.split('\n');
// Elements shows a Object's classname and package, wheras elementsSimple shows
// just its classname
let elements = {};
let elementsSimple = {};
let rootElementId = '';
let i = 0;
while (i < lines.length && !lines[i].endsWith(LEAK_BEGIN_INDICATOR)) {
i++;
}
i++;
if (i >= lines.length) {
return output;
}
let elementId = 0;
let elementIdStr = '';
while (i < lines.length && lines[i].startsWith('*')) {
const line = lines[i];
elementIdStr = String(elementId);
const prevIdStr = String(elementId - 1);
if (elementId !== 0) {
// Add element to previous element's children
elements[prevIdStr].children.push(elementIdStr);
elementsSimple[prevIdStr].children.push(elementIdStr);
} else {
rootElementId = elementIdStr;
}
elements[elementIdStr] = getElementSimple(line, elementIdStr);
elementsSimple[elementIdStr] = getElementSimple(line, elementIdStr);
i++;
elementId++;
}
// Last element is leaked object
const leakedObjName = elements[elementIdStr].name;
while (
i < lines.length &&
!lines[i].startsWith(RETAINED_SIZE_INDICATOR) &&
!lines[i].startsWith(BEGIN_DETAILS_SECTION_INDICATOR)
) {
i++;
}
let retainedSize = 'unknown size';
if (lines[i].startsWith(RETAINED_SIZE_INDICATOR)) {
const match = lines[i].match(/\* Retaining: (.*)./);
if (match) {
retainedSize = match[1];
}
}
while (
i < lines.length &&
!lines[i].startsWith(BEGIN_DETAILS_SECTION_INDICATOR)
) {
i++;
}
i++;
// Parse information on each object's fields, package
const {staticFields, instanceFields, packages} = generateFieldsList(lines, i);
// While elementsSimple remains as-is, elements has the package of each class
// inserted, in order to enable 'Show full class path'
Object.keys(packages).forEach(elementId => {
const pkg = packages[elementId];
const simpleName = elements[elementId].name;
// Gets everything before the field name, which is replaced by the package
const match = simpleName.match(/([^\. ]*)(.*)/);
if (match) {
elements[elementId].name = pkg + match[2];
}
});
output.push({
root: rootElementId,
elements: elements,
elementsSimple: elementsSimple,
staticFields: staticFields,
instanceFields: instanceFields,
title: leakedObjName,
retainedSize: retainedSize,
});
return output;
}
/**
* Processes a set of LeakCanary strings, ignoring non-leaks - see processLeak above.
*/
export function processLeaks(leakInfos: string[]): Leak[] {
const newLeaks = leakInfos.reduce(processLeak, []);
return newLeaks;
}

View File

@@ -0,0 +1,7 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
lodash@^4.17.5:
version "4.17.5"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.5.tgz#99a92d65c0272debe8c96b6057bc8fbfa3bed511"