import * as inspector from 'inspector';
import * as util from 'util';
import {StatusMessage} from '../../client/stackdriver/status-message';
import * as stackdriver from '../../types/stackdriver';
import {ResolvedDebugAgentConfig} from '../config';
import {debugAssert} from '../util/debug-assert';
import {V8Inspector} from '../v8/v8inspector';
const assert = debugAssert(!!process.env.CLOUD_DEBUG_ASSERTIONS);
// Error message indices into the resolved variable table.
const BUFFER_FULL_MESSAGE_INDEX = 0;
const NATIVE_PROPERTY_MESSAGE_INDEX = 1;
const GETTER_MESSAGE_INDEX = 2;
const ARG_LOCAL_LIMIT_MESSAGE_INDEX = 3;
const FILE_PROTOCOL = 'file://';
// on windows on Node 11+ the file protocol needs to have three slashes
const WINDOWS_FILE_PROTOCOL = 'file:///';
// Used to match paths like file:///C:/... on windows
// but do not match paths like file:///home/... on linux
const WINDOWS_URL_REGEX = RegExp(`^${WINDOWS_FILE_PROTOCOL}[a-zA-Z]+:`);
const STABLE_OBJECT_ID_PROPERTY = '[[StableObjectId]]';
const NO_STABLE_OBJECT_ID = -1;
interface RawVariableTableEntry {
objectId?: string;
description?: string;
stableObjectId: number;
}
/**
* Checks that the provided expressions will not have side effects and
* then evaluates the expression in the current execution context.
*
* @return an object with error and mirror fields.
*/
export function evaluate(
expression: string,
frame: inspector.Debugger.CallFrame,
v8inspector: V8Inspector,
returnByValue: boolean
): {error: string | null; object?: inspector.Runtime.RemoteObject} {
// First validate the expression to make sure it doesn't mutate state
// and ask V8 Inspector to evaluate the expression
const result = v8inspector.evaluateOnCallFrame({
callFrameId: frame.callFrameId,
expression,
returnByValue,
throwOnSideEffect: true,
});
if (result.error || !result.response) {
return {
error: result.error ? String(result.error) : 'no reponse in result',
};
} else if (result.response.exceptionDetails) {
return {error: String(result.response.result.description).split('\n')[0]};
} else {
return {error: null, object: result.response.result};
}
}
class StateResolver {
private callFrames: inspector.Debugger.CallFrame[];
private v8Inspector: V8Inspector;
private expressions: string[] | undefined;
private config: ResolvedDebugAgentConfig;
private scriptmapper: {[id: string]: {url: string}};
private breakpoint: stackdriver.Breakpoint;
private evaluatedExpressions: stackdriver.Variable[];
private totalSize: number;
private messageTable: stackdriver.Variable[];
private resolvedVariableTable: stackdriver.Variable[];
private rawVariableTable: Array<RawVariableTableEntry | null>;
/**
* @param {Array<!Object>} callFrames
* @param {Array<string>} expressions
* @param {!Object} config
* @constructor
*/
constructor(
callFrames: inspector.Debugger.CallFrame[],
breakpoint: stackdriver.Breakpoint,
config: ResolvedDebugAgentConfig,
scriptmapper: {[id: string]: {url: string}},
v8Inspector: V8Inspector
) {
this.callFrames = callFrames;
this.breakpoint = breakpoint;
// TODO: Investigate whether this cast can be avoided.
this.expressions = breakpoint.expressions;
this.config = config;
this.scriptmapper = scriptmapper;
this.v8Inspector = v8Inspector;
this.evaluatedExpressions = [];
this.totalSize = 0;
this.messageTable = [];
this.messageTable[BUFFER_FULL_MESSAGE_INDEX] = {
status: new StatusMessage(
StatusMessage.VARIABLE_VALUE,
'Max data size reached',
true
),
};
this.messageTable[NATIVE_PROPERTY_MESSAGE_INDEX] = {
status: new StatusMessage(
StatusMessage.VARIABLE_VALUE,
'Native properties are not available',
true
),
};
this.messageTable[GETTER_MESSAGE_INDEX] = {
status: new StatusMessage(
StatusMessage.VARIABLE_VALUE,
'Properties with getters are not available',
true
),
};
this.messageTable[ARG_LOCAL_LIMIT_MESSAGE_INDEX] = {
status: new StatusMessage(
StatusMessage.VARIABLE_VALUE,
'Locals and arguments are only displayed for the ' +
'top `config.capture.maxExpandFrames=' +
config.capture.maxExpandFrames +
'` stack frames.',
true
),
};
// TODO: Determine why _extend is used here
this.resolvedVariableTable = ((util as {}) as {_extend: Function})._extend(
[],
this.messageTable
);
this.rawVariableTable = this.messageTable.map(() => {
return null;
});
}
/**
* Captures the stack and current execution state.
*
* @return an object with stackFrames, variableTable, and
* evaluatedExpressions fields
*/
capture_(): stackdriver.Breakpoint {
// Evaluate the watch expressions
const evalIndexSet = new Set();
if (this.expressions) {
this.expressions.forEach((expression, index2) => {
const result = evaluate(
expression,
this.callFrames[0],
this.v8Inspector,
false
);
let evaluated;
if (result.error) {
evaluated = {
name: expression,
status: new StatusMessage(
StatusMessage.VARIABLE_VALUE,
result.error,
true
),
};
} else {
// TODO: Determine how to not downcast this to v8Types.ValueMirror
// TODO: Handle the case where `result.mirror` is `undefined`.
evaluated = this.resolveVariable_(
expression,
result.object as inspector.Runtime.RemoteObject,
true
);
const varTableIdx = evaluated.varTableIndex;
if (typeof varTableIdx !== 'undefined') {
evalIndexSet.add(varTableIdx);
}
}
this.evaluatedExpressions[index2] = evaluated;
});
}
// The frames are resolved after the evaluated expressions so that
// evaluated expressions can be evaluated as much as possible within
// the max data size limits
const frames = this.resolveFrames_();
// Now resolve the variables
let index = this.messageTable.length; // skip the sentinel values
const noLimit = this.config.capture.maxDataSize === 0;
while (
index < this.rawVariableTable.length && // NOTE: length changes in loop
(this.totalSize < this.config.capture.maxDataSize || noLimit)
) {
assert.ok(!this.resolvedVariableTable[index]); // shouldn't have it
// resolved yet
const isEvaluated = evalIndexSet.has(index);
// TODO: Handle the cases where `null` or `undefined` occurs
if (this.rawVariableTable![index]!.objectId) {
this.resolvedVariableTable[index] = this.resolveRemoteObject_(
this.rawVariableTable[index]!,
isEvaluated
);
}
index++;
}
// If we filled up the buffer already, we need to trim the remainder
if (index < this.rawVariableTable.length) {
this.trimVariableTable_(index, frames);
}
return {
id: this.breakpoint.id,
stackFrames: frames,
variableTable: this.resolvedVariableTable,
evaluatedExpressions: this.evaluatedExpressions,
};
}
/**
* Limits the size of the variable table to `fromIndex` elements. It marks
* all variables with entries beyond `fromIndex` with a message indicating
* that the table filled.
*
* @param {Number} fromIndex The desired size of the variable table.
* @param {Object} frames Frames associated with the current execution
* environment.
*/
trimVariableTable_(
fromIndex: number,
frames: stackdriver.StackFrame[]
): void {
this.resolvedVariableTable.splice(fromIndex); // remove the remaining entries
// eslint-disable-next-line @typescript-eslint/no-this-alias
const that = this;
const processBufferFull = (variables: stackdriver.Variable[]) => {
variables.forEach(variable => {
if (variable.varTableIndex && variable.varTableIndex >= fromIndex) {
// make it point to the sentinel 'buffer full' value
variable.varTableIndex = BUFFER_FULL_MESSAGE_INDEX;
variable.status = that.messageTable[BUFFER_FULL_MESSAGE_INDEX].status;
}
if (variable.members) {
processBufferFull(variable.members);
}
});
};
frames.forEach(frame => {
processBufferFull(frame.arguments);
processBufferFull(frame.locals);
});
processBufferFull(this.evaluatedExpressions);
processBufferFull(this.resolvedVariableTable);
}
resolveFrames_(): stackdriver.StackFrame[] {
const frames: stackdriver.StackFrame[] = [];
const frameCount = Math.min(
this.callFrames.length,
this.config.capture.maxFrames
);
for (let i = 0; i < frameCount; i++) {
const frame = this.callFrames[i];
if (this.shouldFrameBeResolved_(frame)) {
frames.push(
this.resolveFrame_(frame, i < this.config.capture.maxExpandFrames)
);
}
}
return frames;
}
shouldFrameBeResolved_(frame: inspector.Debugger.CallFrame): boolean {
// Only capture data from the frames for which we can link the data back
// to the source files.
const fullPath = this.resolveFullPath_(frame);
if (!this.isPathInCurrentWorkingDirectory_(fullPath)) {
return false;
}
const relativePath = this.resolveRelativePath_(frame);
if (
!this.config.capture.includeNodeModules &&
this.isPathInNodeModulesDirectory_(relativePath)
) {
return false;
}
return true;
}
resolveFullPath_(frame: inspector.Debugger.CallFrame): string {
const scriptId: string = frame.location.scriptId;
if (this.scriptmapper[scriptId] === undefined) {
return '';
}
if (this.scriptmapper[scriptId].url === undefined) {
return '';
}
const scriptUrl = this.scriptmapper[scriptId].url;
// In Node 11+, non-internal files are formatted as URLs, so get just the
// path.
const strippedUrl = StateResolver.stripFileProtocol_(scriptUrl);
if (process.platform === 'win32') {
// on windows the script url provided to v8 uses forward slashes
// convert them back to backslashes
return strippedUrl.replace(/\//g, '\\');
}
return strippedUrl;
}
resolveRelativePath_(frame: inspector.Debugger.CallFrame): string {
const fullPath = this.resolveFullPath_(frame);
return this.stripCurrentWorkingDirectory_(fullPath);
}
static stripFileProtocol_(path: string) {
const lowerPath = path.toLowerCase();
if (WINDOWS_URL_REGEX.test(lowerPath)) {
return path.substr(WINDOWS_FILE_PROTOCOL.length);
}
if (lowerPath.startsWith(FILE_PROTOCOL)) {
return path.substr(FILE_PROTOCOL.length);
}
return path;
}
stripCurrentWorkingDirectory_(path: string): string {
// Strip 1 extra character to remove the slash.
return StateResolver.stripFileProtocol_(path).substr(
this.config.workingDirectory!.length + 1
);
}
isPathInCurrentWorkingDirectory_(path: string): boolean {
return (
StateResolver.stripFileProtocol_(path).indexOf(
this.config.workingDirectory
) === 0
);
}
isPathInNodeModulesDirectory_(path: string): boolean {
return StateResolver.stripFileProtocol_(path).indexOf('node_modules') === 0;
}
resolveFrame_(
frame: inspector.Debugger.CallFrame,
underFrameCap: boolean
): stackdriver.StackFrame {
const args: stackdriver.Variable[] = [];
let locals: Array<{}> = [];
if (!underFrameCap) {
args.push({
name: 'arguments_not_available',
varTableIndex: ARG_LOCAL_LIMIT_MESSAGE_INDEX,
});
locals.push({
name: 'locals_not_available',
varTableIndex: ARG_LOCAL_LIMIT_MESSAGE_INDEX,
});
} else {
locals = this.resolveLocalsList_(frame);
if (Array.isArray(locals) && locals.length === 0) {
locals = [];
}
}
return {
function: this.resolveFunctionName_(frame),
location: this.resolveLocation_(frame),
arguments: args,
locals,
};
}
resolveFunctionName_(frame: inspector.Debugger.CallFrame): string {
if (!frame) {
return '';
}
if (frame.functionName === '') {
return '(anonymous function)';
}
return frame.functionName;
}
resolveLocation_(
frame: inspector.Debugger.CallFrame
): stackdriver.SourceLocation {
return {
path: this.resolveRelativePath_(frame),
line: frame.location.lineNumber,
};
}
/**
* Iterates and returns variable information for all scopes (excluding global)
* in a given frame. FrameMirrors should return their scope object list with
* most deeply nested scope first so variables initially encountered will take
* precedence over subsequent instance with the same name - this is tracked in
* the usedNames map. The argument list given to this function may be
* manipulated if variables with a deeper scope occur which have the same
* name.
* @function resolveLocalsList_
* @memberof StateResolver
* @param {inspector.Debugger.CallFrame} frame - A instance of callframe.
* @returns {Array<Object>} - returns an array containing data about selected
* variables
*/
resolveLocalsList_(
frame: inspector.Debugger.CallFrame
): stackdriver.Variable[] {
const locals: Array<{}> = [];
const usedNames: {[name: string]: boolean} = {};
const allScopes = frame.scopeChain;
let count = allScopes.length;
// We find the top-level (module global) variable pollute the local
// variables we omit them by default, unless the breakpoint itself is
// top-level. The last scope is always omitted.
if (frame.scopeChain[count - 2].type === 'closure') {
count -= 2;
} else {
count -= 1;
}
for (let i = 0; i < count; ++i) {
const result = this.v8Inspector.getProperties({
objectId: frame.scopeChain[i].object.objectId as string,
});
// TODO: Handle when result.error exists.
if (
result.response &&
!(
Array.isArray(result.response.result) &&
result.response.result.length === 0
)
) {
for (let j = 0; j < result.response.result.length; ++j) {
if (!usedNames[result.response.result[j].name]) {
// It's a valid variable that belongs in the locals list
// and wasn't discovered at a lower-scope
usedNames[result.response.result[j].name] = true;
if (result.response.result[j].value) {
locals.push(
this.resolveVariable_(
result.response.result[j].name,
result.response.result[j]
.value as inspector.Runtime.RemoteObject,
false
)
);
}
}
}
}
}
if (frame.this.objectId) {
locals.push(this.resolveVariable_('context', frame.this, false));
}
return locals;
}
/**
* Computes a text representation of the provided value based on its type.
* If the value is a recursive data type, it will be represented as an index
* into the variable table.
*
* @param {String} name The name of the variable.
* @param {Object} object A RemoteObject from v8 Runtime.
* @param {boolean} isEvaluated Specifies if the variable is from a watched
* expression.
*/
resolveVariable_(
name: string,
object: inspector.Runtime.RemoteObject,
isEvaluated: boolean
): stackdriver.Variable {
let size = name.length;
const data: stackdriver.Variable = {name};
if (this.isPrimitive_(object.type)) {
// primitives: undefined, null, boolean, number, string, symbol
data.value = String(object.value);
const maxLength = this.config.capture.maxStringLength;
if (!isEvaluated && maxLength && maxLength < data.value.length) {
data.status = new StatusMessage(
StatusMessage.VARIABLE_VALUE,
'Only first `config.capture.maxStringLength=' +
this.config.capture.maxStringLength +
'` chars were captured for string of length ' +
data.value.length +
'. Use in an expression to see the full string.',
false
);
data.value = data.value.substring(0, maxLength) + '...';
}
} else if (this.isFunction_(object.type)) {
data.value =
'function ' + (name === '' ? '(anonymous function)' : name + '()');
} else if (this.isObject_(object.type)) {
data.varTableIndex = this.getVariableIndex_(object);
} else {
data.value = 'unknown type';
}
if (data.value) {
size += data.value.length;
} else {
size += 8; // fudge-it
}
this.totalSize += size;
return data;
}
isPrimitive_(type: string): boolean {
return (
type === 'undefined' ||
type === 'boolean' ||
type === 'number' ||
type === 'string' ||
type === 'symbol'
);
}
isObject_(type: string): boolean {
return type === 'object';
}
isFunction_(type: string): boolean {
return type === 'function';
}
/**
* Gets the stable object ID for a given object, or NO_STABLE_OBJECT_ID
* if it can't be obtained.
* @param remoteObject The object whose stable object ID should be retrieved.
*/
private getStableObjectId(
remoteObject: inspector.Runtime.RemoteObject
): number {
if (remoteObject.objectId === undefined) {
// Unexpected... but since this is required to obtain the stable object
// ID, return a value that specifies that it is not available.
return NO_STABLE_OBJECT_ID;
}
const properties = this.v8Inspector.getProperties({
objectId: remoteObject.objectId,
});
if (
properties.error ||
!properties.response ||
!properties.response.internalProperties
) {
return NO_STABLE_OBJECT_ID;
}
const stableObjectIdProperty = properties.response.internalProperties.find(
property => property.name === STABLE_OBJECT_ID_PROPERTY
);
if (
!stableObjectIdProperty ||
!stableObjectIdProperty.value ||
stableObjectIdProperty.value.value === undefined
) {
return NO_STABLE_OBJECT_ID;
}
return stableObjectIdProperty.value.value;
}
getVariableIndex_(value: inspector.Runtime.RemoteObject): number {
const stableObjectId = this.getStableObjectId(value);
let idx = this.rawVariableTable.findIndex(rawVar => {
if (rawVar) {
// stableObjectId was introduced in Node 10.15.3/11.7.0 as a more
// reliable way to check object equality, as objectId is unique only to
// object mirrors, and therefore monotonically increases on repeated
// accesses to the same remote object. If this field is available, use
// it.
if (stableObjectId !== NO_STABLE_OBJECT_ID) {
return rawVar.stableObjectId === stableObjectId;
} else {
// Fall back to using objectId for old versions of Node. Note that
// this will cause large data payloads for objects with circular
// references, and infinite loops if the max payload size is
// unlimited.
return rawVar.objectId === value.objectId;
}
}
return false;
});
if (idx === -1) {
idx = this.rawVariableTable.length;
this.rawVariableTable[idx] = {
objectId: value.objectId,
description: value.description,
stableObjectId,
};
}
return idx;
}
/**
* Responsible for recursively resolving the properties on a
* provided remote object.
*/
resolveRemoteObject_(
object: RawVariableTableEntry,
isEvaluated: boolean
): stackdriver.Variable {
const maxProps = this.config.capture.maxProperties;
// TS: ! is OK since getProperties will populate result.error in the absence
// of an object ID.
const result = this.v8Inspector.getProperties({objectId: object.objectId!});
const members: Array<{}> = [];
if (result.error || !result.response) {
members.push({
name: result.error
? String(result.error)
: 'no response got in getProperty',
});
} else {
let truncate = maxProps && result.response.result.length > maxProps;
let upperBound = result.response.result.length;
if (!isEvaluated && truncate) upperBound = maxProps;
for (let i = 0; i < upperBound; ++i) {
if (result.response.result[i].isOwn) {
members.push(
this.resolveObjectProperty_(isEvaluated, result.response.result[i])
);
} else {
truncate = false;
}
}
if (!isEvaluated && truncate) {
members.push({
name:
'Only first `config.capture.maxProperties=' +
this.config.capture.maxProperties +
'` properties were captured. Use in an expression' +
' to see all properties.',
});
}
}
return {value: object.description, members};
}
resolveObjectProperty_(
isEvaluated: boolean,
property: inspector.Runtime.PropertyDescriptor
): stackdriver.Variable {
const name = String(property.name);
if (property.get !== undefined) {
return {name, varTableIndex: GETTER_MESSAGE_INDEX};
}
// TODO: Handle the case when property.value is undefined
return this.resolveVariable_(name, property.value!, isEvaluated);
}
}
// This function is used by unit tests to make sure assertions are enabled.
export function testAssert(): void {
assert.strictEqual(0, 1);
}
/**
* Captures the stack and current execution state.
*
* @return an object with stackFrames, variableTable, and
* evaluatedExpressions fields
*/
export function capture(
callFrames: inspector.Debugger.CallFrame[],
breakpoint: stackdriver.Breakpoint,
config: ResolvedDebugAgentConfig,
scriptmapper: {[id: string]: {url: string}},
v8Inspector: V8Inspector
): stackdriver.Breakpoint {
return new StateResolver(
callFrames,
breakpoint,
config,
scriptmapper,
v8Inspector
).capture_();
}