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,33 @@
---
id: leak-canary-plugin
title: LeakCanary
---
The LeakCanary plugin provides developers with Sonar support for [LeakCanary](https://github.com/square/leakcanary), an open source memory leak detection library.
## Setup
Note: this plugin is only available for Android.
### Android
First, add the plugin to your Sonar client instance:
```java
import com.facebook.sonar.plugins.leakcanary.LeakCanarySonarPlugin;
client.addPlugin(new LeakCanarySonarPlugin());
```
Next, build a custom RefWatcher using RecordLeakService: (see [LeakCanary docs](https://github.com/square/leakcanary/wiki/Customizing-LeakCanary#uploading-to-a-server) for more information on RefWatcher)
```java
import com.facebook.sonar.plugins.leakcanary.RecordLeakService;
RefWatcher refWatcher = LeakCanary.refWatcher(this)
.listenerServiceClass(RecordLeakService.class);
.buildAndInstall();
```
## Usage
Leaks detected by LeakCanary will appear automatically in Sonar. Each leak will display a hierarchy of objects, beginning from the garbage collector root and ending at the leaked class.
Selecting any object in this list will display contents of the object's various fields.

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"

View File

@@ -6,7 +6,8 @@
"layout-plugin",
"network-plugin",
"sandbox-plugin",
"shared-preferences-plugin"
"shared-preferences-plugin",
"leak-canary-plugin"
],
"Plugins: Desktop part": [
"js-setup",