src/agent/debuglet.ts
IsReady will return a promise to user after user starting the debug agent. This promise will be resolved when one of the following is true:
Methods |
| isReady |
isReady()
|
|
Defined in src/agent/debuglet.ts:159
|
|
Returns :
Promise<void>
|
import is from '@sindresorhus/is';
import * as assert from 'assert';
import * as consoleLogLevel from 'console-log-level';
import * as crypto from 'crypto';
import {EventEmitter} from 'events';
import * as extend from 'extend';
import * as fs from 'fs';
import * as metadata from 'gcp-metadata';
import * as path from 'path';
import * as util from 'util';
import {Debug, PackageInfo} from '../client/stackdriver/debug';
import {StatusMessage} from '../client/stackdriver/status-message';
import {Debuggee, DebuggeeProperties} from '../debuggee';
import * as stackdriver from '../types/stackdriver';
import {defaultConfig} from './config';
import {
DebugAgentConfig,
Logger,
LogLevel,
ResolvedDebugAgentConfig,
} from './config';
import {Controller} from './controller';
import * as scanner from './io/scanner';
import * as SourceMapper from './io/sourcemapper';
import * as utils from './util/utils';
import * as debugapi from './v8/debugapi';
import {DebugApi} from './v8/debugapi';
const readFilep = util.promisify(fs.readFile);
const ALLOW_EXPRESSIONS_MESSAGE =
'Expressions and conditions are not allowed' +
' by default. Please set the allowExpressions configuration option to true.' +
' See the debug agent documentation at https://goo.gl/ShSm6r.';
const NODE_VERSION_MESSAGE =
'Node.js version not supported. Node.js 5.2.0 and ' +
'versions older than 0.12 are not supported.';
const NODE_10_CIRC_REF_MESSAGE =
'capture.maxDataSize=0 is not recommended on older versions of Node 10/11' +
' and Node 12.' +
' See https://github.com/googleapis/cloud-debug-nodejs/issues/516 for more' +
' information.';
const BREAKPOINT_ACTION_MESSAGE =
'The only currently supported breakpoint actions' + ' are CAPTURE and LOG.';
// PROMISE_RESOLVE_CUT_OFF_IN_MILLISECONDS is a heuristic duration that we set
// to force the debug agent to return a new promise for isReady. The value is
// the average of Stackdriver debugger hanging get duration (40s) and TCP
// time-out on GCF (540s).
const PROMISE_RESOLVE_CUT_OFF_IN_MILLISECONDS = ((40 + 540) / 2) * 1000;
interface SourceContext {
[key: string]: string;
}
/**
* Formats a breakpoint object prefixed with a provided message as a string
* intended for logging.
* @param {string} msg The message that prefixes the formatted breakpoint.
* @param {Breakpoint} breakpoint The breakpoint to format.
* @return {string} A formatted string.
*/
const formatBreakpoint = (
msg: string,
breakpoint: stackdriver.Breakpoint
): string => {
let text =
msg +
util.format(
'breakpoint id: %s,\n\tlocation: %s',
breakpoint.id,
util.inspect(breakpoint.location)
);
if (breakpoint.createdTime) {
const unixTime = Number(breakpoint.createdTime.seconds);
const date = new Date(unixTime * 1000); // to milliseconds.
text += '\n\tcreatedTime: ' + date.toString();
}
if (breakpoint.condition) {
text += '\n\tcondition: ' + util.inspect(breakpoint.condition);
}
if (breakpoint.expressions) {
text += '\n\texpressions: ' + util.inspect(breakpoint.expressions);
}
return text;
};
/**
* Formats a map of breakpoint objects prefixed with a provided message as a
* string intended for logging.
* @param {string} msg The message that prefixes the formatted breakpoint.
* @param {Object.<string, Breakpoint>} breakpoints A map of breakpoints.
* @return {string} A formatted string.
*/
const formatBreakpoints = (
msg: string,
breakpoints: {[key: string]: stackdriver.Breakpoint}
): string => {
return (
msg +
Object.keys(breakpoints)
.map(b => {
return formatBreakpoint('', breakpoints[b]);
})
.join('\n')
);
};
/**
* CachedPromise stores a promise. This promise can be resolved by calling
* function resolve() and can only be resolved once.
*/
export class CachedPromise {
private promiseResolve: (() => void) | null = null;
private promise: Promise<void> = new Promise<void>(resolve => {
this.promiseResolve = resolve;
});
get(): Promise<void> {
return this.promise;
}
resolve(): void {
// Each promise can be resolved only once.
if (this.promiseResolve) {
this.promiseResolve();
this.promiseResolve = null;
}
}
}
/**
* IsReady will return a promise to user after user starting the debug agent.
* This promise will be resolved when one of the following is true:
* 1. Time since last listBreakpoint was within a heuristic time.
* 2. listBreakpoint completed successfully.
* 3. Debuggee registration expired or failed, listBreakpoint cannot be
* completed.
*/
export interface IsReady {
isReady(): Promise<void>;
}
/**
* IsReadyManager is a wrapper class to use debuglet.isReady().
*/
class IsReadyImpl implements IsReady {
constructor(private debuglet: Debuglet) {}
isReady(): Promise<void> {
return this.debuglet.isReady();
}
}
export interface FindFilesResult {
jsStats: scanner.ScanStats;
mapFiles: string[];
errors: Map<string, Error>;
hash: string;
}
export class Debuglet extends EventEmitter {
private debug: Debug;
private v8debug: DebugApi | null;
private running: boolean;
private project: string | null;
private controller: Controller;
private completedBreakpointMap: {[key: string]: boolean};
// breakpointFetchedTimestamp represents the last timestamp when
// breakpointFetched was resolved, which means breakpoint update was
// successful.
private breakpointFetchedTimestamp: number;
// breakpointFetched is a CachedPromise only to be resolved after breakpoint
// fetch was successful. Its stored promise will be returned by isReady().
private breakpointFetched: CachedPromise | null;
// debuggeeRegistered is a CachedPromise only to be resolved after debuggee
// registration was successful.
private debuggeeRegistered: CachedPromise;
isReadyManager: IsReady = new IsReadyImpl(this);
// Exposed for testing
config: ResolvedDebugAgentConfig;
fetcherActive: boolean;
logger: Logger;
debuggee: Debuggee | null;
activeBreakpointMap: {[key: string]: stackdriver.Breakpoint};
/**
* @param {Debug} debug - A Debug instance.
* @param {object=} config - The option parameters for the Debuglet.
* @event 'started' once the startup tasks are completed. Only called once.
* @event 'stopped' if the agent stops due to a fatal error after starting.
* Only called once.
* @event 'registered' once successfully registered to the debug api. May be
* emitted multiple times.
* @event 'remotelyDisabled' if the debuggee is disabled by the server. May be
* called multiple times.
* @constructor
*/
constructor(debug: Debug, config: DebugAgentConfig) {
super();
/** @private {object} */
this.config = Debuglet.normalizeConfig_(config);
/** @private {Debug} */
this.debug = debug;
/**
* @private {object} V8 Debug API. This can be null if the Node.js version
* is out of date.
*/
this.v8debug = null;
/** @private {boolean} */
this.running = false;
/** @private {string} */
this.project = null;
/** @private {boolean} */
this.fetcherActive = false;
/** @private */
this.logger = consoleLogLevel({
stderr: true,
prefix: this.debug.packageInfo.name,
level: Debuglet.logLevelToName(this.config.logLevel),
});
/** @private {DebugletApi} */
this.controller = new Controller(this.debug, {apiUrl: config.apiUrl});
/** @private {Debuggee} */
this.debuggee = null;
/** @private {Object.<string, Breakpoint>} */
this.activeBreakpointMap = {};
/** @private {Object.<string, Boolean>} */
this.completedBreakpointMap = {};
this.breakpointFetched = null;
this.breakpointFetchedTimestamp = -Infinity;
this.debuggeeRegistered = new CachedPromise();
}
static LEVELNAMES: LogLevel[] = [
'fatal',
'error',
'warn',
'info',
'debug',
'trace',
];
// The return type `LogLevel` is used instead of
// `consoleLogLevel.LogLevelNames` because, otherwise,
// the `consoleLogLevel.LogLevelNames` type is exposed to
// users of the debug agent, requiring them to have
// @types/console-log-level installed to compile their code.
static logLevelToName(level: number): LogLevel {
if (typeof level === 'string') {
level = Number(level);
}
if (typeof level !== 'number') {
level = defaultConfig.logLevel;
}
if (level < 0) level = 0;
if (level > 4) level = 4;
return Debuglet.LEVELNAMES[level];
}
static normalizeConfig_(config: DebugAgentConfig): ResolvedDebugAgentConfig {
const envConfig = {
logLevel: process.env.GCLOUD_DEBUG_LOGLEVEL,
serviceContext: {
service:
process.env.GAE_SERVICE ||
process.env.GAE_MODULE_NAME ||
process.env.K_SERVICE,
version:
process.env.GAE_VERSION ||
process.env.GAE_MODULE_VERSION ||
process.env.K_REVISION,
minorVersion_:
process.env.GAE_DEPLOYMENT_ID || process.env.GAE_MINOR_VERSION,
},
};
if (process.env.FUNCTION_NAME) {
envConfig.serviceContext.service = process.env.FUNCTION_NAME;
envConfig.serviceContext.version = 'unversioned';
}
return extend(true, {}, defaultConfig, config, envConfig);
}
static buildRegExp(fileExtensions: string[]): RegExp {
return new RegExp(fileExtensions.map(f => f + '$').join('|'));
}
static async findFiles(
config: ResolvedDebugAgentConfig,
precomputedHash?: string
): Promise<FindFilesResult> {
const baseDir = config.workingDirectory;
const fileStats = await scanner.scan(
baseDir,
Debuglet.buildRegExp(config.javascriptFileExtensions.concat('js.map')),
precomputedHash
);
const jsStats = fileStats.selectStats(
Debuglet.buildRegExp(config.javascriptFileExtensions)
);
const mapFiles = fileStats.selectFiles(/.js.map$/, process.cwd());
const errors = fileStats.errors();
return {jsStats, mapFiles, errors, hash: fileStats.hash};
}
/**
* Starts the Debuglet. It is important that this is as quick as possible
* as it is on the critical path of application startup.
* @private
*/
async start(): Promise<void> {
const that = this;
const stat = util.promisify(fs.stat);
try {
await stat(path.join(that.config.workingDirectory, 'package.json'));
} catch (err) {
that.logger.error('No package.json located in working directory.');
that.emit('initError', new Error('No package.json found.'));
return;
}
const workingDir = that.config.workingDirectory;
// Don't continue if the working directory is a root directory
// unless the user wants to force using the root directory
if (
!that.config.allowRootAsWorkingDirectory &&
path.join(workingDir, '..') === workingDir
) {
const message =
'The working directory is a root directory. Disabling ' +
'to avoid a scan of the entire filesystem for JavaScript files. ' +
'Use config `allowRootAsWorkingDirectory` if you really want to ' +
'do this.';
that.logger.error(message);
that.emit('initError', new Error(message));
return;
}
let gaeId: string | undefined;
if (process.env.GAE_MINOR_VERSION) {
gaeId = 'GAE-' + process.env.GAE_MINOR_VERSION;
}
let findResults: FindFilesResult;
try {
findResults = await Debuglet.findFiles(that.config, gaeId);
findResults.errors.forEach(that.logger.warn);
} catch (err) {
that.logger.error('Error scanning the filesystem.', err);
that.emit('initError', err);
return;
}
let mapper;
try {
mapper = await SourceMapper.create(findResults.mapFiles);
} catch (err3) {
that.logger.error('Error processing the sourcemaps.', err3);
that.emit('initError', err3);
return;
}
that.v8debug = debugapi.create(
that.logger,
that.config,
findResults.jsStats,
mapper
);
const id: string = gaeId || findResults.hash;
that.logger.info('Unique ID for this Application: ' + id);
let onGCP: boolean;
try {
onGCP = await Debuglet.runningOnGCP();
} catch (err) {
that.logger.warn(
'Unexpected error detecting GCE metadata service: ' + err.message
);
// Continue, assuming not on GCP.
onGCP = false;
}
let project: string;
try {
project = await that.debug.authClient.getProjectId();
} catch (err) {
that.logger.error(
'The project ID could not be determined: ' + err.message
);
that.emit('initError', err);
return;
}
if (
onGCP &&
(!that.config.serviceContext || !that.config.serviceContext.service)
) {
// If on GCP, check if the clusterName instance attribute is availble.
// Use this as the service context for better service identification on
// GKE.
try {
const clusterName = await Debuglet.getClusterNameFromMetadata();
that.config.serviceContext = {
service: clusterName,
version: 'unversioned',
minorVersion_: undefined,
};
} catch (err) {
/* we are not running on GKE - Ignore error. */
}
}
let sourceContext;
try {
sourceContext =
((that.config.sourceContext as {}) as SourceContext) ||
(await Debuglet.getSourceContextFromFile());
} catch (err5) {
that.logger.warn('Unable to discover source context', err5);
// This is ignorable.
}
if (
this.config.capture &&
this.config.capture.maxDataSize === 0 &&
utils.satisfies(process.version, '>=10 <10.15.3 || >=11 <11.7 || >=12')
) {
that.logger.warn(NODE_10_CIRC_REF_MESSAGE);
}
// We can register as a debuggee now.
that.logger.debug('Starting debuggee, project', project);
that.running = true;
that.project = project;
that.debuggee = Debuglet.createDebuggee(
project,
id,
that.config.serviceContext,
sourceContext,
onGCP,
that.debug.packageInfo,
that.config.description,
undefined
);
that.scheduleRegistration_(0 /* immediately */);
that.emit('started');
}
/**
* isReady returns a promise that only resolved if the last breakpoint update
* happend within a duration (PROMISE_RESOLVE_CUT_OFF_IN_MILLISECONDS). This
* feature is mainly used in Google Cloud Function (GCF), as it is a
* serverless environment and we wanted to make sure debug agent always
* captures the snapshots.
*/
isReady(): Promise<void> {
if (
Date.now() <
this.breakpointFetchedTimestamp + PROMISE_RESOLVE_CUT_OFF_IN_MILLISECONDS
) {
return Promise.resolve();
} else {
if (this.breakpointFetched) return this.breakpointFetched.get();
this.breakpointFetched = new CachedPromise();
this.debuggeeRegistered.get().then(() => {
this.scheduleBreakpointFetch_(
0 /*immediately*/,
true /*only fetch once*/
);
});
return this.breakpointFetched.get();
}
}
/**
* @private
*/
// TODO: Determine the type of sourceContext
static createDebuggee(
projectId: string,
uid: string,
serviceContext: {
service?: string;
version?: string;
minorVersion_?: string;
},
sourceContext: SourceContext | undefined,
onGCP: boolean,
packageInfo: PackageInfo,
description?: string,
errorMessage?: string
): Debuggee {
const cwd = process.cwd();
const mainScript = path.relative(cwd, process.argv[1]);
const version =
'google.com/node-' +
(onGCP ? 'gcp' : 'standalone') +
'/v' +
packageInfo.version;
let desc = process.title + ' ' + mainScript;
const labels: {[key: string]: string} = {
'main script': mainScript,
'process.title': process.title,
'node version': process.versions.node,
'V8 version': process.versions.v8,
'agent.name': packageInfo.name,
'agent.version': packageInfo.version,
projectid: projectId,
};
if (serviceContext) {
if (
is.string(serviceContext.service) &&
serviceContext.service !== 'default'
) {
// As per app-engine-ids, the module label is not reported
// when it happens to be 'default'.
labels.module = serviceContext.service;
desc += ' module:' + serviceContext.service;
}
if (is.string(serviceContext.version)) {
labels.version = serviceContext.version;
desc += ' version:' + serviceContext.version;
}
if (is.string(serviceContext.minorVersion_)) {
// v--- intentional lowercase
labels.minorversion = serviceContext.minorVersion_;
}
}
if (!description && process.env.FUNCTION_NAME) {
description = 'Function: ' + process.env.FUNCTION_NAME;
}
if (description) {
desc += ' description:' + description;
}
const uniquifier = Debuglet._createUniquifier(
desc,
version,
uid,
sourceContext,
labels
);
const statusMessage = errorMessage
? new StatusMessage(StatusMessage.UNSPECIFIED, errorMessage, true)
: undefined;
const properties: DebuggeeProperties = {
project: projectId,
uniquifier,
description: desc,
agentVersion: version,
labels,
statusMessage,
packageInfo,
};
if (sourceContext) {
properties.sourceContexts = [sourceContext];
}
return new Debuggee(properties);
}
static runningOnGCP(): Promise<boolean> {
return metadata.isAvailable();
}
static async getClusterNameFromMetadata(): Promise<string> {
return (await metadata.instance('attributes/cluster-name')).data as string;
}
static async getSourceContextFromFile(): Promise<SourceContext> {
// If read errors, the error gets thrown to the caller.
const contents = await readFilep('source-context.json', 'utf8');
try {
return JSON.parse(contents);
} catch (e) {
throw new Error('Malformed source-context.json file: ' + e);
}
}
/**
* @param {number} seconds
* @private
*/
scheduleRegistration_(seconds: number): void {
const that = this;
function onError(err: Error) {
that.logger.error(
'Failed to re-register debuggee ' + that.project + ': ' + err
);
that.scheduleRegistration_(
Math.min(
(seconds + 1) * 2,
that.config.internal.maxRegistrationRetryDelay
)
);
}
setTimeout(() => {
if (!that.running) {
onError(new Error('Debuglet not running'));
return;
}
// TODO: Handle the case when `that.debuggee` is null.
that.controller.register(
that.debuggee as Debuggee,
(err: Error | null, result?: {debuggee: Debuggee}) => {
if (err) {
onError(err);
return;
}
// TODO: It appears that the Debuggee class never has an
// `isDisabled`
// field set. Determine if this is a bug or if the following
// code is not needed.
// TODO: Handle the case when `result` is undefined.
if ((result as {debuggee: Debuggee}).debuggee.isDisabled) {
// Server has disabled this debuggee / debug agent.
onError(new Error('Disabled by the server'));
that.emit('remotelyDisabled');
return;
}
// TODO: Handle the case when `result` is undefined.
that.logger.info(
'Registered as debuggee:',
(result as {debuggee: Debuggee}).debuggee.id
);
// TODO: Handle the case when `that.debuggee` is null.
// TODO: Handle the case when `result` is undefined.
(that.debuggee as Debuggee).id = (result as {
debuggee: Debuggee;
}).debuggee.id;
// TODO: Handle the case when `result` is undefined.
that.emit('registered', (result as {debuggee: Debuggee}).debuggee.id);
that.debuggeeRegistered.resolve();
if (!that.fetcherActive) {
that.scheduleBreakpointFetch_(0, false);
}
}
);
}, seconds * 1000).unref();
}
/**
* @param {number} seconds
* @param {boolean} once
* @private
*/
scheduleBreakpointFetch_(seconds: number, once: boolean): void {
const that = this;
if (!once) {
that.fetcherActive = true;
}
setTimeout(() => {
if (!that.running) {
return;
}
if (!once) {
assert(that.fetcherActive);
}
that.logger.info('Fetching breakpoints');
// TODO: Address the case when `that.debuggee` is `null`.
that.controller.listBreakpoints(
that.debuggee as Debuggee,
(err, response, body) => {
if (err) {
that.logger.error(
'Unable to fetch breakpoints – stopping fetcher',
err
);
that.fetcherActive = false;
// We back-off from fetching breakpoints, and try to register
// again after a while. Successful registration will restart the
// breakpoint fetcher.
that.updatePromise();
that.scheduleRegistration_(
that.config.internal.registerDelayOnFetcherErrorSec
);
return;
}
// TODO: Address the case where `response` is `undefined`.
switch (response!.statusCode) {
case 404:
// Registration expired. Deactivate the fetcher and queue
// re-registration, which will re-active breakpoint fetching.
that.logger.info('\t404 Registration expired.');
that.fetcherActive = false;
that.updatePromise();
that.scheduleRegistration_(0 /*immediately*/);
return;
default:
// TODO: Address the case where `response` is `undefined`.
that.logger.info('\t' + response!.statusCode + ' completed.');
if (!body) {
that.logger.error('\tinvalid list response: empty body');
that.scheduleBreakpointFetch_(
that.config.breakpointUpdateIntervalSec,
once
);
return;
}
if (body.waitExpired) {
that.logger.info('\tLong poll completed.');
that.scheduleBreakpointFetch_(0 /*immediately*/, once);
return;
}
const bps = (body.breakpoints || []).filter(
(bp: stackdriver.Breakpoint) => {
const action = bp.action || 'CAPTURE';
if (action !== 'CAPTURE' && action !== 'LOG') {
that.logger.warn(
'Found breakpoint with invalid action:',
action
);
bp.status = new StatusMessage(
StatusMessage.UNSPECIFIED,
BREAKPOINT_ACTION_MESSAGE,
true
);
that.rejectBreakpoint_(bp);
return false;
}
return true;
}
);
that.updateActiveBreakpoints_(bps);
if (Object.keys(that.activeBreakpointMap).length) {
that.logger.info(
formatBreakpoints(
'Active Breakpoints: ',
that.activeBreakpointMap
)
);
}
that.breakpointFetchedTimestamp = Date.now();
if (once) {
if (that.breakpointFetched) {
that.breakpointFetched.resolve();
that.breakpointFetched = null;
}
} else {
that.scheduleBreakpointFetch_(
that.config.breakpointUpdateIntervalSec,
once
);
}
return;
}
}
);
}, seconds * 1000).unref();
}
/**
* updatePromise_ is called when debuggee is expired. debuggeeRegistered
* CachedPromise will be refreshed. Also, breakpointFetched CachedPromise will
* be resolved so that uses (such as GCF users) will not hang forever to wait
* non-fetchable breakpoints.
*/
private updatePromise() {
this.debuggeeRegistered = new CachedPromise();
if (this.breakpointFetched) {
this.breakpointFetched.resolve();
this.breakpointFetched = null;
}
}
/**
* Given a list of server breakpoints, update our internal list of breakpoints
* @param {Array.<Breakpoint>} breakpoints
* @private
*/
updateActiveBreakpoints_(breakpoints: stackdriver.Breakpoint[]): void {
const that = this;
const updatedBreakpointMap = this.convertBreakpointListToMap_(breakpoints);
if (breakpoints.length) {
that.logger.info(
formatBreakpoints('Server breakpoints: ', updatedBreakpointMap)
);
}
breakpoints.forEach((breakpoint: stackdriver.Breakpoint) => {
// TODO: Address the case when `breakpoint.id` is `undefined`.
if (
!that.completedBreakpointMap[breakpoint.id as string] &&
!that.activeBreakpointMap[breakpoint.id as string]
) {
// New breakpoint
that.addBreakpoint_(breakpoint, err => {
if (err) {
that.completeBreakpoint_(breakpoint, false);
}
});
// Schedule the expiry of server breakpoints.
that.scheduleBreakpointExpiry_(breakpoint);
}
});
// Remove completed breakpoints that the server no longer cares about.
Debuglet.mapSubtract(
this.completedBreakpointMap,
updatedBreakpointMap
).forEach(breakpoint => {
// TODO: FIXME: breakpoint is a boolean here that doesn't have an id
// field. It is possible that breakpoint.id is always
// undefined!
// TODO: Make sure the use of `that` here is correct.
delete that.completedBreakpointMap[
((breakpoint as {}) as {id: number}).id
];
});
// Remove active breakpoints that the server no longer care about.
Debuglet.mapSubtract(
this.activeBreakpointMap,
updatedBreakpointMap
).forEach(bp => {
this.removeBreakpoint_(bp, true);
});
}
/**
* Array of breakpints get converted to Map of breakpoints, indexed by id
* @param {Array.<Breakpoint>} breakpointList
* @return {Object.<string, Breakpoint>} A map of breakpoint IDs to breakpoints.
* @private
*/
convertBreakpointListToMap_(
breakpointList: stackdriver.Breakpoint[]
): {[key: string]: stackdriver.Breakpoint} {
const map: {[id: string]: stackdriver.Breakpoint} = {};
breakpointList.forEach(breakpoint => {
// TODO: Address the case when `breakpoint.id` is `undefined`.
map[breakpoint.id as string] = breakpoint;
});
return map;
}
/**
* @param {Breakpoint} breakpoint
* @private
*/
removeBreakpoint_(
breakpoint: stackdriver.Breakpoint,
deleteFromV8: boolean
): void {
this.logger.info('\tdeleted breakpoint', breakpoint.id);
// TODO: Address the case when `breakpoint.id` is `undefined`.
delete this.activeBreakpointMap[breakpoint.id as string];
if (deleteFromV8 && this.v8debug) {
this.v8debug.clear(breakpoint, err => {
if (err) this.logger.error(err);
});
}
}
/**
* @param {Breakpoint} breakpoint
* @return {boolean} false on error
* @private
*/
addBreakpoint_(
breakpoint: stackdriver.Breakpoint,
cb: (ob: Error | string) => void
): void {
const that = this;
if (
!that.config.allowExpressions &&
(breakpoint.condition || breakpoint.expressions)
) {
that.logger.error(ALLOW_EXPRESSIONS_MESSAGE);
breakpoint.status = new StatusMessage(
StatusMessage.UNSPECIFIED,
ALLOW_EXPRESSIONS_MESSAGE,
true
);
setImmediate(() => {
cb(ALLOW_EXPRESSIONS_MESSAGE);
});
return;
}
if (utils.satisfies(process.version, '5.2 || <4')) {
const message = NODE_VERSION_MESSAGE;
that.logger.error(message);
breakpoint.status = new StatusMessage(
StatusMessage.UNSPECIFIED,
message,
true
);
setImmediate(() => {
cb(message);
});
return;
}
// TODO: Address the case when `that.v8debug` is `null`.
(that.v8debug as DebugApi).set(breakpoint, err1 => {
if (err1) {
cb(err1);
return;
}
that.logger.info('\tsuccessfully added breakpoint ' + breakpoint.id);
// TODO: Address the case when `breakpoint.id` is `undefined`.
that.activeBreakpointMap[breakpoint.id as string] = breakpoint;
if (breakpoint.action === 'LOG') {
// TODO: Address the case when `that.v8debug` is `null`.
(that.v8debug as DebugApi).log(
breakpoint,
(fmt: string, exprs: string[]) => {
that.config.log.logFunction(
`LOGPOINT: ${Debuglet.format(fmt, exprs)}`
);
},
() => {
// TODO: Address the case when `breakpoint.id` is `undefined`.
return that.completedBreakpointMap[breakpoint.id as string];
}
);
} else {
// TODO: Address the case when `that.v8debug` is `null`.
(that.v8debug as DebugApi).wait(breakpoint, err2 => {
if (err2) {
that.logger.error(err2);
cb(err2);
return;
}
that.logger.info('Breakpoint hit!: ' + breakpoint.id);
that.completeBreakpoint_(breakpoint);
});
}
});
}
/**
* Update the server that the breakpoint has been completed (captured, or
* expired).
* @param {Breakpoint} breakpoint
* @private
*/
completeBreakpoint_(
breakpoint: stackdriver.Breakpoint,
deleteFromV8 = true
): void {
const that = this;
that.logger.info('\tupdating breakpoint data on server', breakpoint.id);
that.controller.updateBreakpoint(
// TODO: Address the case when `that.debuggee` is `null`.
that.debuggee as Debuggee,
breakpoint,
(err /*, body*/) => {
if (err) {
that.logger.error('Unable to complete breakpoint on server', err);
} else {
// TODO: Address the case when `breakpoint.id` is `undefined`.
that.completedBreakpointMap[breakpoint.id as string] = true;
that.removeBreakpoint_(breakpoint, deleteFromV8);
}
}
);
}
/**
* Update the server that the breakpoint cannot be handled.
* @param {Breakpoint} breakpoint
* @private
*/
rejectBreakpoint_(breakpoint: stackdriver.Breakpoint): void {
const that = this;
// TODO: Address the case when `that.debuggee` is `null`.
that.controller.updateBreakpoint(that.debuggee as Debuggee, breakpoint, (
err /*, body*/
) => {
if (err) {
that.logger.error('Unable to complete breakpoint on server', err);
}
});
}
/**
* This schedules a delayed operation that will delete the breakpoint from the
* server after the expiry period.
* FIXME: we should cancel the timer when the breakpoint completes. Otherwise
* we hold onto the closure memory until the breapointExpirateion timeout.
* @param {Breakpoint} breakpoint Server breakpoint object
* @private
*/
scheduleBreakpointExpiry_(breakpoint: stackdriver.Breakpoint): void {
const that = this;
const now = Date.now() / 1000;
const createdTime = breakpoint.createdTime
? Number(breakpoint.createdTime.seconds)
: now;
const expiryTime = createdTime + that.config.breakpointExpirationSec;
setTimeout(() => {
that.logger.info('Expiring breakpoint ' + breakpoint.id);
breakpoint.status = {
description: {format: 'The snapshot has expired'},
isError: true,
refersTo: StatusMessage.BREAKPOINT_AGE,
};
that.completeBreakpoint_(breakpoint);
}, (expiryTime - now) * 1000).unref();
}
/**
* Stops the Debuglet. This is for testing purposes only. Stop should only be
* called on a agent that has started (i.e. emitted the 'started' event).
* Calling this while the agent is initializing may not necessarily stop all
* pending operations.
*/
stop(): void {
assert.ok(this.running, 'stop can only be called on a running agent');
this.logger.debug('Stopping Debuglet');
this.running = false;
this.emit('stopped');
}
/**
* Performs a set subtract. Returns A - B given maps A, B.
* @return {Array.<Breakpoint>} A array containing elements from A that are not
* in B.
*/
// TODO: Determine if this can be generic
// TODO: The code that uses this actually assumes the supplied arguments
// are objects and used as an associative array. Determine what is
// correct (the code or the docs).
// TODO: Fix the docs because the code actually assumes that the values
// of the keys in the supplied arguments have boolean values or
// Breakpoint values.
static mapSubtract<T, U>(A: {[key: string]: T}, B: {[key: string]: U}): T[] {
const removed = [];
for (const key in A) {
if (!B[key]) {
removed.push(A[key]);
}
}
return removed;
}
/**
* Formats the message base with placeholders `$0`, `$1`, etc
* by substituting the provided expressions. If more expressions
* are given than placeholders extra expressions are dropped.
*/
static format(base: string, exprs: string[]): string {
const tokens = Debuglet._tokenize(base, exprs.length);
for (let i = 0; i < tokens.length; i++) {
// TODO: Determine how to remove this explicit cast
if (!(tokens[i] as {v: string}).v) {
continue;
}
// TODO: Determine how to not have an explicit cast here
if ((tokens[i] as {v: string}).v === '$$') {
tokens[i] = '$';
continue;
}
for (let j = 0; j < exprs.length; j++) {
// TODO: Determine how to not have an explicit cast here
if ((tokens[i] as {v: string}).v === '$' + j) {
tokens[i] = exprs[j];
break;
}
}
}
return tokens.join('');
}
static _tokenize(
base: string,
exprLength: number
): Array<{v: string} | string> {
let acc = Debuglet._delimit(base, '$$');
for (let i = exprLength - 1; i >= 0; i--) {
const newAcc = [];
for (let j = 0; j < acc.length; j++) {
// TODO: Determine how to remove this explicit cast
if ((acc[j] as {v: string}).v) {
newAcc.push(acc[j]);
} else {
// TODO: Determine how to not have an explicit cast to string here
newAcc.push.apply(
newAcc,
Debuglet._delimit(acc[j] as string, '$' + i)
);
}
}
acc = newAcc;
}
return acc;
}
static _delimit(source: string, delim: string): Array<{v: string} | string> {
const pieces = source.split(delim);
const dest = [];
dest.push(pieces[0]);
for (let i = 1; i < pieces.length; i++) {
dest.push({v: delim}, pieces[i]);
}
return dest;
}
static _createUniquifier(
desc: string,
version: string,
uid: string,
sourceContext: SourceContext | undefined,
labels: {[key: string]: string}
): string {
const uniquifier =
desc +
version +
uid +
JSON.stringify(sourceContext) +
JSON.stringify(labels);
return crypto
.createHash('sha1')
.update(uniquifier)
.digest('hex');
}
}