Skip to content

Commit

Permalink
Support symbolicating allocation stacks in Chrome heap snapshots
Browse files Browse the repository at this point in the history
Summary:
Adds support for symbolicating allocation stacks in Chrome-formatted heap snapshot (specifically, heap timeline) files.

Other data in the heap snapshot remains untouched, in particular the `locations` array - since `locations` doesn't expose the unsymbolicated file names (which we need to e.g. extract segment IDs) and at any rate isn't exposed in the Chrome DevTools UI when loading a snapshot from a file.

Reviewed By: MichaReiser

Differential Revision: D26022945

fbshipit-source-id: ceff6ee875d170e3af524bf3032d7dac5db2ff52
  • Loading branch information
motiz88 authored and facebook-github-bot committed Jan 28, 2021
1 parent cb542c0 commit 6b0a0cb
Show file tree
Hide file tree
Showing 7 changed files with 252 additions and 7 deletions.
59 changes: 59 additions & 0 deletions packages/metro-symbolicate/src/Symbolication.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ const invariant = require('invariant');
const nullthrows = require('nullthrows');
const path = require('path');

const {ChromeHeapSnapshotProcessor} = require('./ChromeHeapSnapshot');

import type {ChromeHeapSnapshot} from './ChromeHeapSnapshot';
import type {MixedSourceMap, HermesFunctionOffsets} from 'metro-source-map';
// flowlint-next-line untyped-type-import:off
import {typeof SourceMapConsumer} from 'source-map';
Expand Down Expand Up @@ -380,6 +383,62 @@ class SymbolicationContext<ModuleIdsT> {
throw new Error('Not implemented');
}

/**
* Symbolicates heap alloction stacks in a Chrome-formatted heap
* snapshot/timeline.
* Line and column offsets in options (both input and output) are _ignored_,
* because this format has a well-defined convention (1-based lines and
* columns).
*/
symbolicateHeapSnapshot(
snapshotContents: string | ChromeHeapSnapshot,
): ChromeHeapSnapshot {
const snapshotData: ChromeHeapSnapshot =
typeof snapshotContents === 'string'
? JSON.parse(snapshotContents)
: snapshotContents;
const processor = new ChromeHeapSnapshotProcessor(snapshotData);
for (const frame of processor.traceFunctionInfos()) {
const moduleIds = this.parseFileName(frame.getString('script_name'));
const generatedLine = frame.getNumber('line');
const generatedColumn = frame.getNumber('column');
if (generatedLine === 0 && generatedColumn === 0) {
continue;
}
const {
line: originalLine,
column: originalColumn,
source: originalSource,
functionName: originalFunctionName,
} = this.getOriginalPositionDetailsFor(
frame.getNumber('line') - 1 + this.options.inputLineStart,
frame.getNumber('column') - 1 + this.options.inputColumnStart,
moduleIds,
);
if (originalSource != null) {
frame.setString('script_name', originalSource);
if (originalLine != null) {
frame.setNumber(
'line',
originalLine - this.options.outputLineStart + 1,
);
} else {
frame.setNumber('line', 0);
}
if (originalColumn != null) {
frame.setNumber(
'column',
originalColumn - this.options.outputColumnStart + 1,
);
} else {
frame.setNumber('column', 0);
}
}
frame.setString('name', originalFunctionName ?? frame.getString('name'));
}
return snapshotData;
}

/*
* An internal helper function similar to getOriginalPositionFor. This one
* returns both `name` and `functionName` fields so callers can distinguish the
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`heap snapshots/timelines symbolicating allocation stacks: symbolicated 1`] = `
"<global> @ /js/RKJSModules/Apps/GenSampleHeapSnapshot/GenSampleHeapSnapshot.js:10:20
loadModuleImplementation @ /js/node_modules/metro-runtime/src/polyfills/require.js:409:12
metroRequire @ /js/node_modules/metro-runtime/src/polyfills/require.js:193:20
metroRequire @ /js/node_modules/metro-runtime/src/polyfills/require.js:193:20
metroImportDefault @ /js/node_modules/metro-runtime/src/polyfills/require.js:212:31
<global> @ /js/RKJSModules/EntryPoints/GenSampleHeapSnapshotBundle.js:12:1
loadModuleImplementation @ /js/node_modules/metro-runtime/src/polyfills/require.js:409:12
guardedLoadModule @ /js/node_modules/metro-runtime/src/polyfills/require.js:271:45
metroRequire @ /js/node_modules/metro-runtime/src/polyfills/require.js:193:20
global @ ./GenSampleHeapSnapshotBundle.js:26:4
global @ ./GenSampleHeapSnapshotBundle.js:1:1
(root) @ :0:0"
`;
exports[`heap snapshots/timelines symbolicating allocation stacks: unsymbolicated 1`] = `
"(anonymous) @ ./GenSampleHeapSnapshotBundle.js:25:231
loadModuleImplementation @ ./GenSampleHeapSnapshotBundle.js:10:6144
metroRequire @ ./GenSampleHeapSnapshotBundle.js:10:2060
metroRequire @ ./GenSampleHeapSnapshotBundle.js:10:2060
metroImportDefault @ ./GenSampleHeapSnapshotBundle.js:10:2452
(anonymous) @ ./GenSampleHeapSnapshotBundle.js:15:139
loadModuleImplementation @ ./GenSampleHeapSnapshotBundle.js:10:6144
guardedLoadModule @ ./GenSampleHeapSnapshotBundle.js:10:3528
metroRequire @ ./GenSampleHeapSnapshotBundle.js:10:2060
global @ ./GenSampleHeapSnapshotBundle.js:26:4
global @ ./GenSampleHeapSnapshotBundle.js:1:1
(root) @ :0:0"
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails oncall+js_symbolication
* @format
* @flow strict-local
*/

'use strict';

const fs = require('fs');
const path = require('path');
const symbolicate = require('../symbolicate');

const {ChromeHeapSnapshotProcessor} = require('../ChromeHeapSnapshot');
const {PassThrough} = require('stream');
const resolve = fileName => path.resolve(__dirname, '__fixtures__', fileName);
const read = fileName => fs.readFileSync(resolve(fileName), 'utf8');

const execute = async (
args: Array<string>,
stdin?: string,
): Promise<string> => {
const streams = {
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr: new PassThrough(),
};
const stdout = [];
const errorMessage = ['Process failed with the following output:\n======\n'];
streams.stdout.on('data', data => {
errorMessage.push(data);
stdout.push(data);
});
streams.stderr.on('data', data => {
errorMessage.push(data);
});
if (stdin != null) {
streams.stdin.write(stdin);
streams.stdin.end();
}
const code = await symbolicate(args, streams);

if (code !== 0) {
errorMessage.push('======\n');
throw new Error(errorMessage.join(''));
}
return stdout.join('');
};

describe('heap snapshots/timelines', () => {
test('symbolicating allocation stacks', async () => {
function findKnownAllocationStack(heapSnapshotStr) {
const rawData = JSON.parse(heapSnapshotStr);
const data = new ChromeHeapSnapshotProcessor(rawData);
const node = findObjectByInboundProperty('RETAIN_ME', data, rawData);
return getStackTrace(node.getNumber('trace_node_id'), data);
}

const symbolicated = await execute([
resolve('GenSampleHeapSnapshotBundle.js.map'),
resolve('GenSampleHeapSnapshotBundle.js.heaptimeline'),
]);

// Snapshot the original unsymbolicated trace for easy comparison
const unsymbolicated = read('GenSampleHeapSnapshotBundle.js.heaptimeline');
expect(findKnownAllocationStack(unsymbolicated)).toMatchSnapshot(
'unsymbolicated',
);
expect(findKnownAllocationStack(symbolicated)).toMatchSnapshot(
'symbolicated',
);
});
});

// Returns a node in the heap snapshot that has an incoming property edge with
// the name passed as `propertyName`.
function findObjectByInboundProperty(propertyName, data, rawData) {
const sigilStrIndex = rawData.strings.indexOf(propertyName);
for (const edge of data.edges()) {
if (
edge.getNumber('name_or_index') === sigilStrIndex &&
edge.getString('type') === 'property'
) {
const nodeIt = data.nodes();
nodeIt.moveToRecord(
edge.getNumber('to_node') / rawData.snapshot.meta.node_fields.length,
);
return nodeIt;
}
}
throw new Error(
`Could not find an object with an inbound property edge '${propertyName}'`,
);
}

// Find a given trace node in the trace tree and record the path from the root
// (reversed and translated into readable stack frames).
function getStackTrace(traceNodeId, data) {
const functionInfoStack = [];
const FOUND = Symbol('FOUND');

function visit(traceNode) {
functionInfoStack.push(traceNode.getNumber('function_info_index'));
if (traceNode.getNumber('id') === traceNodeId) {
throw FOUND;
}
for (const child of traceNode.getChildren('children')) {
visit(child);
}
functionInfoStack.pop();
}

for (const traceRoot of data.traceTree()) {
try {
visit(traceRoot);
} catch (e) {
if (e === FOUND) {
break;
}
throw e;
}
}

const frameIt = data.traceFunctionInfos();
return functionInfoStack
.reverse()
.map(index => {
frameIt.moveToRecord(index);
const name = frameIt.getString('name');
const scriptName = frameIt.getString('script_name');
const line = frameIt.getNumber('line');
const column = frameIt.getNumber('column');
return `${name} @ ${scriptName}:${line}:${column}`;
})
.join('\n');
}
18 changes: 11 additions & 7 deletions packages/metro-symbolicate/src/__tests__/symbolicate-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*
* @emails oncall+js_symbolication
* @format
* @flow strict-local
*/

'use strict';
Expand All @@ -18,30 +19,33 @@ const {PassThrough} = require('stream');
const resolve = fileName => path.resolve(__dirname, '__fixtures__', fileName);
const read = fileName => fs.readFileSync(resolve(fileName), 'utf8');

const execute = async (args: Array<string>, stdin: string): Promise<string> => {
const execute = async (
args: Array<string>,
stdin?: string,
): Promise<string> => {
const streams = {
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr: new PassThrough(),
};
const stdout = [];
const output = ['Process failed with the following output:\n======\n'];
const errorMessage = ['Process failed with the following output:\n======\n'];
streams.stdout.on('data', data => {
output.push(data);
errorMessage.push(data);
stdout.push(data);
});
streams.stderr.on('data', data => {
output.push(data);
errorMessage.push(data);
});
if (stdin) {
if (stdin != null) {
streams.stdin.write(stdin);
streams.stdin.end();
}
const code = await symbolicate(args, streams);

if (code !== 0) {
output.push('======\n');
throw new Error(output.join(''));
errorMessage.push('======\n');
throw new Error(errorMessage.join(''));
}
return stdout.join('');
};
Expand Down
9 changes: 9 additions & 0 deletions packages/metro-symbolicate/src/symbolicate.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,15 @@ async function main(
}
} else if (argv[0].endsWith('.profmap')) {
stdout.write(context.symbolicateProfilerMap(argv[0]));
} else if (
argv[0].endsWith('.heapsnapshot') ||
argv[0].endsWith('.heaptimeline')
) {
stdout.write(
JSON.stringify(
context.symbolicateHeapSnapshot(fs.readFileSync(argv[0], 'utf8')),
),
);
} else if (argv[0] === '--attribution') {
let buffer = '';
await waitForStream(
Expand Down

0 comments on commit 6b0a0cb

Please sign in to comment.