618 lines
21 KiB
Plaintext
618 lines
21 KiB
Plaintext
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @flow strict
|
|
* @format
|
|
* @oncall react_native
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const invariant = require('invariant');
|
|
|
|
type RawBuffer = Array<number | RawBuffer>;
|
|
|
|
export type ChromeHeapSnapshot = {
|
|
snapshot: {
|
|
meta: {
|
|
trace_function_info_fields: Array<string>,
|
|
location_fields: Array<string>,
|
|
edge_fields: Array<string>,
|
|
edge_types: Array<string | Array<string>>,
|
|
node_fields: Array<string>,
|
|
node_types: Array<string | Array<string>>,
|
|
trace_node_fields: Array<string>,
|
|
...
|
|
},
|
|
...
|
|
},
|
|
trace_function_infos: Array<number>,
|
|
locations: Array<number>,
|
|
edges: Array<number>,
|
|
nodes: Array<number>,
|
|
strings: Array<string>,
|
|
trace_tree: RawBuffer,
|
|
...
|
|
};
|
|
|
|
// The snapshot metadata doesn't have a type describing the `children` field
|
|
// of `trace_tree`, but modeling it as a type works really well. So we make up
|
|
// our own name for it and use that internally.
|
|
const CHILDREN_FIELD_TYPE = '__CHILDREN__';
|
|
|
|
// An adapter for reading and mutating a Chrome heap snapshot in-place,
|
|
// including safely decoding and encoding fields that point into the global
|
|
// string table and into enum types.
|
|
// Care is taken to adhere to the self-describing heap snapshot schema, but
|
|
// we make some additional assumptions based on what Chrome hardcodes (where
|
|
// the format leaves us no other choice).
|
|
class ChromeHeapSnapshotProcessor {
|
|
// The raw snapshot data provided to this processor. Mutable.
|
|
+_snapshotData: ChromeHeapSnapshot;
|
|
|
|
// An adapter for the global string table in the raw snapshot data.
|
|
// This is shared across all the iterators we will create.
|
|
+_globalStringTable: ChromeHeapSnapshotStringTable;
|
|
|
|
constructor(snapshotData: ChromeHeapSnapshot) {
|
|
this._snapshotData = snapshotData;
|
|
this._globalStringTable = new ChromeHeapSnapshotStringTable(
|
|
this._snapshotData.strings,
|
|
);
|
|
}
|
|
|
|
traceFunctionInfos(): ChromeHeapSnapshotRecordIterator {
|
|
return new ChromeHeapSnapshotRecordIterator(
|
|
// Flow is being conservative here, but we'll never change a number into RawBuffer or vice versa.
|
|
// $FlowIgnore[incompatible-call]
|
|
this._snapshotData.trace_function_infos,
|
|
this._snapshotData.snapshot.meta.trace_function_info_fields,
|
|
{name: 'string', script_name: 'string'},
|
|
this._globalStringTable,
|
|
undefined /* start position */,
|
|
);
|
|
}
|
|
|
|
locations(): ChromeHeapSnapshotRecordIterator {
|
|
return new ChromeHeapSnapshotRecordIterator(
|
|
// Flow is being conservative here, but we'll never change a number into RawBuffer or vice versa.
|
|
// $FlowIgnore[incompatible-call]
|
|
this._snapshotData.locations,
|
|
this._snapshotData.snapshot.meta.location_fields,
|
|
null,
|
|
this._globalStringTable,
|
|
undefined /* start position */,
|
|
);
|
|
}
|
|
|
|
nodes(): ChromeHeapSnapshotRecordIterator {
|
|
return new ChromeHeapSnapshotRecordIterator(
|
|
// Flow is being conservative here, but we'll never change a number into RawBuffer or vice versa.
|
|
// $FlowIgnore[incompatible-call]
|
|
this._snapshotData.nodes,
|
|
this._snapshotData.snapshot.meta.node_fields,
|
|
this._snapshotData.snapshot.meta.node_types,
|
|
this._globalStringTable,
|
|
undefined /* start position */,
|
|
);
|
|
}
|
|
|
|
edges(): ChromeHeapSnapshotRecordIterator {
|
|
return new ChromeHeapSnapshotRecordIterator(
|
|
// Flow is being conservative here, but we'll never change a number into RawBuffer or vice versa.
|
|
// $FlowIgnore[incompatible-call]
|
|
this._snapshotData.edges,
|
|
this._snapshotData.snapshot.meta.edge_fields,
|
|
this._snapshotData.snapshot.meta.edge_types,
|
|
this._globalStringTable,
|
|
undefined /* start position */,
|
|
);
|
|
}
|
|
|
|
traceTree(): ChromeHeapSnapshotRecordIterator {
|
|
return new ChromeHeapSnapshotRecordIterator(
|
|
this._snapshotData.trace_tree,
|
|
this._snapshotData.snapshot.meta.trace_node_fields,
|
|
{children: CHILDREN_FIELD_TYPE},
|
|
this._globalStringTable,
|
|
undefined /* start position */,
|
|
);
|
|
}
|
|
}
|
|
|
|
// An uniquing adapter for the heap snapshot's string table that allows
|
|
// retrieving and adding strings.
|
|
//
|
|
// Assumptions:
|
|
// 1. The string table is only manipulated via this class, and only via a
|
|
// single instance of it.
|
|
// 2. The string table array is always mutated in-place rather than being
|
|
// copied / replaced with a new array in its containing object.
|
|
class ChromeHeapSnapshotStringTable {
|
|
+_strings: Array<string>;
|
|
+_indexCache: Map<string, number>;
|
|
|
|
constructor(strings: Array<string>) {
|
|
this._strings = strings;
|
|
this._indexCache = new Map();
|
|
// NOTE: _indexCache is lazily initialised in _syncIndexCache.
|
|
}
|
|
|
|
// Looks up a string in the string table, adds it if necessary, and returns
|
|
// its index.
|
|
add(value: string): number {
|
|
this._syncIndexCache();
|
|
let index = this._indexCache.get(value);
|
|
if (index != null) {
|
|
return index;
|
|
}
|
|
index = this._strings.length;
|
|
this._strings.push(value);
|
|
this._indexCache.set(value, index);
|
|
return index;
|
|
}
|
|
|
|
// Retrieve the string at the given index.
|
|
get(index: number): string {
|
|
invariant(
|
|
index >= 0 && index < this._strings.length,
|
|
'index out of string table range',
|
|
);
|
|
return this._strings[index];
|
|
}
|
|
|
|
// Indexes the string table for fast lookup.
|
|
_syncIndexCache() {
|
|
// Because we only grow the string table and we assume it's unique to begin
|
|
// with, we only need to scan any strings that we may have appended since
|
|
// the last time we synced the index.
|
|
// NOTE: This is not even strictly necessary other than for the very first
|
|
// add() call, but it might allow us to do more complicated string table
|
|
// manipulation down the line.
|
|
if (this._strings.length > this._indexCache.size) {
|
|
for (let i = this._indexCache.size; i < this._strings.length; ++i) {
|
|
this._indexCache.set(this._strings[i], i);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
type ChromeHeapSnapshotFieldType =
|
|
// enum
|
|
| Array<string>
|
|
// type name
|
|
| string;
|
|
|
|
// The input type to functions that accept record objects.
|
|
type DenormalizedRecordInput = $ReadOnly<{
|
|
[field: string]: string | number | $ReadOnlyArray<DenormalizedRecordInput>,
|
|
}>;
|
|
|
|
// A cursor pointing to a record-aligned position in a 1D array of N records
|
|
// each with K fields in a fixed order. Supports encoding/decoding field values
|
|
// in the raw array according to a schema passed to the constructor.
|
|
//
|
|
// Field values are stored as either numbers (representing scalars) or arrays
|
|
// (representing lists of nested records). Scalar fields may represent strings
|
|
// in the string table, strings in an enum, or numbers. Nested record lists are
|
|
// processed according to the same schema as their parent record.
|
|
//
|
|
// Setters directly mutate raw data in the buffer and in the string table.
|
|
class ChromeHeapSnapshotRecordAccessor {
|
|
// Fast lookup tables from field names to their offsets (required) and types
|
|
// (optional). These are shared with any child iterators.
|
|
+_fieldToOffset: $ReadOnlyMap<string, number>;
|
|
+_fieldToType: $ReadOnlyMap<string, ChromeHeapSnapshotFieldType>;
|
|
|
|
// The number of fields in every record (i.e. K).
|
|
+_recordSize: number;
|
|
|
|
// The raw buffer. Mutable.
|
|
+_buffer: RawBuffer;
|
|
|
|
// The global string table. Mutable in the ways allowed by the string table
|
|
// class.
|
|
+_globalStringTable: ChromeHeapSnapshotStringTable;
|
|
|
|
// The current position in the raw buffer.
|
|
_position: number;
|
|
|
|
constructor(
|
|
buffer: RawBuffer,
|
|
recordFields: Array<string>,
|
|
// recordTypes can be:
|
|
// 1. An array: Field types as described in the snapshot itself, e.g.
|
|
// node_types, edge_types.
|
|
// 2. An object: Field types that are implicit (hardcoded in V8 / DevTools)
|
|
// so we pass them in by field name.
|
|
// 3. null: No field types are known.
|
|
// Fields with unknown types are assumed to be numeric.
|
|
recordTypes:
|
|
| Array<ChromeHeapSnapshotFieldType>
|
|
| $ReadOnly<{
|
|
[string]: ChromeHeapSnapshotFieldType,
|
|
}>
|
|
| null,
|
|
globalStringTable: ChromeHeapSnapshotStringTable,
|
|
position: number,
|
|
parent?: ChromeHeapSnapshotRecordAccessor,
|
|
) {
|
|
if (parent) {
|
|
this._recordSize = parent._recordSize;
|
|
this._fieldToOffset = parent._fieldToOffset;
|
|
this._fieldToType = parent._fieldToType;
|
|
} else {
|
|
this._recordSize = recordFields.length;
|
|
this._fieldToOffset = new Map(
|
|
// $FlowFixMe[not-an-object]
|
|
Object.entries(recordFields).map(([offsetStr, name]) => [
|
|
String(name),
|
|
Number(offsetStr),
|
|
]),
|
|
);
|
|
if (Array.isArray(recordTypes)) {
|
|
this._fieldToType = new Map<string, ChromeHeapSnapshotFieldType>(
|
|
// $FlowFixMe[not-an-object]
|
|
Object.entries(recordTypes).map(([offsetStr, type]) => [
|
|
recordFields[Number(offsetStr)],
|
|
type,
|
|
]),
|
|
);
|
|
} else {
|
|
// $FlowIssue[incompatible-type-arg] Object.entries is incompletely typed
|
|
this._fieldToType = new Map(Object.entries(recordTypes || {}));
|
|
}
|
|
}
|
|
this._buffer = buffer;
|
|
this._position = position;
|
|
invariant(
|
|
this._position % this._recordSize === 0,
|
|
'Record accessor constructed at invalid offset',
|
|
);
|
|
invariant(
|
|
this._buffer.length % this._recordSize === 0,
|
|
'Record accessor constructed with wrong size buffer',
|
|
);
|
|
this._globalStringTable = globalStringTable;
|
|
}
|
|
|
|
/** Public API */
|
|
|
|
// Reads a scalar string or enum value from the given field.
|
|
// It's an error to read a number (or other non-string) field as a string.
|
|
// NOTE: The type "string_or_number" is always treated as a number and cannot
|
|
// be read using this method.
|
|
getString(field: string): string {
|
|
const dynamicValue = this._getScalar(field);
|
|
if (typeof dynamicValue === 'string') {
|
|
return dynamicValue;
|
|
}
|
|
throw new Error('Not a string or enum field: ' + field);
|
|
}
|
|
|
|
// Reads a scalar numeric value from the given field.
|
|
// It's an error to read a string (or other non-number) field as a number.
|
|
// NOTE: The type "string_or_number" is always treated as a number.
|
|
getNumber(field: string): number {
|
|
const dynamicValue = this._getScalar(field);
|
|
if (typeof dynamicValue === 'number') {
|
|
return dynamicValue;
|
|
}
|
|
throw new Error('Not a number field: ' + field);
|
|
}
|
|
|
|
// Returns an iterator over the children of this record that are stored in
|
|
// the given field (typically 'children'). Children conform to the same
|
|
// schema as the current record.
|
|
getChildren(field: string): ChromeHeapSnapshotRecordIterator {
|
|
const fieldType = this._fieldToType.get(field);
|
|
if (fieldType !== CHILDREN_FIELD_TYPE) {
|
|
throw new Error('Not a children field: ' + field);
|
|
}
|
|
const childrenBuffer = this._getRaw(field);
|
|
invariant(
|
|
Array.isArray(childrenBuffer),
|
|
'Expected array in children-typed field',
|
|
);
|
|
return new ChromeHeapSnapshotRecordIterator(
|
|
childrenBuffer,
|
|
[], // recordFields ignored when there's a parent
|
|
null, // recordTypes ignored when there's a parent
|
|
this._globalStringTable,
|
|
-this._fieldToOffset.size /* start position */,
|
|
this,
|
|
);
|
|
}
|
|
|
|
// Writes a scalar string or enum value into the given field, updating the
|
|
// global string table as needed.
|
|
// It's an error to write anything other than a string into a string or enum
|
|
// field.
|
|
// It's an error to write an unknown enum value into an enum field.
|
|
// NOTE: The type "string_or_number" is always treated as a number and cannot
|
|
// be written using this method.
|
|
setString(field: string, value: string): void {
|
|
this._setRaw(field, this._encodeString(field, value));
|
|
}
|
|
|
|
// Writes a scalar numeric value into the given field.
|
|
// It's an error to write anything other than a number into a numeric field.
|
|
// NOTE: The type "string_or_number" is always treated as a number.
|
|
setNumber(field: string, value: number): void {
|
|
const fieldType = this._fieldToType.get(field);
|
|
if (
|
|
Array.isArray(fieldType) ||
|
|
fieldType === 'string' ||
|
|
fieldType === CHILDREN_FIELD_TYPE
|
|
) {
|
|
throw new Error('Not a number field: ' + field);
|
|
}
|
|
this._setRaw(field, value);
|
|
}
|
|
|
|
// Moves the cursor to a given index in the buffer (expressed in # of
|
|
// records, NOT fields).
|
|
moveToRecord(recordIndex: number) {
|
|
this._moveToPosition(recordIndex * this._recordSize);
|
|
}
|
|
|
|
// Appends a new record at the end of the buffer.
|
|
//
|
|
// Returns the index of the appended record. All fields must be specified and
|
|
// have values of the correct types. The cursor may move while writing, but
|
|
// is guaranteed to return to its initial position when this function returns
|
|
// (or throws).
|
|
append(record: DenormalizedRecordInput): number {
|
|
const savedPosition = this._position;
|
|
try {
|
|
return this.moveAndInsert(this._buffer.length / this._recordSize, record);
|
|
} finally {
|
|
this._position = savedPosition;
|
|
}
|
|
}
|
|
|
|
// Moves the cursor to a given index in the buffer (expressed in # of
|
|
// records, NOT fields) and inserts a record.
|
|
//
|
|
// Returns the index of the inserted record. All fields must be specified and
|
|
// have values of the correct types. The given index may be the end of the
|
|
// buffer; otherwise existing records starting at the given index will be
|
|
// shifted to the right to accommodate the new record.
|
|
//
|
|
// NOTE: Inserting is a risky, low-level operation. Care must be taken not to
|
|
// desync buffers that implicitly or explicitly depend on one another (e.g.
|
|
// edge.to_node -> node position, cumulative node.edge_count -> edge indices).
|
|
moveAndInsert(recordIndex: number, record: DenormalizedRecordInput): number {
|
|
this._moveToPosition(recordIndex * this._recordSize, /* allowEnd */ true);
|
|
let didResizeBuffer = false;
|
|
try {
|
|
for (const field of this._fieldToOffset.keys()) {
|
|
// $FlowFixMe[method-unbinding] added when improving typing for this parameters
|
|
if (!Object.prototype.hasOwnProperty.call(record, field)) {
|
|
throw new Error('Missing value for field: ' + field);
|
|
}
|
|
}
|
|
this._buffer.splice(
|
|
this._position,
|
|
0,
|
|
...new Array<number | RawBuffer>(this._recordSize),
|
|
);
|
|
didResizeBuffer = true;
|
|
for (const field of Object.keys(record)) {
|
|
this._set(field, record[field]);
|
|
}
|
|
return this._position / this._recordSize;
|
|
} catch (e) {
|
|
if (didResizeBuffer) {
|
|
// Roll back the write
|
|
this._buffer.splice(this._position, this._recordSize);
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/** "Protected" methods (please don't use) */
|
|
|
|
// Return true if we can advance the position by one record (including from
|
|
// the last record to the "end" position).
|
|
protectedHasNext(): boolean {
|
|
if (this._position < 0) {
|
|
// We haven't started iterating yet, so this might _be_ the end position.
|
|
return this._buffer.length > 0;
|
|
}
|
|
return this._position < this._buffer.length;
|
|
}
|
|
|
|
// Move to the next record (or the end) if we're not already at the end.
|
|
protectedTryMoveNext(): void {
|
|
if (this.protectedHasNext()) {
|
|
this._moveToPosition(
|
|
this._position + this._recordSize,
|
|
/* allowEnd */ true,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Private methods */
|
|
|
|
// Reads the raw numeric value of a field.
|
|
_getRaw(field: string): number | RawBuffer {
|
|
this._validatePosition();
|
|
const offset = this._fieldToOffset.get(field);
|
|
if (offset == null) {
|
|
throw new Error('Unknown field: ' + field);
|
|
}
|
|
return this._buffer[this._position + offset];
|
|
}
|
|
|
|
// Decodes a scalar (string or number) field.
|
|
_getScalar(field: string): string | number {
|
|
const rawValue = this._getRaw(field);
|
|
if (Array.isArray(rawValue)) {
|
|
throw new Error('Not a scalar field: ' + field);
|
|
}
|
|
const fieldType = this._fieldToType.get(field);
|
|
if (Array.isArray(fieldType)) {
|
|
invariant(
|
|
rawValue >= 0 && rawValue < fieldType.length,
|
|
'raw value does not match field enum type',
|
|
);
|
|
return fieldType[rawValue];
|
|
}
|
|
if (fieldType === 'string') {
|
|
return this._globalStringTable.get(rawValue);
|
|
}
|
|
return rawValue;
|
|
}
|
|
|
|
// Writes the raw value of a field.
|
|
_setRaw(field: string, rawValue: number | RawBuffer): void {
|
|
this._validatePosition();
|
|
const offset = this._fieldToOffset.get(field);
|
|
if (offset == null) {
|
|
throw new Error('Unknown field: ' + field);
|
|
}
|
|
this._buffer[this._position + offset] = rawValue;
|
|
}
|
|
|
|
// Writes a scalar or children value to `field`, inferring the intended type
|
|
// based on the runtime type of `value`.
|
|
_set(
|
|
field: string,
|
|
value: string | number | $ReadOnlyArray<DenormalizedRecordInput>,
|
|
): void {
|
|
if (typeof value === 'string') {
|
|
this.setString(field, value);
|
|
} else if (typeof value === 'number') {
|
|
this.setNumber(field, value);
|
|
} else if (Array.isArray(value)) {
|
|
this._setChildren(field, value);
|
|
} else {
|
|
throw new Error('Unsupported value for field: ' + field);
|
|
}
|
|
}
|
|
|
|
// Writes a children array to `field` by appending each element of `value` to
|
|
// a new buffer using `append()`s semantics.
|
|
_setChildren(
|
|
field: string,
|
|
value: $ReadOnlyArray<DenormalizedRecordInput>,
|
|
): void {
|
|
const fieldType = this._fieldToType.get(field);
|
|
if (fieldType !== CHILDREN_FIELD_TYPE) {
|
|
throw new Error('Not a children field: ' + field);
|
|
}
|
|
this._setRaw(field, []);
|
|
const childIt = this.getChildren(field);
|
|
for (const child of value) {
|
|
childIt.append(child);
|
|
}
|
|
}
|
|
|
|
// Encodes a string value according to its field schema.
|
|
// The global string table may be updated as a side effect.
|
|
_encodeString(field: string, value: string): number {
|
|
const fieldType = this._fieldToType.get(field);
|
|
if (Array.isArray(fieldType)) {
|
|
const index = fieldType.indexOf(value);
|
|
invariant(index >= 0, 'Cannot define new values in enum field');
|
|
return index;
|
|
}
|
|
if (fieldType === 'string') {
|
|
return this._globalStringTable.add(value);
|
|
}
|
|
throw new Error('Not a string or enum field: ' + field);
|
|
}
|
|
|
|
// Asserts that the given position (default: the current position) is either
|
|
// a valid position for reading a record, or (if allowEnd is true) the end of
|
|
// the buffer.
|
|
_validatePosition(
|
|
allowEnd?: boolean = false,
|
|
position?: number = this._position,
|
|
): void {
|
|
if (!Number.isInteger(position)) {
|
|
throw new Error(`Position ${position} is not an integer`);
|
|
}
|
|
if (position % this._recordSize !== 0) {
|
|
throw new Error(
|
|
`Position ${position} is not a multiple of record size ${this._recordSize}`,
|
|
);
|
|
}
|
|
if (position < 0) {
|
|
throw new Error(`Position ${position} is out of range`);
|
|
}
|
|
const maxPosition = allowEnd
|
|
? this._buffer.length
|
|
: this._buffer.length - 1;
|
|
if (position > maxPosition) {
|
|
throw new Error(`Position ${position} is out of range`);
|
|
}
|
|
if (this._buffer.length - position < this._recordSize) {
|
|
if (!(allowEnd && this._buffer.length === position)) {
|
|
throw new Error(
|
|
`Record at position ${position} is truncated: expected ${
|
|
this._recordSize
|
|
} fields but found ${this._buffer.length - position}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Move to the given position or throw an error if it is invalid.
|
|
_moveToPosition(nextPosition: number, allowEnd: boolean = false) {
|
|
this._validatePosition(allowEnd, nextPosition);
|
|
this._position = nextPosition;
|
|
}
|
|
}
|
|
|
|
// $FlowIssue[prop-missing] Flow doesn't see that we implement the iteration protocol
|
|
class ChromeHeapSnapshotRecordIterator
|
|
extends ChromeHeapSnapshotRecordAccessor
|
|
implements Iterable<ChromeHeapSnapshotRecordAccessor>
|
|
{
|
|
constructor(
|
|
buffer: RawBuffer,
|
|
recordFields: Array<string>,
|
|
recordTypes:
|
|
| Array<ChromeHeapSnapshotFieldType>
|
|
| $ReadOnly<{
|
|
[string]: ChromeHeapSnapshotFieldType,
|
|
}>
|
|
| null,
|
|
globalStringTable: ChromeHeapSnapshotStringTable,
|
|
// Initialise to "before the first iteration".
|
|
// The Accessor constructor intentionally checks only alignment, not range,
|
|
// so this works as long as we don't try to read/write (at which point
|
|
// validation will kick in).
|
|
position: number = -recordFields.length,
|
|
parent?: ChromeHeapSnapshotRecordAccessor,
|
|
) {
|
|
super(
|
|
buffer,
|
|
recordFields,
|
|
recordTypes,
|
|
globalStringTable,
|
|
position,
|
|
parent,
|
|
);
|
|
}
|
|
|
|
// JS Iterator protocol
|
|
next(): {done: boolean, +value: this} {
|
|
this.protectedTryMoveNext();
|
|
return {done: !this.protectedHasNext(), value: this};
|
|
}
|
|
|
|
// JS Iterable protocol
|
|
// $FlowIssue[unsupported-syntax]
|
|
[Symbol.iterator](): this {
|
|
return this;
|
|
}
|
|
}
|
|
|
|
module.exports = {ChromeHeapSnapshotProcessor};
|