mirror of
https://github.com/nodejs/node.git
synced 2026-11-09 15:11:11 -05:00
The inspector can accept a connection before an --inspect-brk target enters its frontend wait. Runtime.runIfWaitingForDebugger can then be handled too early, allowing the target to subsequently block forever. Wait for NodeRuntime.waitingForDebugger before initializing and releasing launched targets. Race the handshake against disconnects and apply it to both interactive and probe startup. Refs: https://github.com/nodejs/node/issues/64116 Assisted-by: codex:gpt-5.6-sol Co-authored-by: Archkon <180910180+Archkon@users.noreply.github.com> Signed-off-by: Archkon <180910180+Archkon@users.noreply.github.com> Signed-off-by: Filip Skokan <panva.ip@gmail.com> PR-URL: https://github.com/nodejs/node/pull/65194 Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com> Reviewed-By: James M Snell <jasnell@gmail.com>
1099 lines
39 KiB
JavaScript
1099 lines
39 KiB
JavaScript
'use strict';
|
|
|
|
const {
|
|
ArrayFrom,
|
|
ArrayIsArray,
|
|
ArrayPrototypeJoin,
|
|
ArrayPrototypeMap,
|
|
ArrayPrototypePush,
|
|
ArrayPrototypeSlice,
|
|
ArrayPrototypeSome,
|
|
FunctionPrototypeBind,
|
|
JSONStringify,
|
|
NumberIsNaN,
|
|
NumberParseInt,
|
|
ObjectEntries,
|
|
PromiseWithResolvers,
|
|
RegExpPrototypeExec,
|
|
RegExpPrototypeSymbolSplit,
|
|
SafeMap,
|
|
SafeSet,
|
|
StringPrototypeIncludes,
|
|
StringPrototypeSlice,
|
|
StringPrototypeStartsWith,
|
|
StringPrototypeTrim,
|
|
Symbol,
|
|
} = primordials;
|
|
|
|
const { clearTimeout, setTimeout } = require('timers');
|
|
const { SideEffectFreeRegExpPrototypeSymbolReplace } = require('internal/util');
|
|
const debug = require('internal/util/debuglog').debuglog('inspect_probe');
|
|
|
|
const InspectClient = require('internal/debugger/inspect_client');
|
|
const {
|
|
ensureTrailingNewline,
|
|
launchChildProcess,
|
|
waitForDebugger,
|
|
} = require('internal/debugger/inspect_helpers');
|
|
|
|
const { ERR_DEBUGGER_STARTUP_ERROR } = require('internal/errors').codes;
|
|
const {
|
|
exitCodes: {
|
|
kGenericUserError,
|
|
kNoFailure,
|
|
},
|
|
} = internalBinding('errors');
|
|
|
|
const kProbeDefaultTimeout = 30000;
|
|
const kProbeVersion = 2;
|
|
const kProbeDisconnectSentinel = 'Waiting for the debugger to disconnect...';
|
|
const kProbeAttachedSentinel = 'Debugger attached.';
|
|
const kProbeListeningPrefix = 'Debugger listening on ws://';
|
|
const kProbeEndingPrefix = 'Debugger ending on ws://';
|
|
const kProbeHelpLine =
|
|
'For help, see: https://nodejs.org/learn/getting-started/debugging';
|
|
// Thrown by `callCdp` after `recordInspectorFailure` has handled the failure,
|
|
// so callers can short-circuit without recording duplicate events.
|
|
const kInspectorFailedSentinel = Symbol('probe.inspectorFailed');
|
|
const kStartupTeardownAdvice =
|
|
'The target startup may have torn down the inspector. If startup does ' +
|
|
'not touch the inspector, this is likely a Node.js bug. Please file an issue.';
|
|
const kReviewProbeExprAdvice = 'If the failure repeats, review the probe expression.';
|
|
const kDigitsRegex = /^\d+$/;
|
|
const kInspectPortRegex = /^--inspect-port=(\d+)$/;
|
|
|
|
/**
|
|
* The probe request specified by --probe, serialized into the public report.
|
|
* @typedef {object} ProbeTarget
|
|
* @property {string} suffix The raw suffix supplied by the user.
|
|
* @property {number} line 1-based line number.
|
|
* @property {number} [column] 1-based column number.
|
|
*/
|
|
|
|
/**
|
|
* Location where the probe was evaluated, serialized into the public report.
|
|
* @typedef {object} Location
|
|
* @property {string} [url] V8-reported script URL, if known.
|
|
* @property {number} line 1-based line number.
|
|
* @property {number} column 1-based column number.
|
|
*/
|
|
|
|
/**
|
|
* Per-breakpoint state keyed by V8 `breakpointId` from `Debugger.setBreakpointByUrl`.
|
|
* @typedef {object} BreakpointDefinition
|
|
* @property {number[]} probeIndices Indices into probes that bound to this breakpoint.
|
|
*/
|
|
|
|
/**
|
|
* Per-probe state corresponds to each --probe --expr pair.
|
|
* @typedef {object} Probe
|
|
* @property {string} expr Expression to evaluate on hit.
|
|
* @property {ProbeTarget} target User's original --probe request shape.
|
|
* @property {string} [condition] Condition from --cond. V8 only breaks when it is truthy.
|
|
* Scoped to the location, so probes sharing a location all carry the same value.
|
|
* @property {number} maxHit Per-probe hit limit from --max-hit. Infinity when unlimited.
|
|
* @property {number} hits Count of hits observed.
|
|
*/
|
|
|
|
function probeReachedLimit(probe) {
|
|
return probe.hits >= probe.maxHit;
|
|
}
|
|
|
|
function parseUnsignedInteger(value, name, allowZero = false) {
|
|
if (typeof value !== 'string' || RegExpPrototypeExec(kDigitsRegex, value) === null) {
|
|
throw new ERR_DEBUGGER_STARTUP_ERROR(`Invalid ${name}: ${value}`);
|
|
}
|
|
const parsed = NumberParseInt(value, 10);
|
|
if (NumberIsNaN(parsed) || (!allowZero && parsed < 1)) {
|
|
throw new ERR_DEBUGGER_STARTUP_ERROR(`Invalid ${name}: ${value}`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
/**
|
|
* @param {string} text Raw `--probe` argument.
|
|
* @returns {ProbeTarget}
|
|
*/
|
|
function parseProbeTarget(text) {
|
|
// Accepts file:line or file:line:column formats.
|
|
// Non-greedy (.+?) allows Windows drive-letter paths like C:\foo.js:10.
|
|
const match = RegExpPrototypeExec(/^(.+?):(\d+)(?::(\d+))?$/, text);
|
|
if (match === null) {
|
|
throw new ERR_DEBUGGER_STARTUP_ERROR(`Invalid probe location: ${text}`);
|
|
}
|
|
|
|
const suffix = match[1];
|
|
const line = parseUnsignedInteger(match[2], 'probe location');
|
|
// Column is left as undefined if the user does not supply one.
|
|
const column = match[3] !== undefined ? parseUnsignedInteger(match[3], 'probe location') : undefined;
|
|
return { suffix, line, column };
|
|
}
|
|
|
|
function formatTargetText(target) {
|
|
const { suffix, line, column } = target;
|
|
return column === undefined ? `${suffix}:${line}` : `${suffix}:${line}:${column}`;
|
|
}
|
|
|
|
// Identity of a probe location. Probes sharing a key share one V8 breakpoint,
|
|
// so this must stay in sync between condition validation and breakpoint setup.
|
|
function locationKey(target) {
|
|
return `${target.suffix}\n${target.line}\n${target.column ?? ''}`;
|
|
}
|
|
|
|
function formatPendingProbeLocations(probes, pending) {
|
|
const seen = new SafeSet();
|
|
for (const probeIndex of pending) {
|
|
seen.add(formatTargetText(probes[probeIndex].target));
|
|
}
|
|
return ArrayPrototypeJoin(ArrayFrom(seen), ', ');
|
|
}
|
|
|
|
// Trim inspector-side noise lines from stderr for reporting child errors.
|
|
function trimProbeChildStderr(stderr) {
|
|
const lines = RegExpPrototypeSymbolSplit(/\r\n|\r|\n/g, stderr);
|
|
const kept = [];
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (line === '' && i === lines.length - 1) { continue; }
|
|
if (line === kProbeDisconnectSentinel) { continue; }
|
|
if (line === kProbeAttachedSentinel) { continue; }
|
|
if (line === kProbeHelpLine) { continue; }
|
|
if (StringPrototypeStartsWith(line, kProbeListeningPrefix)) { continue; }
|
|
if (StringPrototypeStartsWith(line, kProbeEndingPrefix)) { continue; }
|
|
ArrayPrototypePush(kept, line);
|
|
}
|
|
return ArrayPrototypeJoin(kept, '\n');
|
|
}
|
|
|
|
function formatPreviewPropertyValue(property) {
|
|
if (property.type === 'string') {
|
|
return JSONStringify(property.value ?? '');
|
|
}
|
|
return property.value ?? property.type;
|
|
}
|
|
|
|
function trimRemoteObject(result) {
|
|
if (result === undefined || result === null || typeof result !== 'object') {
|
|
return result;
|
|
}
|
|
|
|
if (ArrayIsArray(result)) {
|
|
return ArrayPrototypeMap(result, trimRemoteObject);
|
|
}
|
|
|
|
const trimmed = { __proto__: null };
|
|
for (const { 0: key, 1: value } of ObjectEntries(result)) {
|
|
if (key === 'objectId' || key === 'className') {
|
|
continue;
|
|
}
|
|
trimmed[key] = trimRemoteObject(value);
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function stripProbePreviews(value) {
|
|
if (value === undefined || value === null || typeof value !== 'object') {
|
|
return value;
|
|
}
|
|
|
|
if (ArrayIsArray(value)) {
|
|
return ArrayPrototypeMap(value, stripProbePreviews);
|
|
}
|
|
|
|
const stripped = { __proto__: null };
|
|
for (const { 0: key, 1: entry } of ObjectEntries(value)) {
|
|
if (key === 'preview') {
|
|
continue;
|
|
}
|
|
stripped[key] = stripProbePreviews(entry);
|
|
}
|
|
return stripped;
|
|
}
|
|
|
|
// Format CDP RemoteObject values into more readable formats.
|
|
function formatRemoteObject(result) {
|
|
if (result === undefined) { return 'undefined'; }
|
|
|
|
switch (result.type) {
|
|
case 'undefined':
|
|
return 'undefined';
|
|
case 'string':
|
|
return JSONStringify(result.value);
|
|
case 'number':
|
|
if (result.unserializableValue !== undefined) {
|
|
return result.unserializableValue;
|
|
}
|
|
return `${result.value}`;
|
|
case 'boolean':
|
|
return `${result.value}`;
|
|
case 'symbol':
|
|
return result.description || 'Symbol()';
|
|
case 'bigint':
|
|
return result.unserializableValue ?? result.description ?? '0n';
|
|
case 'function':
|
|
return result.description || 'function()';
|
|
case 'object':
|
|
if (result.subtype === 'null') {
|
|
return 'null';
|
|
}
|
|
if (result.subtype === 'error') {
|
|
return result.description || 'Error';
|
|
}
|
|
if (result.preview !== undefined) {
|
|
const properties = ArrayPrototypeJoin(ArrayPrototypeMap(
|
|
result.preview.properties,
|
|
result.preview.subtype === 'array' ?
|
|
(property) => formatPreviewPropertyValue(property) :
|
|
(property) => `${property.name}: ${formatPreviewPropertyValue(property)}`,
|
|
), ', ');
|
|
const suffix = result.preview.overflow ? ', ...' : '';
|
|
if (result.preview.subtype === 'array') {
|
|
return `[${properties}${suffix}]`;
|
|
}
|
|
return `{${properties}${suffix}}`;
|
|
}
|
|
return result.description || result.className || 'Object';
|
|
default:
|
|
return `${result.value ?? result.description ?? ''}`;
|
|
}
|
|
}
|
|
|
|
function formatHitLocation(location) {
|
|
return `${location.url}:${location.line}:${location.column}`;
|
|
}
|
|
|
|
// Built human-readable text output for probe reports.
|
|
function buildProbeTextReport(report) {
|
|
const lines = [];
|
|
|
|
for (const result of report.results) {
|
|
if (result.event === 'hit') {
|
|
const probe = report.probes[result.probe];
|
|
// If Debugger.scriptParsed was missed and the URL is unknown, fall back to the user's
|
|
// probe target text for readability. This is unlikely unless there's a bug in V8.
|
|
const locText = (result.location.url !== undefined) ?
|
|
formatHitLocation(result.location) : formatTargetText(probe.target);
|
|
ArrayPrototypePush(lines, `Hit ${result.hit} at ${locText}`);
|
|
if (result.error !== undefined) {
|
|
ArrayPrototypePush(lines, ` [error] ${probe.expr} = ${result.error.message}`);
|
|
} else {
|
|
ArrayPrototypePush(lines,
|
|
` ${probe.expr} = ` +
|
|
`${formatRemoteObject(result.result)}`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (result.event === 'completed') {
|
|
ArrayPrototypePush(lines, 'Completed');
|
|
continue;
|
|
}
|
|
|
|
if (result.event === 'miss') {
|
|
ArrayPrototypePush(lines,
|
|
`Missed probes: ` +
|
|
`${formatPendingProbeLocations(report.probes, result.pending)}`);
|
|
continue;
|
|
}
|
|
|
|
if (result.event === 'timeout') {
|
|
ArrayPrototypePush(lines, result.error.message);
|
|
continue;
|
|
}
|
|
|
|
if (result.event === 'error') {
|
|
ArrayPrototypePush(lines, result.error.message);
|
|
if (result.error.stderr !== undefined) {
|
|
ArrayPrototypePush(lines, ' [stderr]');
|
|
const stderrLines = RegExpPrototypeSymbolSplit(
|
|
/\r\n|\r|\n/g,
|
|
result.error.stderr,
|
|
);
|
|
for (let i = 0; i < stderrLines.length; i++) {
|
|
if (stderrLines[i] === '' && i === stderrLines.length - 1) {
|
|
continue;
|
|
}
|
|
ArrayPrototypePush(lines, ` ${stderrLines[i]}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return ensureTrailingNewline(ArrayPrototypeJoin(lines, '\n'));
|
|
}
|
|
|
|
function parseProbeTokens(tokens, args) {
|
|
let port = 0;
|
|
let preview = false;
|
|
let timeout = kProbeDefaultTimeout;
|
|
let json = false;
|
|
let sawSeparator = false;
|
|
let childStartIndex = args.length;
|
|
let pendingTarget;
|
|
let expectedExprIndex = -1;
|
|
const probes = [];
|
|
|
|
for (const token of tokens) {
|
|
if (token.kind === 'option-terminator') {
|
|
sawSeparator = true;
|
|
childStartIndex = token.index + 1;
|
|
break;
|
|
}
|
|
|
|
if (pendingTarget !== undefined) {
|
|
if (token.kind === 'option' &&
|
|
token.name === 'expr' &&
|
|
token.index === expectedExprIndex &&
|
|
token.value !== undefined) {
|
|
ArrayPrototypePush(probes, {
|
|
expr: token.value,
|
|
target: pendingTarget,
|
|
});
|
|
pendingTarget = undefined;
|
|
continue;
|
|
}
|
|
|
|
throw new ERR_DEBUGGER_STARTUP_ERROR(
|
|
'Each --probe must be followed immediately by --expr <expr>');
|
|
}
|
|
|
|
if (token.kind === 'positional') {
|
|
childStartIndex = token.index;
|
|
break;
|
|
}
|
|
|
|
switch (token.name) {
|
|
case 'json':
|
|
json = true;
|
|
break;
|
|
case 'timeout':
|
|
if (token.value === undefined) {
|
|
throw new ERR_DEBUGGER_STARTUP_ERROR(`Missing value for ${token.rawName}`);
|
|
}
|
|
timeout = parseUnsignedInteger(token.value, 'timeout', true);
|
|
break;
|
|
case 'port':
|
|
if (token.value === undefined) {
|
|
throw new ERR_DEBUGGER_STARTUP_ERROR(`Missing value for ${token.rawName}`);
|
|
}
|
|
port = parseUnsignedInteger(token.value, 'inspector port', true);
|
|
break;
|
|
case 'preview':
|
|
preview = true;
|
|
break;
|
|
case 'probe':
|
|
pendingTarget = parseProbeTarget(token.value);
|
|
expectedExprIndex = token.index + (token.inlineValue ? 1 : 2);
|
|
break;
|
|
case 'expr':
|
|
throw new ERR_DEBUGGER_STARTUP_ERROR('Unexpected --expr before --probe');
|
|
case 'max-hit': {
|
|
if (probes.length === 0) {
|
|
throw new ERR_DEBUGGER_STARTUP_ERROR('Unexpected --max-hit before --probe');
|
|
}
|
|
if (token.value === undefined) {
|
|
throw new ERR_DEBUGGER_STARTUP_ERROR(`Missing value for ${token.rawName}`);
|
|
}
|
|
const probe = probes[probes.length - 1];
|
|
if (probe.maxHit !== undefined) {
|
|
throw new ERR_DEBUGGER_STARTUP_ERROR('Duplicate --max-hit for a single --probe');
|
|
}
|
|
probe.maxHit = parseUnsignedInteger(token.value, 'max-hit');
|
|
break;
|
|
}
|
|
case 'cond': {
|
|
if (probes.length === 0) {
|
|
throw new ERR_DEBUGGER_STARTUP_ERROR('Unexpected --cond before --probe');
|
|
}
|
|
// A blank condition does not act as a real predicate in V8 (an empty
|
|
// string always breaks), so reject it rather than silently mislead.
|
|
if (token.value === undefined || StringPrototypeTrim(token.value) === '') {
|
|
throw new ERR_DEBUGGER_STARTUP_ERROR(`Missing value for ${token.rawName}`);
|
|
}
|
|
const probe = probes[probes.length - 1];
|
|
if (probe.condition !== undefined) {
|
|
throw new ERR_DEBUGGER_STARTUP_ERROR('A --probe can have at most one --cond');
|
|
}
|
|
probe.condition = token.value;
|
|
break;
|
|
}
|
|
default:
|
|
if (probes.length > 0) {
|
|
throw new ERR_DEBUGGER_STARTUP_ERROR(
|
|
'Use -- before child Node.js flags in probe mode');
|
|
}
|
|
throw new ERR_DEBUGGER_STARTUP_ERROR(`Unknown probe option: ${token.rawName}`);
|
|
}
|
|
}
|
|
|
|
if (pendingTarget !== undefined) {
|
|
throw new ERR_DEBUGGER_STARTUP_ERROR(
|
|
'Each --probe must be followed immediately by --expr <expr>');
|
|
}
|
|
|
|
if (probes.length === 0) {
|
|
throw new ERR_DEBUGGER_STARTUP_ERROR(
|
|
'Probe mode requires at least one --probe <loc> --expr <expr> group');
|
|
}
|
|
|
|
// V8 allows only one breakpoint per location, so probes sharing a location
|
|
// cannot carry different conditions.
|
|
const conditionByLocation = new SafeMap();
|
|
for (const { target, condition } of probes) {
|
|
const key = locationKey(target);
|
|
// All probes at the same location must share one condition. We split the
|
|
// existence check since if one probe does not have a condition (= undefined),
|
|
// then all probes at the location must also omit it.
|
|
if (conditionByLocation.has(key)) {
|
|
if (conditionByLocation.get(key) !== condition) {
|
|
throw new ERR_DEBUGGER_STARTUP_ERROR(
|
|
`Probes at ${formatTargetText(target)} must use the same --cond (or none)`);
|
|
}
|
|
} else {
|
|
conditionByLocation.set(key, condition);
|
|
}
|
|
}
|
|
|
|
const childArgv = ArrayPrototypeSlice(args, childStartIndex);
|
|
if (childArgv.length === 0) {
|
|
throw new ERR_DEBUGGER_STARTUP_ERROR('Probe mode requires a child script');
|
|
}
|
|
|
|
if (!sawSeparator && StringPrototypeStartsWith(childArgv[0], '-')) {
|
|
throw new ERR_DEBUGGER_STARTUP_ERROR('Use -- before child Node.js flags in probe mode');
|
|
}
|
|
|
|
let skipPortPreflight = port === 0;
|
|
for (const arg of childArgv) {
|
|
const inspectPortMatch = RegExpPrototypeExec(kInspectPortRegex, arg);
|
|
if (inspectPortMatch === null) {
|
|
continue;
|
|
}
|
|
if (inspectPortMatch[1] === '0') {
|
|
skipPortPreflight = true;
|
|
continue;
|
|
}
|
|
throw new ERR_DEBUGGER_STARTUP_ERROR(
|
|
'Only child --inspect-port=0 is supported in probe mode');
|
|
}
|
|
|
|
return {
|
|
host: '127.0.0.1',
|
|
port,
|
|
preview,
|
|
timeout,
|
|
json,
|
|
probes,
|
|
childArgv,
|
|
skipPortPreflight,
|
|
};
|
|
}
|
|
|
|
class ProbeInspectorSession {
|
|
constructor(options) {
|
|
this.options = options;
|
|
this.client = new InspectClient();
|
|
this.child = null;
|
|
this.cleanupStarted = false;
|
|
this.childStderr = '';
|
|
this.disconnectRequested = false;
|
|
this.finished = false;
|
|
// True once the inspector WebSocket connects. Event handlers ignore
|
|
// pre-connect exits/closes so launch/connect failures report through run().
|
|
this.connected = false;
|
|
// True once breakpoints are bound and the target is released from --inspect-brk
|
|
// via `Runtime.runIfWaitingForDebugger`.
|
|
// Distinguishes "exited before user code ran" from "exited during the live session".
|
|
this.started = false;
|
|
// A sliding buffer of at most kProbeDisconnectSentinel.length to detect disconnect.
|
|
this.disconnectSentinelBuffer = '';
|
|
/** @type {Map<string, BreakpointDefinition>} keyed by V8 breakpointId. */
|
|
this.breakpointDefinitions = new SafeMap();
|
|
/** @type {Map<string, string>} scriptId -> URL. */
|
|
this.scriptIdToUrl = new SafeMap();
|
|
this.results = [];
|
|
this.timeout = null;
|
|
// The currently-awaited CDP request, or null when no request is in flight.
|
|
/** @type {{ method: string, probe: { index: number, location: Location } | null } | null} */
|
|
this.inFlight = null;
|
|
// Most recently completed probe, used for `error.probe` when a non-evaluate CDP call rejects.
|
|
this.lastProbeIndex = null;
|
|
const { promise, resolve } = PromiseWithResolvers();
|
|
this.completionPromise = promise;
|
|
this.resolveCompletion = resolve;
|
|
/** @type {Probe[]} */
|
|
this.probes = ArrayPrototypeMap(options.probes,
|
|
({ expr, target, maxHit, condition }) =>
|
|
({ expr, target, condition, maxHit: maxHit ?? Infinity, hits: 0 }));
|
|
this.onChildOutput = FunctionPrototypeBind(this.onChildOutput, this);
|
|
this.onChildExit = FunctionPrototypeBind(this.onChildExit, this);
|
|
this.onClientClose = FunctionPrototypeBind(this.onClientClose, this);
|
|
this.onPaused = FunctionPrototypeBind(this.onPaused, this);
|
|
this.onScriptParsed = FunctionPrototypeBind(this.onScriptParsed, this);
|
|
}
|
|
|
|
// Marking the probing process to exit with 0, recorded hits are trustworthy.
|
|
finishWithTrustedResult(terminal) {
|
|
this.finish(kNoFailure, terminal);
|
|
}
|
|
|
|
// Marking the probing process to exit with 1, recorded hits are best-effort.
|
|
finishWithUnreliableResult(terminal) {
|
|
this.finish(kGenericUserError, terminal);
|
|
}
|
|
|
|
finish(exitCode, terminal) {
|
|
if (this.finished) { return; }
|
|
debug('finish: exitCode=%d, terminal=%s', exitCode, terminal?.event);
|
|
this.finished = true;
|
|
if (this.timeout !== null) {
|
|
clearTimeout(this.timeout);
|
|
this.timeout = null;
|
|
}
|
|
this.resolveCompletion({ exitCode, terminal });
|
|
}
|
|
|
|
getProbeTargetExitEvent(exitCode, signal) {
|
|
const pending = this.getPendingProbeIndices();
|
|
const how = signal !== null ? `with signal ${signal}` : `with code ${exitCode}`;
|
|
const status = pending.length === 0 ?
|
|
'target completion' : `probes: ${formatPendingProbeLocations(this.probes, pending)}`;
|
|
const error = {
|
|
__proto__: null,
|
|
code: 'probe_target_exit',
|
|
message: `Target exited ${how} before ${status}`,
|
|
};
|
|
if (exitCode !== null) { error.exitCode = exitCode; }
|
|
if (signal !== null) { error.signal = signal; }
|
|
error.stderr = trimProbeChildStderr(this.childStderr);
|
|
return { event: 'error', pending, error };
|
|
}
|
|
|
|
onChildOutput(text, which) {
|
|
if (which !== 'stderr') { return; }
|
|
debug('child stderr: %j', text);
|
|
|
|
this.childStderr += text;
|
|
|
|
const combined = this.disconnectSentinelBuffer + text;
|
|
// Detect the disconnect sentinel.
|
|
if (this.connected &&
|
|
StringPrototypeIncludes(combined, kProbeDisconnectSentinel)) {
|
|
debug('disconnect sentinel detected, resetting client');
|
|
this.disconnectRequested = true;
|
|
this.client.reset();
|
|
}
|
|
|
|
if (combined.length > kProbeDisconnectSentinel.length) { // Slide the buffer.
|
|
this.disconnectSentinelBuffer = StringPrototypeSlice(combined,
|
|
combined.length - kProbeDisconnectSentinel.length);
|
|
} else {
|
|
this.disconnectSentinelBuffer = combined;
|
|
}
|
|
}
|
|
|
|
onChildExit(code, signal) {
|
|
debug('child exit: code=%s signal=%s connected=%s started=%s finished=%s inFlight=%j',
|
|
code, signal, this.connected, this.started, this.finished, this.inFlight);
|
|
// Pre-connect exits are deliberately silent: the target never reached
|
|
// a state where probes could be set, so any report would be empty.
|
|
if (!this.connected) { return; }
|
|
if (this.finished) { return; }
|
|
if (this.inFlight !== null && this.inFlight.probe !== null) {
|
|
this.recordInspectorFailure({
|
|
reason: 'Target process exited during probe evaluation',
|
|
advice: kReviewProbeExprAdvice,
|
|
});
|
|
return;
|
|
}
|
|
if (this.started && code === 0 && signal === null) {
|
|
const pending = this.getPendingProbeIndices();
|
|
this.finishWithTrustedResult(pending.length === 0 ? { event: 'completed' } : { event: 'miss', pending });
|
|
return;
|
|
}
|
|
this.finishWithTrustedResult(this.getProbeTargetExitEvent(code, signal));
|
|
}
|
|
|
|
onClientClose() {
|
|
debug('client close: disconnectRequested=%s finished=%s inFlight=%j',
|
|
this.disconnectRequested, this.finished, this.inFlight);
|
|
if (!this.connected) { return; }
|
|
if (this.disconnectRequested) { return; }
|
|
if (this.finished) { return; }
|
|
|
|
if (this.child.exitCode !== null || this.child.signalCode !== null) {
|
|
this.onChildExit(this.child.exitCode, this.child.signalCode);
|
|
return;
|
|
}
|
|
if (!this.started) {
|
|
this.recordInspectorFailure({
|
|
reason: 'Inspector connection lost before probes started',
|
|
advice: kStartupTeardownAdvice,
|
|
});
|
|
return;
|
|
}
|
|
if (this.inFlight !== null) {
|
|
if (this.inFlight.probe !== null || this.lastProbeIndex !== null) {
|
|
this.recordInspectorFailure({
|
|
reason: 'Inspector connection lost during probe activity',
|
|
advice: 'A probe expression may have caused the disconnection. ' +
|
|
'If the failure repeats, review the probe expressions.',
|
|
});
|
|
} else {
|
|
this.recordInspectorFailure({
|
|
reason: 'Inspector connection lost during inspector activity',
|
|
advice: 'This is likely a Node.js bug. Please file an issue.',
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
this.recordInspectorFailure({
|
|
reason: 'Inspector connection lost',
|
|
advice: 'The target was likely terminated externally. If the failure ' +
|
|
'persists, check the target\'s process environment.',
|
|
});
|
|
}
|
|
|
|
onPaused(params) {
|
|
debug('paused: finished=%d, reason=%s hitBreakpoints=%j', this.finished, params.reason, params.hitBreakpoints);
|
|
this.handlePaused(params).catch((error) => {
|
|
if (error === kInspectorFailedSentinel) { return; }
|
|
this.recordInspectorFailure({
|
|
reason: 'Probe mode encountered an unexpected internal failure',
|
|
advice: 'This is likely a Node.js bug. Please file an issue.',
|
|
internalError: error,
|
|
});
|
|
});
|
|
}
|
|
|
|
async handlePaused(params) {
|
|
if (this.finished) { return; }
|
|
// Ignore pauses that arrive before breakpoint setup is complete. Once
|
|
// startup is marked complete, `Runtime.runIfWaitingForDebugger` may surface
|
|
// the initial --inspect-brk pause, which the normal resume path handles.
|
|
if (!this.started) { return; }
|
|
|
|
const hitBreakpoints = params.hitBreakpoints;
|
|
if (hitBreakpoints === undefined || hitBreakpoints.length === 0) {
|
|
await this.resume();
|
|
return;
|
|
}
|
|
|
|
const topFrame = params.callFrames?.[0];
|
|
const callFrameId = topFrame?.callFrameId;
|
|
if (callFrameId === undefined) {
|
|
await this.resume();
|
|
return;
|
|
}
|
|
|
|
const { scriptId, lineNumber, columnNumber } = topFrame.location;
|
|
// `Debugger.scriptParsed` should always precede a pause for the same script.
|
|
// It should only be undefined if there's a bug (even in that case, just omit it).
|
|
const location = {
|
|
url: this.scriptIdToUrl.get(scriptId),
|
|
// CDP locations are 0-based, locations in public report are 1-based.
|
|
line: lineNumber + 1,
|
|
column: columnNumber + 1,
|
|
};
|
|
for (const breakpointId of hitBreakpoints) {
|
|
// The breakpoint ID is stable even for scripts parsed after the initial resolution
|
|
// so we can count on it here.
|
|
const definition = this.breakpointDefinitions.get(breakpointId);
|
|
if (definition === undefined) { continue; }
|
|
|
|
// Evaluate the expressions in the order they appear on the command line.
|
|
for (const probeIndex of definition.probeIndices) {
|
|
await this.evaluateProbe(callFrameId, probeIndex, location);
|
|
if (this.finished) { break; }
|
|
}
|
|
}
|
|
|
|
// Finish proactively as soon as any probe reaches its hit limit. All probes
|
|
// hit in this pause are recorded first, then the session ends.
|
|
// TODO(joyeecheung): When we implement attach mode, this teardown must
|
|
// resume-and-detach rather than kill, since the target is not ours.
|
|
if (!this.finished && ArrayPrototypeSome(this.probes, probeReachedLimit)) {
|
|
this.finishWithTrustedResult({ event: 'completed' });
|
|
return;
|
|
}
|
|
|
|
await this.resume();
|
|
}
|
|
|
|
async evaluateProbe(callFrameId, probeIndex, location) {
|
|
if (this.finished) { return; }
|
|
const probe = this.probes[probeIndex];
|
|
const evaluation = await this.callCdp(
|
|
'Debugger.evaluateOnCallFrame',
|
|
{ callFrameId, expression: probe.expr, generatePreview: true },
|
|
{ __proto__: null, index: probeIndex, location },
|
|
);
|
|
this.lastProbeIndex = probeIndex;
|
|
|
|
probe.hits++;
|
|
const result = { probe: probeIndex, event: 'hit', hit: probe.hits, location };
|
|
if (evaluation.exceptionDetails !== undefined) {
|
|
result.error = {
|
|
__proto__: null,
|
|
message: evaluation.result?.description ?? 'Probe expression failed',
|
|
details: { __proto__: null, exception: trimRemoteObject(evaluation.exceptionDetails) },
|
|
};
|
|
} else {
|
|
result.result = trimRemoteObject(evaluation.result);
|
|
}
|
|
ArrayPrototypePush(this.results, result);
|
|
}
|
|
|
|
async resume() {
|
|
if (this.finished) { return; }
|
|
await this.callCdp('Debugger.resume');
|
|
}
|
|
|
|
async callCdp(method, params, probe = null) {
|
|
if (this.finished) { throw kInspectorFailedSentinel; }
|
|
const request = { __proto__: null, method, probe };
|
|
this.inFlight = request;
|
|
debug('CDP -> %s%s', method, probe !== null ? `, probe=${probe.index}` : '');
|
|
try {
|
|
const result = await this.client.callMethod(method, params);
|
|
// A timeout or process exit can finish the report while the CDP request
|
|
// is still outstanding. Ignore the late reply in that case.
|
|
if (this.finished) {
|
|
debug('CDP <- %s discarded (already finished)', method);
|
|
throw kInspectorFailedSentinel;
|
|
}
|
|
debug('CDP <- %s (success)', method);
|
|
return result;
|
|
} catch (err) {
|
|
if (err !== kInspectorFailedSentinel) { // Already handled.
|
|
debug('CDP <- %s error: %s', method, err?.code);
|
|
}
|
|
if (this.disconnectRequested) {
|
|
// Only the in-flight evaluation gets attribution. Other rejections
|
|
// under disconnect are downstream noise.
|
|
if (probe !== null) {
|
|
this.recordInspectorFailure({
|
|
reason: 'Target process exited during probe evaluation',
|
|
advice: kReviewProbeExprAdvice,
|
|
});
|
|
}
|
|
throw kInspectorFailedSentinel;
|
|
}
|
|
// Another event handler already recorded the terminal event.
|
|
if (this.finished) { throw kInspectorFailedSentinel; }
|
|
if (!this.started) {
|
|
this.recordInspectorFailure({
|
|
reason: 'Probe mode failed before user code ran',
|
|
advice: kStartupTeardownAdvice,
|
|
cdpError: err,
|
|
});
|
|
} else if (method === 'Debugger.evaluateOnCallFrame') {
|
|
this.recordInspectorFailure({
|
|
reason: 'The inspector could not evaluate a probe expression',
|
|
advice: `The rejection details are recorded on the probe hit. ${kReviewProbeExprAdvice}`,
|
|
cdpError: err,
|
|
});
|
|
} else if (this.lastProbeIndex !== null) {
|
|
this.recordInspectorFailure({
|
|
reason: 'Probe session failed after a probe evaluation',
|
|
advice: 'If the failure repeats, review the most-recently-evaluated probe expression.',
|
|
cdpError: err,
|
|
});
|
|
} else {
|
|
this.recordInspectorFailure({
|
|
reason: 'Probe session failed during inspector activity',
|
|
advice: 'This is likely a Node.js bug. Please file an issue.',
|
|
cdpError: err,
|
|
});
|
|
}
|
|
throw kInspectorFailedSentinel;
|
|
} finally {
|
|
if (this.inFlight === request) {
|
|
this.inFlight = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Records the first inspector-side terminal for the session, later callers are ignored.
|
|
recordInspectorFailure({ reason, advice, cdpError, internalError }) {
|
|
if (this.finished) { return; }
|
|
debug('recordInspectorFailure "%s": inFlight=%j, lastProbeIndex=%s, cdpError=%j',
|
|
reason, this.inFlight, this.lastProbeIndex, cdpError);
|
|
const child = this.child;
|
|
const exitedAbnormally = child !== null &&
|
|
(child.signalCode !== null || (child.exitCode !== null && child.exitCode !== 0));
|
|
const inFlightProbe = this.inFlight === null ? null : this.inFlight.probe;
|
|
// This normally emits `probe_failure`, but yields to `probe_target_exit` when the child
|
|
// has already exited abnormally and there is no in-flight probe to attribute to.
|
|
if (exitedAbnormally && inFlightProbe === null) {
|
|
this.finishWithTrustedResult(this.getProbeTargetExitEvent(child.exitCode, child.signalCode));
|
|
return;
|
|
}
|
|
|
|
const failedCdpMethod = this.inFlight === null ? null : this.inFlight.method;
|
|
let protocolError = null;
|
|
// // `ERR_DEBUGGER_ERROR` is a Node-internal code, not a CDP-level protocol code
|
|
if (cdpError !== undefined && cdpError.code !== 'ERR_DEBUGGER_ERROR') {
|
|
protocolError = { __proto__: null, message: cdpError.message, code: cdpError.code };
|
|
}
|
|
const protocolErrorGoesOnHit = (protocolError !== null) && (failedCdpMethod === 'Debugger.evaluateOnCallFrame');
|
|
|
|
let attribution = null;
|
|
if (inFlightProbe !== null) {
|
|
const { index, location } = inFlightProbe;
|
|
const error = { __proto__: null };
|
|
if (protocolErrorGoesOnHit) {
|
|
error.message = 'Probe evaluation failed at the protocol layer';
|
|
error.details = { __proto__: null, protocolError };
|
|
} else {
|
|
error.message = 'Probe evaluation did not complete';
|
|
}
|
|
this.probes[index].hits++;
|
|
ArrayPrototypePush(this.results, {
|
|
probe: index, event: 'hit', hit: this.probes[index].hits, location, error,
|
|
});
|
|
attribution = index;
|
|
} else if (failedCdpMethod !== null && this.lastProbeIndex !== null) {
|
|
attribution = this.lastProbeIndex;
|
|
}
|
|
// When there is no in-flight CDP call (e.g. `onClientClose` after all probes hit), ignore
|
|
// `lastProbeIndex` since it can't be attributed to a specific probe.
|
|
|
|
const pending = this.getPendingProbeIndices();
|
|
const suffix = pending.length === 0 ?
|
|
'' : ` before probes: ${formatPendingProbeLocations(this.probes, pending)}`;
|
|
const error = {
|
|
__proto__: null,
|
|
code: 'probe_failure',
|
|
message: `${reason}${suffix}. ${advice}`,
|
|
};
|
|
if (attribution !== null) { error.probe = attribution; }
|
|
error.stderr = trimProbeChildStderr(this.childStderr);
|
|
|
|
let details;
|
|
if (failedCdpMethod !== null) {
|
|
details = { __proto__: null, lastCdpMethod: failedCdpMethod };
|
|
if (protocolError !== null && !protocolErrorGoesOnHit) { details.protocolError = protocolError; }
|
|
}
|
|
if (internalError !== undefined) {
|
|
details ??= { __proto__: null };
|
|
details.internalError = { __proto__: null, message: internalError?.message, stack: internalError?.stack };
|
|
}
|
|
if (details !== undefined) { error.details = details; }
|
|
|
|
this.finishWithUnreliableResult({ event: 'error', pending, error });
|
|
}
|
|
|
|
startTimeout() {
|
|
this.timeout = setTimeout(() => {
|
|
debug('timeout fired: finished=%s, inFlight=%j, lastProbeIndex=%s',
|
|
this.finished, this.inFlight, this.lastProbeIndex);
|
|
if (this.finished) { return; }
|
|
if (this.inFlight !== null) {
|
|
const hasProbeAttribution =
|
|
this.inFlight.probe !== null || this.lastProbeIndex !== null;
|
|
this.recordInspectorFailure({
|
|
reason: 'Probe session timed out',
|
|
advice: hasProbeAttribution ?
|
|
('The probe expression may be slow, hanging, or interfering with the inspector connection. ' +
|
|
'Try increasing `--timeout`; if the failure persists, review the probe expressions.') :
|
|
'Try increasing `--timeout`; if the failure persists, please file an issue.',
|
|
});
|
|
return;
|
|
}
|
|
const pending = this.getPendingProbeIndices();
|
|
const message = `Timed out after ${this.options.timeout}ms waiting for ` +
|
|
(pending.length === 0 ? 'target completion' :
|
|
`probes: ${formatPendingProbeLocations(this.probes, pending)}`);
|
|
this.finishWithUnreliableResult({
|
|
event: 'timeout',
|
|
pending,
|
|
error: { code: 'probe_timeout', message },
|
|
});
|
|
}, this.options.timeout);
|
|
this.timeout.unref();
|
|
}
|
|
|
|
attachListeners() {
|
|
this.child.on('exit', this.onChildExit);
|
|
this.client.on('close', this.onClientClose);
|
|
this.client.on('Debugger.paused', this.onPaused);
|
|
this.client.on('Debugger.scriptParsed', this.onScriptParsed);
|
|
}
|
|
|
|
onScriptParsed(params) {
|
|
if (params.url && !StringPrototypeStartsWith(params.url, 'node:')) {
|
|
debug('scriptParsed: scriptId=%s url=%s, length=%d', params.scriptId, params.url, params.length);
|
|
}
|
|
// This map grows by the number of scripts parsed, which is limited, and is just a
|
|
// small string -> string map. The lifetime is bounded by probe timeout etc. so cleanup is overkill.
|
|
this.scriptIdToUrl.set(params.scriptId, params.url);
|
|
}
|
|
|
|
async bindBreakpoints() {
|
|
const uniqueTargets = new SafeMap();
|
|
|
|
for (let probeIndex = 0; probeIndex < this.probes.length; probeIndex++) {
|
|
const { target, condition } = this.probes[probeIndex];
|
|
// Probes at the same location share one V8 breakpoint. parseProbeTokens has
|
|
// already ensured they carry the same condition.
|
|
const key = locationKey(target);
|
|
let entry = uniqueTargets.get(key);
|
|
if (entry === undefined) {
|
|
entry = { target, condition, probeIndices: [] };
|
|
uniqueTargets.set(key, entry);
|
|
}
|
|
ArrayPrototypePush(entry.probeIndices, probeIndex);
|
|
}
|
|
|
|
for (const { target, condition, probeIndices } of uniqueTargets.values()) {
|
|
// On Windows, normalize backslashes to forward slashes so the regex matches
|
|
// V8 script URLs which always use forward slashes.
|
|
const normalizedFile = process.platform === 'win32' ?
|
|
SideEffectFreeRegExpPrototypeSymbolReplace(/\\/g, target.suffix, '/') :
|
|
target.suffix;
|
|
const escapedPath = SideEffectFreeRegExpPrototypeSymbolReplace(
|
|
/([/\\.?*()^${}|[\]])/g,
|
|
normalizedFile,
|
|
'\\$1',
|
|
);
|
|
const params = {
|
|
urlRegex: `^(.*[\\/\\\\])?${escapedPath}$`,
|
|
// CDP locations are 0-based, the probe target from CLI is 1-based.
|
|
lineNumber: target.line - 1,
|
|
};
|
|
if (target.column !== undefined) {
|
|
// Only pass columnNumber to CDP when the user specifies one, otherwise let
|
|
// the inspector bind to the first executable column.
|
|
params.columnNumber = target.column - 1;
|
|
}
|
|
if (condition !== undefined) {
|
|
params.condition = condition;
|
|
}
|
|
|
|
const result = await this.callCdp('Debugger.setBreakpointByUrl', params);
|
|
debug('breakpoint set: id=%s urlRegex=%s locations=%j',
|
|
result.breakpointId, params.urlRegex, result.locations);
|
|
this.breakpointDefinitions.set(result.breakpointId, { probeIndices });
|
|
}
|
|
}
|
|
|
|
getPendingProbeIndices() {
|
|
const pending = [];
|
|
for (let probeIndex = 0; probeIndex < this.probes.length; probeIndex++) {
|
|
if (this.probes[probeIndex].hits === 0) {
|
|
ArrayPrototypePush(pending, probeIndex);
|
|
}
|
|
}
|
|
return pending;
|
|
}
|
|
|
|
buildReport({ exitCode, terminal }) {
|
|
const results = ArrayPrototypeSlice(this.results);
|
|
ArrayPrototypePush(results, terminal);
|
|
return {
|
|
code: exitCode,
|
|
report: {
|
|
v: kProbeVersion,
|
|
probes: ArrayPrototypeMap(this.probes, ({ expr, target, maxHit, condition }) => {
|
|
const probe = { expr, target };
|
|
if (condition !== undefined) { probe.condition = condition; }
|
|
// Omit an unlimited maxHit, as Infinity would serialize to null in JSON.
|
|
if (maxHit !== Infinity) { probe.maxHit = maxHit; }
|
|
return probe;
|
|
}),
|
|
results,
|
|
},
|
|
};
|
|
}
|
|
|
|
async cleanup() {
|
|
if (this.cleanupStarted) { return; }
|
|
this.cleanupStarted = true;
|
|
|
|
if (this.timeout !== null) {
|
|
clearTimeout(this.timeout);
|
|
this.timeout = null;
|
|
}
|
|
|
|
this.client.reset();
|
|
|
|
if (this.child === null) { return; }
|
|
|
|
// TODO(joyeecheung): When we implement attach mode, this teardown must
|
|
// resume-and-detach rather than kill, since the target is not ours.
|
|
if (this.child.exitCode === null && this.child.signalCode === null) {
|
|
this.child.kill();
|
|
}
|
|
}
|
|
|
|
async run() {
|
|
try {
|
|
const { childArgv, host, port, skipPortPreflight } = this.options;
|
|
const { 0: child, 1: actualPort, 2: actualHost } =
|
|
await launchChildProcess(childArgv,
|
|
host,
|
|
port,
|
|
this.onChildOutput,
|
|
{ skipPortPreflight });
|
|
this.child = child;
|
|
// On Debugger.enable, V8 emits Debugger.scriptParsed for all existing scripts.
|
|
// Attach the listener early to make sure we don't miss any events.
|
|
this.attachListeners();
|
|
|
|
await this.client.connect(actualPort, actualHost);
|
|
this.connected = true;
|
|
|
|
try {
|
|
try {
|
|
await waitForDebugger(
|
|
this.client,
|
|
(method) => this.callCdp(method),
|
|
);
|
|
} catch (err) {
|
|
// A close event may have completed the structured report while the
|
|
// readiness helper was rejecting its disconnect race.
|
|
if (this.finished) { throw kInspectorFailedSentinel; }
|
|
throw err;
|
|
}
|
|
await this.callCdp('Runtime.enable');
|
|
await this.callCdp('Debugger.enable');
|
|
await this.bindBreakpoints();
|
|
this.started = true;
|
|
this.startTimeout();
|
|
await this.callCdp('Runtime.runIfWaitingForDebugger');
|
|
} catch (err) {
|
|
if (err !== kInspectorFailedSentinel) { throw err; }
|
|
}
|
|
|
|
const state = await this.completionPromise;
|
|
return this.buildReport(state);
|
|
} finally {
|
|
await this.cleanup();
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runProbeMode(stdout, probeOptions) {
|
|
try {
|
|
const session = new ProbeInspectorSession(probeOptions);
|
|
const { code, report } = await session.run();
|
|
stdout.write(probeOptions.json ?
|
|
`${JSONStringify(probeOptions.preview ? report : stripProbePreviews(report))}\n` :
|
|
buildProbeTextReport(report));
|
|
process.exit(code);
|
|
} catch (error) {
|
|
if (error.childStderr) {
|
|
process.stderr.write(error.childStderr);
|
|
}
|
|
process.stderr.write(ensureTrailingNewline(error.message));
|
|
process.exit(kGenericUserError);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
parseProbeTokens,
|
|
ProbeInspectorSession,
|
|
runProbeMode,
|
|
};
|