diff --git a/docs/leak-canary-plugin.md b/docs/leak-canary-plugin.md new file mode 100644 index 000000000..a87ed1045 --- /dev/null +++ b/docs/leak-canary-plugin.md @@ -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. diff --git a/src/plugins/leak_canary/index.js b/src/plugins/leak_canary/index.js new file mode 100644 index 000000000..4f4252157 --- /dev/null +++ b/src/plugins/leak_canary/index.js @@ -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 { + 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 ( + + + + + + + + + ); + } + + render() { + const {selectedIdx, selectedEid, showFullClassPaths} = this.state; + const sidebar = this.renderSidebar(); + + return ( + + + + {this.state.leaks.map((leak: Leak, idx: number) => { + const elements = showFullClassPaths + ? leak.elements + : leak.elementsSimple; + const selected = selectedIdx == idx ? selectedEid : null; + return ( + + { + this._selectElement(idx, eid); + }} + onElementHovered={() => {}} + onElementExpanded={(eid, deep) => { + this._toggleElement(idx, eid); + }} + onValueChanged={() => {}} + selected={selected} + searchResults={null} + root={leak.root} + elements={elements} + /> + + ); + })} + + + + + + + { + this.setState({showFullClassPaths: checked}); + }} + /> + Show full class path + + + + {sidebar} + + ); + } +} diff --git a/src/plugins/leak_canary/package.json b/src/plugins/leak_canary/package.json new file mode 100644 index 000000000..4420350a1 --- /dev/null +++ b/src/plugins/leak_canary/package.json @@ -0,0 +1,9 @@ +{ + "name": "sonar-plugin-leakcanary", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.5" + } +} diff --git a/src/plugins/leak_canary/processLeakString.js b/src/plugins/leak_canary/processLeakString.js new file mode 100644 index 000000000..0163ca8d8 --- /dev/null +++ b/src/plugins/leak_canary/processLeakString.js @@ -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; +} diff --git a/src/plugins/leak_canary/yarn.lock b/src/plugins/leak_canary/yarn.lock new file mode 100644 index 000000000..560de8e50 --- /dev/null +++ b/src/plugins/leak_canary/yarn.lock @@ -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" diff --git a/website/sidebars.json b/website/sidebars.json index d661a9569..89afcf3b3 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -6,7 +6,8 @@ "layout-plugin", "network-plugin", "sandbox-plugin", - "shared-preferences-plugin" + "shared-preferences-plugin", + "leak-canary-plugin" ], "Plugins: Desktop part": [ "js-setup",