Skip to content

Commit

Permalink
feat: track stacktraces for leaking collections
Browse files Browse the repository at this point in the history
Fixes #14
  • Loading branch information
nolanlawson committed Dec 28, 2021
1 parent fb21adb commit 47fc8fd
Show file tree
Hide file tree
Showing 20 changed files with 571 additions and 108 deletions.
38 changes: 14 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,37 +349,27 @@ Use the `--output` command to output a JSON file, which will contain a list of e

**How do I debug leaking collections?**

Figuring out why an Array or Object is continually growing may be tricky. First, run `fuite` in debug mode:
`fuite` will analyze your leaking collections and print out a stacktrace of which code caused the increase –
for instance, `push`ing to an Array, or `set`ing a Map. So this is the first place to look.

NODE_OPTIONS=--inspect-brk fuite https://example.com --debug
If you have sourcemaps, it will show the original source. Otherwise, it'll show the original stacktrace.

Then open `chrome:inspect` in Chrome and click "Open dedicated DevTools for Node." Then, when the breakpoint is hit, open the DevTools in Chrome and click the "Play" button to let the scenario keep running.
Sometimes more than one thing is increasing the size, and not every increase is at fault (e.g. it deletes right after).
In those cases, you should use `--output` and look at the JSON output to see the full list of stacktraces.

Eventually `fuite` will give you a breakpoint in the Chrome DevTools itself, where you have access to the leaking collection (Array, Map, etc.) and can inspect it.
In some other cases, `fuite` is not able to track increases to collections. (E.g. the object disallows modifications, or the code uses `Array.prototype.push.call()` instead of `.push()`ing directly.)

One technique is to override the object's methods to check whenever it's called:
In those cases, you may have to do a manual analysis. Below is how you can do that.

```js
for (const prop of ['push', 'concat', 'unshift', 'splice']) {
const original = obj[prop]; // `obj` is the array
obj[prop] = function () {
debugger;
return original.apply(this, arguments);
};
}
```
First, run `fuite` in debug mode:

NODE_OPTIONS=--inspect-brk fuite https://example.com --debug

For Maps you can override `set`, and for Sets you can override `add`. For plain Objects, you'll need a slightly more elaborate solution:
Then open `chrome:inspect` in Chrome and click "Open dedicated DevTools for Node." Then, when the breakpoint is hit, open the DevTools in Chromium (the one running your website) and click the "Play" button to let the scenario keep running.

```js
// `obj` is the plain object
Object.setPrototypeOf(obj, new Proxy(Object.create(null), {
set (obj, prop, val) {
debugger;
return (obj[prop] = val);
}
}))
```
Eventually `fuite` will give you a breakpoint in the Chrome DevTools itself, where you have access to the leaking collection (Array, Map, etc.) and can inspect it.

It will also give you `debugger` breakpoints on when the collection is increasing (e.g. `push`, `set`, etc.). For plain objects, it tries to override the prototype and spy on setters to accomplish this.

Note that not every leaking collection is a serious memory leak: for instance, your router may keep some metadata about past routes in an ever-growing stack. Or your analytics library may store some timings in an array that continually grows. These are generally not a concern unless the objects are huge, or contain closures that reference lots of memory.

Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,23 @@
"lint": "standard"
},
"dependencies": {
"as-table": "^1.0.55",
"chalk": "^5.0.0",
"commander": "^8.3.0",
"crypto-random-string": "^4.0.0",
"exit-hook": "^3.0.0",
"markdown-table": "^3.0.2",
"node-abort-controller": "^3.0.1",
"node-fetch": "^3.1.0",
"ono": "^7.1.3",
"ora": "^6.0.1",
"please-upgrade-node": "^3.2.0",
"pretty-bytes": "^5.6.0",
"puppeteer": "^12.0.1",
"quick-lru": "^6.0.2",
"source-map": "^0.7.3",
"source-map-resolve": "^0.6.0",
"stacktrace-parser": "^0.1.10",
"table": "^6.7.5",
"temp-dir": "^2.0.0",
"uuid": "^8.3.2"
},
Expand Down
2 changes: 1 addition & 1 deletion src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ ${chalk.blue('Output')} : ${outputFilename}
let numResults = 0
for await (const result of findLeaksIterable) {
numResults++
console.log(formatResult(result))
console.log(await formatResult(result))
console.log('\n' + '-'.repeat(20) + '\n')
if (result.leaks && result.leaks.detected) {
leaksDetected = true
Expand Down
123 changes: 105 additions & 18 deletions src/collections.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { sortBy } from './util.js'
import { prettifyStacktrace } from './prettifyStacktrace.js'

export async function startTrackingCollections (page) {
// The basic idea for this comes from
Expand All @@ -9,9 +10,9 @@ export async function startTrackingCollections (page) {
const objects = await page.queryObjects(
prototype
)
const weakMap = await page.evaluateHandle(() => new WeakMap())
const collectionsToCountsMap = await page.evaluateHandle(() => new WeakMap())
await page.evaluate(
(objects, weakMap) => {
(objects, collectionsToCountsMap) => {
const { hasOwnProperty, toString } = Object.prototype
const { isArray } = Array

Expand Down Expand Up @@ -64,39 +65,42 @@ export async function startTrackingCollections (page) {
for (const obj of objects) {
if (obj instanceof Map || obj instanceof Set || Array.isArray(obj) || isPlainObject(obj)) {
const size = getSize(obj)
weakMap.set(obj, size)
collectionsToCountsMap.set(obj, size)
}
}
},
objects,
weakMap
collectionsToCountsMap
)

await Promise.all([prototype.dispose(), objects.dispose()])
return weakMap
return collectionsToCountsMap
}

export async function findLeakingCollections (page, weakMap, numIterations, debug) {
export async function findLeakingCollections (page, collectionsToCountsMap, numIterations, debug) {
const prototype = await page.evaluateHandle(() => {
return Object.prototype
})
const objects = await page.queryObjects(
prototype
)

// Test if the weakMap is still available
// Test if the collectionsToCountsMap is still available
try {
await page.evaluate(() => {
// no-op
}, weakMap)
}, collectionsToCountsMap)
} catch (err) {
if (err.message.includes('JSHandles can be evaluated only in the context they were created')) {
return [] // multi-page app, not single-page app
// TODO: exception logging
// Assume this is a multi-page app, not a single-page app
return {
collections: []
}
throw err
}

const leakingCollections = await page.evaluate((objects, weakMap, numIterations, debug) => {
const trackedStacktraces = await page.evaluateHandle(() => ([]))

const leakingCollections = await page.evaluate((objects, collectionsToCountsMap, trackedStacktraces, numIterations, debug) => {
const { isArray } = Array
function getSize (obj) {
try {
Expand Down Expand Up @@ -177,10 +181,49 @@ export async function findLeakingCollections (page, weakMap, numIterations, debu
}
return `{${createPreviewOfFirstItem(obj)}, ...}`
}
function trackMethods (obj, stacktraces, methods) {
for (const method of methods) {
const oldMethod = obj[method]
obj[method] = function () {
if (method !== 'splice' || (arguments.length > 2)) { // splice is only an addition if args.length > 2
if (debug) {
// Detected someone adding to a collection that is suspected to leak
debugger // eslint-disable-line no-debugger
}
stacktraces.push(new Error().stack)
}
return oldMethod.apply(this, arguments)
}
}
}
function trackPlainObject (obj, stacktraces) {
Object.setPrototypeOf(obj, new Proxy(Object.create(null), {
set (obj, prop, val) {
if (debug) {
// Detected someone adding to a collection that is suspected to leak
debugger // eslint-disable-line no-debugger
}
stacktraces.push(new Error().stack)
return (obj[prop] = val)
}
}))
}
function trackSizeIncreases (obj, stacktraces) {
if (obj instanceof Map) {
trackMethods(obj, stacktraces, ['set'])
} else if (obj instanceof Set) {
trackMethods(obj, stacktraces, ['add'])
} else if (isArray(obj)) {
trackMethods(obj, stacktraces, ['push', 'concat', 'splice', 'unshift'])
} else { // plain object
trackPlainObject(obj, stacktraces)
}
}
const result = []
let id = 0
for (const obj of objects) {
if (weakMap.has(obj)) {
const sizeBefore = weakMap.get(obj)
if (collectionsToCountsMap.has(obj)) {
const sizeBefore = collectionsToCountsMap.get(obj)
const sizeAfter = getSize(obj)
const delta = sizeAfter - sizeBefore
if (delta % numIterations === 0 && delta > 0) {
Expand All @@ -189,27 +232,71 @@ export async function findLeakingCollections (page, weakMap, numIterations, debu
}
const type = getType(obj)
const preview = createPreview(obj)
result.push({
const details = {
id: id++,
type,
sizeBefore,
sizeAfter,
delta,
deltaPerIteration: delta / numIterations,
preview
}
result.push(details)
const stacktraces = []
trackedStacktraces.push({
id: details.id,
stacktraces
})

try {
trackSizeIncreases(obj, stacktraces)
} catch (err) {
// ignore if this doesn't work for any reason
}
}
}
}

return result
},
objects,
weakMap,
collectionsToCountsMap,
trackedStacktraces,
numIterations,
debug
)

await Promise.all([prototype.dispose(), objects.dispose(), weakMap.dispose()])
await Promise.all([prototype.dispose(), objects.dispose(), collectionsToCountsMap.dispose()])

const collections = sortBy(leakingCollections, ['type', 'delta'])
return {
collections,
trackedStacktraces
}
}

export async function augmentLeakingCollectionsWithStacktraces (page, collections, trackedStacktraces) {
const trackedStacktracesArray = await page.evaluate((trackedStacktraces) => {
return trackedStacktraces
}, trackedStacktraces)

const idsToStacktraces = Object.fromEntries(trackedStacktracesArray.map(({ id, stacktraces }) => ([id, stacktraces])))

return sortBy(leakingCollections, ['type', 'delta'])
return (await Promise.all(collections.map(async collection => {
const res = { ...collection }
if (collection.id in idsToStacktraces) {
const stacktraces = idsToStacktraces[collection.id]
res.stacktraces = await Promise.all(stacktraces.map(async original => {
let pretty
try {
pretty = await prettifyStacktrace(original)
} catch (err) {
// ignore if this prettification fails for any reason
// TODO: log errors
}
return { original, pretty }
}))
}
return res
})))
}
36 changes: 24 additions & 12 deletions src/format.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import chalk from 'chalk'
import prettyBytes from 'pretty-bytes'
import { markdownTable } from 'markdown-table'
import { table } from 'table'

function formatStacktraces (stacktraces) {
if (!stacktraces || !stacktraces.length) {
return ''
}
// just show a preview of the stacktraces, the first one is good enough
const [stacktrace] = stacktraces
const { original, pretty } = stacktrace
return pretty || original || ''
}

function formatLeakingObjects (objects) {
const tableData = [[
Expand All @@ -19,7 +29,7 @@ function formatLeakingObjects (objects) {
return `
Leaking objects:
${markdownTable(tableData)}
${table(tableData)}
`.trim() + '\n\n'
}

Expand Down Expand Up @@ -48,7 +58,7 @@ function formatLeakingEventListeners (listenerSummaries, eventListenersSummary)
return `
Leaking event listeners (+${eventListenersSummary.deltaPerIteration} total):
${markdownTable(tableData)}
${table(tableData)}
`.trim() + '\n\n'
}

Expand All @@ -72,32 +82,34 @@ function formatLeakingDomNodes (domNodes) {
return `
Leaking DOM nodes (+${domNodes.deltaPerIteration} total):
${markdownTable(tableData)}
${table(tableData)}
`.trim() + '\n\n'
}

function formatLeakingCollections (leakingCollections) {
const tableData = [[
'Collection type',
'Size increase',
'Preview'
'Type',
'Change',
'Preview',
'Size increased at'
]]

for (const { type, deltaPerIteration, preview } of leakingCollections) {
for (const { type, deltaPerIteration, preview, stacktraces } of leakingCollections) {
tableData.push([
type,
deltaPerIteration,
preview
`+${deltaPerIteration}`,
preview,
formatStacktraces(stacktraces)
])
}
return `
Leaking collections:
${markdownTable(tableData)}
${table(tableData)}
`.trim() + '\n\n'
}

export function formatResult ({ test, result }) {
export async function formatResult ({ test, result }) {
let str = ''

str += `Test : ${chalk.blue(test.description)}\n`
Expand Down
Loading

0 comments on commit 47fc8fd

Please sign in to comment.