/
githubmirror
/
node
Обзор
Документация
Войти
/
githubmirror
/
node
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
lib/internal/debugger/inspect_probe.js
1 086 строк
39 KB
Trivikram Kamat
debugger: preserve overlapping CDP request state
25 июл 2026, 04:44
Не верифицирован
25 июл 2026, 04:44
9024119
Код
Авторство
О чём код?
'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, } = 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 { 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, };