Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions doc/api/debugger.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ added:
- v26.1.0
- v24.16.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/63704
description: Add per-probe `--max-hit <n>` option to limit evaluated hits and finish
with a `completed` terminal event as soon as any probe reaches its limit.
- version: v26.3.0
pr-url: https://github.com/nodejs/node/pull/63437
description: Add `probe_failure` terminal `error` event for inspector-side mid-session
Expand Down Expand Up @@ -265,8 +269,8 @@ printf-style debugging without having to modify the application code and
clean up afterwards. It also supports structured JSON output for tool use.

```console
$ node inspect --probe <file>:<line>[:<col>] --expr <expr>
[--probe <file>:<line>[:<col>] --expr <expr> ...]
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
[--probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>] ...]
[--json] [--preview] [--timeout=<ms>] [--port=<port>]
[--] [<node-option> ...] <script> [<script-args> ...]
```
Expand All @@ -279,6 +283,11 @@ $ node inspect --probe <file>:<line>[:<col>] --expr <expr>
* `--expr <expr>`: JavaScript expression to evaluate whenever execution reaches
the location specified by the preceding `--probe`.
Must immediately follow the `--probe` it belongs to.
* `--max-hit <n>`: An optional per-probe limit on the number of times the probe
can be hit. When not specified, there's no hit limit. When any probe reaches
its hit limit, the probing process will detach and report the results. The process
being probed will continue to run. If any other probe is never reached by the time
the session ends, it will be reported as a missed probe.
* `--timeout=<ms>`: A global wall-clock deadline for the entire probe session.
The default is `30000`. This can be used to probe a long-running application
that can be terminated externally.
Expand All @@ -293,6 +302,10 @@ Additional rules about the `--probe` and `--expr` arguments:

* `--probe <file>:<line>[:<col>]` and `--expr <expr>` are strict pairs. Each
`--probe` must be followed immediately by exactly one `--expr`.
* `--max-hit <n>` is an optional per-probe option that applies to the most recent
`--probe`/`--expr` pair. It may not appear before the first `--probe` or
between a `--probe` and its matching `--expr`, and may be given at most once
per probe.
* `--timeout`, `--json`, `--preview`, and `--port` are global probe options
for the whole probe session. They may appear before or between probe pairs,
but not between a `--probe` and its matching `--expr`.
Expand Down Expand Up @@ -367,6 +380,7 @@ $ node inspect --json --probe cli.js:5 --expr 'rss' cli.js
"suffix": "cli.js",
"line": 5
}
// `maxHit` is present only when the probe was given a --max-hit limit.
}
],
"results": [
Expand Down
19 changes: 10 additions & 9 deletions lib/internal/debugger/inspect.js
Original file line number Diff line number Diff line change
Expand Up @@ -267,16 +267,17 @@ function parseInteractiveArgs(args) {
}

const kInspectArgOptions = {
__proto__: null,
expr: { type: 'string' },
help: { type: 'boolean', short: 'h' },
json: { type: 'boolean' },
// Port and timeout use type 'string' because parseArgs has no
'__proto__': null,
'expr': { type: 'string' },
'help': { type: 'boolean', short: 'h' },
'json': { type: 'boolean' },
// Port, timeout, and max-hit use type 'string' because parseArgs has no
// numeric type; the values are parsed to integers by parseProbeTokens().
port: { type: 'string' },
preview: { type: 'boolean' },
probe: { type: 'string' },
timeout: { type: 'string' },
'max-hit': { type: 'string' },
'port': { type: 'string' },
'preview': { type: 'boolean' },
'probe': { type: 'string' },
'timeout': { type: 'string' },
};

// Parses args once and decides whether the user wants the inspect help, probe
Expand Down
8 changes: 6 additions & 2 deletions lib/internal/debugger/inspect_helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ function writeInspectUsageAndExit(invokedAs, message, exitCode) {
}
out.write(`Usage: ${invokedAs} [--port=<port>] [<node-option> ...]
[<script> [<script-args>] | <host>:<port> | -p <pid>]
${invokedAs} --probe <file>:<line>[:<col>] --expr <expr>
[--probe <file>:<line>[:<col>] --expr <expr> ...]
${invokedAs} --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
[--probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>] ...]
[--json] [--preview] [--timeout=<ms>] [--port=<port>]
[--] [<node-option> ...] <script> [<script-args> ...]
Expand Down Expand Up @@ -109,6 +109,10 @@ Options:
preceding --probe each time execution reaches it.
Avoid probing let/const-bound variables at their
declaration site or a ReferenceError may be thrown.
--max-hit <n> Per-probe limit on evaluated hits. When not specified,
there's no hit limit. When any probe reaches its hit LIMIT,
the probing process will detach and report the results.
The probed process will continue to run.
--json Output JSON if specified, otherwise human-readable text.
--preview Include V8 object previews in JSON output.
--timeout <ms> Global session timeout (default: 30000).
Expand Down
42 changes: 40 additions & 2 deletions lib/internal/debugger/inspect_probe.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const {
ArrayPrototypeMap,
ArrayPrototypePush,
ArrayPrototypeSlice,
ArrayPrototypeSome,
FunctionPrototypeBind,
JSONStringify,
NumberIsNaN,
Expand Down Expand Up @@ -86,9 +87,14 @@ const kInspectPortRegex = /^--inspect-port=(\d+)$/;
* @typedef {object} Probe
* @property {string} expr Expression to evaluate on hit.
* @property {ProbeTarget} target User's original --probe request shape.
* @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}`);
Expand Down Expand Up @@ -371,6 +377,20 @@ function parseProbeTokens(tokens, args) {
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;
}
default:
if (probes.length > 0) {
throw new ERR_DEBUGGER_STARTUP_ERROR(
Expand Down Expand Up @@ -458,7 +478,9 @@ class ProbeInspectorSession {
this.completionPromise = promise;
this.resolveCompletion = resolve;
/** @type {Probe[]} */
this.probes = ArrayPrototypeMap(options.probes, ({ expr, target }) => ({ expr, target, hits: 0 }));
this.probes = ArrayPrototypeMap(options.probes,
({ expr, target, maxHit }) =>
({ expr, target, maxHit: maxHit ?? Infinity, hits: 0 }));
this.onChildOutput = FunctionPrototypeBind(this.onChildOutput, this);
this.onChildExit = FunctionPrototypeBind(this.onChildExit, this);
this.onClientClose = FunctionPrototypeBind(this.onClientClose, this);
Expand Down Expand Up @@ -638,6 +660,15 @@ class ProbeInspectorSession {
}
}

// 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();
}

Expand Down Expand Up @@ -908,7 +939,12 @@ class ProbeInspectorSession {
code: exitCode,
report: {
v: kProbeVersion,
probes: ArrayPrototypeMap(this.probes, ({ expr, target }) => ({ expr, target })),
probes: ArrayPrototypeMap(this.probes, ({ expr, target, maxHit }) => {
// Omit an unlimited maxHit, as Infinity would serialize to null in JSON.
const probe = { expr, target };
if (maxHit !== Infinity) { probe.maxHit = maxHit; }
return probe;
}),
results,
},
};
Expand All @@ -927,6 +963,8 @@ class ProbeInspectorSession {

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();
}
Expand Down
11 changes: 11 additions & 0 deletions test/common/debugger-probe.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

const assert = require('assert');
const { spawnSyncAndExit } = require('./child_process');

// Work around a pre-existing inspector issue: if the debuggee exits too quickly
// the inspector can segfault while tearing down. For now normalize the segfault
Expand Down Expand Up @@ -79,7 +80,17 @@ function assertProbeText(output, expected) {
assert.strictEqual(normalized, expected);
}

function assertProbeCliError(inspectArgs, expectedStderr, { cwd } = {}) {
spawnSyncAndExit(process.execPath, ['inspect', ...inspectArgs], { cwd }, {
signal: null,
status: 9,
stderr: expectedStderr,
trim: true,
});
}

module.exports = {
assertProbeJson,
assertProbeCliError,
assertProbeText,
};
7 changes: 7 additions & 0 deletions test/fixtures/debugger/probe-max-hit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use strict';

let total = 0;
for (let index = 0; index < 3; index++) {
total += index + 1;
}
console.log(total);
40 changes: 40 additions & 0 deletions test/parallel/test-debugger-probe-max-hit-invalid.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// This tests that probe mode rejects malformed --max-hit usage.
'use strict';

const common = require('../common');
common.skipIfInspectorDisabled();

const fixtures = require('../common/fixtures');
const { assertProbeCliError } = require('../common/debugger-probe');

const cwd = fixtures.path('debugger');

// --max-hit before any --probe.
assertProbeCliError(
['--max-hit', '1', '--probe', 'probe.js:12', '--expr', 'finalValue', 'probe.js'],
/Unexpected --max-hit before --probe/, { cwd });

// --max-hit between a --probe and its --expr.
assertProbeCliError(
['--probe', 'probe.js:12', '--max-hit', '1', '--expr', 'finalValue', 'probe.js'],
/Each --probe must be followed immediately by --expr/, { cwd });

// Duplicate --max-hit for a single probe.
assertProbeCliError(
['--probe', 'probe.js:12', '--expr', 'finalValue', '--max-hit', '1', '--max-hit', '2', 'probe.js'],
/Duplicate --max-hit for a single --probe/, { cwd });

// Non-numeric value.
assertProbeCliError(
['--probe', 'probe.js:12', '--expr', 'finalValue', '--max-hit', 'abc', 'probe.js'],
/Invalid max-hit: abc/, { cwd });

// Zero is not allowed (limit must be at least 1).
assertProbeCliError(
['--probe', 'probe.js:12', '--expr', 'finalValue', '--max-hit', '0', 'probe.js'],
/Invalid max-hit: 0/, { cwd });

// Missing value: --max-hit as the final token has nothing to consume.
assertProbeCliError(
['--probe', 'probe.js:12', '--expr', 'finalValue', '--max-hit'],
/Missing value for --max-hit/, { cwd });
36 changes: 36 additions & 0 deletions test/parallel/test-debugger-probe-max-hit-miss.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// This tests that a limited probe that is never reached is still reported as a missed probe.
'use strict';

const common = require('../common');
common.skipIfInspectorDisabled();

const fixtures = require('../common/fixtures');
const { spawnSyncAndAssert } = require('../common/child_process');
const { assertProbeJson } = require('../common/debugger-probe');

const cwd = fixtures.path('debugger');

spawnSyncAndAssert(process.execPath, [
'inspect',
'--json',
'--probe', 'probe-miss.js:99',
'--expr', '42',
'--max-hit', '3',
'probe-miss.js',
], { cwd }, {
stdout(output) {
assertProbeJson(output, {
v: 2,
probes: [{
expr: '42',
target: { suffix: 'probe-miss.js', line: 99 },
maxHit: 3,
}],
results: [{
event: 'miss',
pending: [0],
}],
});
},
trim: true,
});
58 changes: 58 additions & 0 deletions test/parallel/test-debugger-probe-max-hit-partial.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// This tests that when the target exits before a probe reaches its limit,
// the session still ends with `completed`.
'use strict';

const common = require('../common');
common.skipIfInspectorDisabled();

const fixtures = require('../common/fixtures');
const { spawnSyncAndAssert } = require('../common/child_process');
const { assertProbeJson } = require('../common/debugger-probe');

const cwd = fixtures.path('debugger');
const probeUrl = fixtures.fileURL('debugger', 'probe-max-hit.js').href;

spawnSyncAndAssert(process.execPath, [
'inspect',
'--json',
'--probe', 'probe-max-hit.js:5',
'--expr', 'index',
'--max-hit', '10',
'probe-max-hit.js',
], { cwd }, {
stdout(output) {
assertProbeJson(output, {
v: 2,
probes: [{
expr: 'index',
target: { suffix: 'probe-max-hit.js', line: 5 },
maxHit: 10,
}],
results: [
{
probe: 0,
event: 'hit',
hit: 1,
location: { url: probeUrl, line: 5, column: 3 },
result: { type: 'number', value: 0, description: '0' },
},
{
probe: 0,
event: 'hit',
hit: 2,
location: { url: probeUrl, line: 5, column: 3 },
result: { type: 'number', value: 1, description: '1' },
},
{
probe: 0,
event: 'hit',
hit: 3,
location: { url: probeUrl, line: 5, column: 3 },
result: { type: 'number', value: 2, description: '2' },
},
{ event: 'completed' },
],
});
},
trim: true,
});
Loading
Loading