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:
committed by
Facebook Github Bot
parent
ff0b045bde
commit
1008dbb283
33
docs/leak-canary-plugin.md
Normal file
33
docs/leak-canary-plugin.md
Normal 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.
|
||||||
224
src/plugins/leak_canary/index.js
Normal file
224
src/plugins/leak_canary/index.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/plugins/leak_canary/package.json
Normal file
9
src/plugins/leak_canary/package.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "sonar-plugin-leakcanary",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash": "^4.17.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
226
src/plugins/leak_canary/processLeakString.js
Normal file
226
src/plugins/leak_canary/processLeakString.js
Normal 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;
|
||||||
|
}
|
||||||
7
src/plugins/leak_canary/yarn.lock
Normal file
7
src/plugins/leak_canary/yarn.lock
Normal 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"
|
||||||
@@ -6,7 +6,8 @@
|
|||||||
"layout-plugin",
|
"layout-plugin",
|
||||||
"network-plugin",
|
"network-plugin",
|
||||||
"sandbox-plugin",
|
"sandbox-plugin",
|
||||||
"shared-preferences-plugin"
|
"shared-preferences-plugin",
|
||||||
|
"leak-canary-plugin"
|
||||||
],
|
],
|
||||||
"Plugins: Desktop part": [
|
"Plugins: Desktop part": [
|
||||||
"js-setup",
|
"js-setup",
|
||||||
|
|||||||
Reference in New Issue
Block a user