Commit 445bcce

Browse files
joyeecheungaduh95
authored andcommitted
inspector: add --cond to node inspect probe mode
On a hot path, the probe can record every hit and require filtering afterwards. This patch adds a per-probe `--cond <expr>` option that allows limiting the hit to only when the expression is truthy at the probe location. V8 evaluates it as the breakpoint's native condition, so the target is not paused when it does not hold, and a condition that throws is treated as false. Since in CDP, a location can only carry one breakpoint per URL pattern, probes sharing a location must share one condition (or none). Conflicting conditions are rejected. Example: ```js // app.js let total = 0; for (let i = 0; i < 10; i++) { total += i; // line 4 } ``` ``` $ out/Release/node inspect --probe app.js:4 --expr 'total' \ --cond 'i % 3 === 0' app.js ``` ``` Hit 1 at file:///path/to/app.js:3:3 total = 0 Hit 2 at file:///path/to/app.js:3:3 total = 3 Hit 3 at file:///path/to/app.js:3:3 total = 15 Hit 4 at file:///path/to/app.js:3:3 total = 36 Completed ``` Signed-off-by: Joyee Cheung <joyeec9h3@gmail.com> PR-URL: #64328 Refs: #63646 Reviewed-By: James M Snell <jasnell@gmail.com>
1 parent 5d90e48 commit 445bcce

9 files changed

Lines changed: 348 additions & 17 deletions

β€Ždoc/api/debugger.mdβ€Ž

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ debug>
236236
added:
237237
- v24.16.0
238238
changes:
239+
- version: REPLACEME
240+
pr-url: https://github.com/nodejs/node/pull/64328
241+
description: Add per-probe `--cond <expr>` option to only record a hit when the
242+
condition is truthy at the probe location.
239243
- version: v24.19.0
240244
pr-url: https://github.com/nodejs/node/pull/63704
241245
description: Add per-probe `--max-hit <n>` option to limit evaluated hits and finish
@@ -268,8 +272,8 @@ printf-style debugging without having to modify the application code and
268272
clean up afterwards. It also supports structured JSON output for tool use.
269273

270274
```console
271-
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
272-
[--probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>] ...]
275+
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>]
276+
[--probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>] ...]
273277
[--json] [--preview] [--timeout=<ms>] [--port=<port>]
274278
[--] [<node-option> ...] <script> [<script-args> ...]
275279
```
@@ -282,6 +286,9 @@ $ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
282286
*`--expr <expr>`: JavaScript expression to evaluate whenever execution reaches
283287
the location specified by the preceding `--probe`.
284288
Must immediately follow the `--probe` it belongs to.
289+
*`--cond <expr>`: An optional condition for the probe location. The probe only
290+
records a hit when `<expr>` is truthy at the location. A condition that throws
291+
is treated as false.
285292
*`--max-hit <n>`: An optional per-probe limit on the number of times the probe
286293
can be hit. When not specified, there's no hit limit. When any probe reaches
287294
its hit limit, the probing process will detach and report the results. The process
@@ -297,14 +304,17 @@ $ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
297304
will listen. Defaults to `0`, which requests a random port.
298305
*`--` is optional unless the child needs its own Node.js flags.
299306

300-
Additional rules about the `--probe` and `--expr` arguments:
307+
Additional rules about the composition of the options:
301308

302309
*`--probe <file>:<line>[:<col>]` and `--expr <expr>` are strict pairs. Each
303310
`--probe` must be followed immediately by exactly one `--expr`.
304-
*`--max-hit <n>` is an optional per-probe option that applies to the most recent
305-
`--probe`/`--expr` pair. It may not appear before the first `--probe` or
306-
between a `--probe` and its matching `--expr`, and may be given at most once
307-
per probe.
311+
*`--cond <expr>` and `--max-hit <n>` are optional modifiers written _after_ the
312+
`--probe`/`--expr` pair they apply to, each at most once per pair. They may not
313+
appear before the first `--probe` or between a `--probe` and its matching
314+
`--expr`.
315+
*`--max-hit` scopes to the `--probe`/`--expr` pair it follows, so pairs
316+
sharing a location may set different limits. `--cond` scopes to the whole
317+
location, probes sharing a location must share one condition (or none).
308318
*`--timeout`, `--json`, `--preview`, and `--port` are global probe options
309319
for the whole probe session. They may appear before or between probe pairs,
310320
but not between a `--probe` and its matching `--expr`.
@@ -379,6 +389,7 @@ $ node inspect --json --probe cli.js:5 --expr 'rss' cli.js
379389
"suffix": "cli.js",
380390
"line": 5
381391
}
392+
// `condition` is present only when the probe was given a --cond expression.
382393
// `maxHit` is present only when the probe was given a --max-hit limit.
383394
}
384395
],
@@ -480,6 +491,10 @@ When multiple `--probe`/`--expr` pairs share the same `--probe`, the
480491
expressions will be evaluated on the same pause in the order they appear
481492
on the command line.
482493

494+
For each location, there can only be at most one `--cond` (or none).
495+
Multiple `--probe`/`--expr` pairs with conflicting conditions
496+
at the same location will be rejected at launch time.
497+
483498
```js
484499
// app.js
485500
constx= { x:42 }; // line 2
@@ -563,6 +578,37 @@ that only matches the intended file:
563578
$ node inspect --probe src/utils.js:10 --expr 'x' main.js # matches only src/utils.js
564579
```
565580

581+
### Probe examples
582+
583+
#### Probing a variable conditionally
584+
585+
```js
586+
// app.js
587+
let total =0;
588+
for (let i =0; i <10; i++) {
589+
total += i; // line 4
590+
}
591+
```
592+
593+
```console
594+
$ out/Release/node inspect --probe app.js:4 --expr 'total' \
595+
--cond 'i % 3 === 0' app.js
596+
```
597+
598+
```text
599+
Hit 1 at file:///path/to/app.js:3:3
600+
total = 0
601+
Hit 2 at file:///path/to/app.js:3:3
602+
total = 3
603+
Hit 3 at file:///path/to/app.js:3:3
604+
total = 15
605+
Hit 4 at file:///path/to/app.js:3:3
606+
total = 36
607+
Completed
608+
```
609+
610+
<!-- TODO(joyeecheung): add more examples for different options -->
611+
566612
## Advanced usage
567613

568614
### V8 inspector integration for Node.js

β€Žlib/internal/debugger/inspect.jsβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ function parseInteractiveArgs(args) {
268268

269269
constkInspectArgOptions={
270270
'__proto__': null,
271+
'cond': {type: 'string'},
271272
'expr': {type: 'string'},
272273
'help': {type: 'boolean',short: 'h'},
273274
'json': {type: 'boolean'},

β€Žlib/internal/debugger/inspect_helpers.jsβ€Ž

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ function writeInspectUsageAndExit(invokedAs, message, exitCode) {
6969
}
7070
out.write(`Usage: ${invokedAs} [--port=<port>] [<node-option> ...]
7171
[<script> [<script-args>] | <host>:<port> | -p <pid>]
72-
${invokedAs} --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
73-
[--probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>] ...]
72+
${invokedAs} --probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>]
73+
[--probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>] ...]
7474
[--json] [--preview] [--timeout=<ms>] [--port=<port>]
7575
[--] [<node-option> ...] <script> [<script-args> ...]
7676
@@ -109,6 +109,9 @@ Options:
109109
preceding --probe each time execution reaches it.
110110
Avoid probing let/const-bound variables at their
111111
declaration site or a ReferenceError may be thrown.
112+
--cond <expr> Optional condition for the probe location. The probe only
113+
records a hit when <expr> is truthy at the location. A
114+
condition that throws is treated as false.
112115
--max-hit <n> Per-probe limit on evaluated hits. When not specified,
113116
there's no hit limit. When any probe reaches its hit LIMIT,
114117
the probing process will detach and report the results.
@@ -121,6 +124,9 @@ Options:
121124
Semantics:
122125
* Multiple --probe/--expr pairs are allowed. Same-location --probes share
123126
a pause and scope, their --exprs are evaluated in command-line order.
127+
* --max-hit scopes to one --probe/--expr pair, so same-location pairs may set
128+
different limits. --cond scopes to the location, probes sharing a location
129+
must all share one condition (or none).
124130
* --probe utils.js:<line>[:<col>] matches every loaded utils.js. Pass a
125131
fuller path e.g. src/utils.js to narrow the match.
126132
* Use -- before any Node.js flags intended for the child process.

β€Žlib/internal/debugger/inspect_probe.jsβ€Ž

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const {
2121
StringPrototypeIncludes,
2222
StringPrototypeSlice,
2323
StringPrototypeStartsWith,
24+
StringPrototypeTrim,
2425
Symbol,
2526
}=primordials;
2627

@@ -87,6 +88,8 @@ const kInspectPortRegex = /^--inspect-port=(\d+)$/;
8788
* @typedef {object} Probe
8889
* @property {string} expr Expression to evaluate on hit.
8990
* @property {ProbeTarget} target User's original --probe request shape.
91+
* @property {string} [condition] Condition from --cond. V8 only breaks when it is truthy.
92+
* Scoped to the location, so probes sharing a location all carry the same value.
9093
* @property {number} maxHit Per-probe hit limit from --max-hit. Infinity when unlimited.
9194
* @property {number} hits Count of hits observed.
9295
*/
@@ -130,6 +133,12 @@ function formatTargetText(target) {
130133
returncolumn===undefined ? `${suffix}:${line}` : `${suffix}:${line}:${column}`;
131134
}
132135

136+
// Identity of a probe location. Probes sharing a key share one V8 breakpoint,
137+
// so this must stay in sync between condition validation and breakpoint setup.
138+
functionlocationKey(target){
139+
return`${target.suffix}\n${target.line}\n${target.column??''}`;
140+
}
141+
133142
functionformatPendingProbeLocations(probes,pending){
134143
constseen=newSafeSet();
135144
for(constprobeIndexofpending){
@@ -391,6 +400,22 @@ function parseProbeTokens(tokens, args) {
391400
probe.maxHit=parseUnsignedInteger(token.value,'max-hit');
392401
break;
393402
}
403+
case'cond': {
404+
if(probes.length===0){
405+
thrownewERR_DEBUGGER_STARTUP_ERROR('Unexpected --cond before --probe');
406+
}
407+
// A blank condition does not act as a real predicate in V8 (an empty
408+
// string always breaks), so reject it rather than silently mislead.
409+
if(token.value===undefined||StringPrototypeTrim(token.value)===''){
410+
thrownewERR_DEBUGGER_STARTUP_ERROR(`Missing value for ${token.rawName}`);
411+
}
412+
constprobe=probes[probes.length-1];
413+
if(probe.condition!==undefined){
414+
thrownewERR_DEBUGGER_STARTUP_ERROR('A --probe can have at most one --cond');
415+
}
416+
probe.condition=token.value;
417+
break;
418+
}
394419
default:
395420
if(probes.length>0){
396421
thrownewERR_DEBUGGER_STARTUP_ERROR(
@@ -410,6 +435,24 @@ function parseProbeTokens(tokens, args) {
410435
'Probe mode requires at least one --probe <loc> --expr <expr> group');
411436
}
412437

438+
// V8 allows only one breakpoint per location, so probes sharing a location
439+
// cannot carry different conditions.
440+
constconditionByLocation=newSafeMap();
441+
for(const{ target, condition }ofprobes){
442+
constkey=locationKey(target);
443+
// All probes at the same location must share one condition. We split the
444+
// existence check since if one probe does not have a condition (= undefined),
445+
// then all probes at the location must also omit it.
446+
if(conditionByLocation.has(key)){
447+
if(conditionByLocation.get(key)!==condition){
448+
thrownewERR_DEBUGGER_STARTUP_ERROR(
449+
`Probes at ${formatTargetText(target)} must use the same --cond (or none)`);
450+
}
451+
}else{
452+
conditionByLocation.set(key,condition);
453+
}
454+
}
455+
413456
constchildArgv=ArrayPrototypeSlice(args,childStartIndex);
414457
if(childArgv.length===0){
415458
thrownewERR_DEBUGGER_STARTUP_ERROR('Probe mode requires a child script');
@@ -479,8 +522,8 @@ class ProbeInspectorSession {
479522
this.resolveCompletion=resolve;
480523
/** @type {Probe[]} */
481524
this.probes=ArrayPrototypeMap(options.probes,
482-
({ expr, target, maxHit })=>
483-
({ expr, target,maxHit: maxHit??Infinity,hits: 0}));
525+
({ expr, target, maxHit, condition})=>
526+
({ expr, target,condition,maxHit: maxHit??Infinity,hits: 0}));
484527
this.onChildOutput=FunctionPrototypeBind(this.onChildOutput,this);
485528
this.onChildExit=FunctionPrototypeBind(this.onChildExit,this);
486529
this.onClientClose=FunctionPrototypeBind(this.onClientClose,this);
@@ -887,17 +930,19 @@ class ProbeInspectorSession {
887930
constuniqueTargets=newSafeMap();
888931

889932
for(letprobeIndex=0;probeIndex<this.probes.length;probeIndex++){
890-
const{ target }=this.probes[probeIndex];
891-
constkey=`${target.suffix}\n${target.line}\n${target.column??''}`;
933+
const{ target, condition }=this.probes[probeIndex];
934+
// Probes at the same location share one V8 breakpoint. parseProbeTokens has
935+
// already ensured they carry the same condition.
936+
constkey=locationKey(target);
892937
letentry=uniqueTargets.get(key);
893938
if(entry===undefined){
894-
entry={ target,probeIndices: []};
939+
entry={ target,condition,probeIndices: []};
895940
uniqueTargets.set(key,entry);
896941
}
897942
ArrayPrototypePush(entry.probeIndices,probeIndex);
898943
}
899944

900-
for(const{ target, probeIndices }ofuniqueTargets.values()){
945+
for(const{ target,condition,probeIndices }ofuniqueTargets.values()){
901946
// On Windows, normalize backslashes to forward slashes so the regex matches
902947
// V8 script URLs which always use forward slashes.
903948
constnormalizedFile=process.platform==='win32' ?
@@ -918,6 +963,9 @@ class ProbeInspectorSession {
918963
// the inspector bind to the first executable column.
919964
params.columnNumber=target.column-1;
920965
}
966+
if(condition!==undefined){
967+
params.condition=condition;
968+
}
921969

922970
constresult=awaitthis.callCdp('Debugger.setBreakpointByUrl',params);
923971
debug('breakpoint set: id=%s urlRegex=%s locations=%j',
@@ -943,9 +991,10 @@ class ProbeInspectorSession {
943991
code: exitCode,
944992
report: {
945993
v: kProbeVersion,
946-
probes: ArrayPrototypeMap(this.probes,({ expr, target, maxHit })=>{
947-
// Omit an unlimited maxHit, as Infinity would serialize to null in JSON.
994+
probes: ArrayPrototypeMap(this.probes,({ expr, target, maxHit, condition })=>{
948995
constprobe={ expr, target };
996+
if(condition!==undefined){probe.condition=condition;}
997+
// Omit an unlimited maxHit, as Infinity would serialize to null in JSON.
949998
if(maxHit!==Infinity){probe.maxHit=maxHit;}
950999
returnprobe;
9511000
}),
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// This tests that probe mode rejects malformed --cond usage.
2+
'use strict';
3+
4+
constcommon=require('../common');
5+
common.skipIfInspectorDisabled();
6+
7+
constfixtures=require('../common/fixtures');
8+
const{ assertProbeCliError }=require('../common/debugger-probe');
9+
10+
constcwd=fixtures.path('debugger');
11+
12+
assertProbeCliError(
13+
['--cond','x','--probe','probe.js:12','--expr','finalValue','probe.js'],
14+
/Unexpected--condbefore--probe/,{ cwd });
15+
16+
assertProbeCliError(
17+
['--probe','probe.js:12','--cond','x','--expr','finalValue','probe.js'],
18+
/Each--probemustbefollowedimmediatelyby--expr/,{ cwd });
19+
20+
assertProbeCliError(
21+
['--probe','probe.js:12','--expr','finalValue','--cond','x','--cond','y','probe.js'],
22+
/A--probecanhaveatmostone--cond/,{ cwd });
23+
24+
assertProbeCliError(
25+
['--probe','probe.js:12','--expr','finalValue','--cond',' ','probe.js'],
26+
/Missingvaluefor--cond/,{ cwd });
27+
28+
assertProbeCliError(
29+
['--probe','probe.js:12','--expr','finalValue','--cond'],
30+
/Missingvaluefor--cond/,{ cwd });
31+
32+
assertProbeCliError(
33+
['--probe','probe.js:12','--expr','a','--cond','x',
34+
'--probe','probe.js:12','--expr','b','--cond','y','probe.js'],
35+
/Probesatprobe\.js:12mustusethesame--cond\(ornone\)/,{ cwd });
36+
37+
assertProbeCliError(
38+
['--probe','probe.js:12','--expr','a','--cond','x',
39+
'--probe','probe.js:12','--expr','b','probe.js'],
40+
/Probesatprobe\.js:12mustusethesame--cond\(ornone\)/,{ cwd });
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// This tests that --cond and --max-hit work together.
2+
'use strict';
3+
4+
constcommon=require('../common');
5+
common.skipIfInspectorDisabled();
6+
7+
constfixtures=require('../common/fixtures');
8+
const{ spawnSyncAndAssert }=require('../common/child_process');
9+
const{ assertProbeJson }=require('../common/debugger-probe');
10+
11+
constcwd=fixtures.path('debugger');
12+
constprobeUrl=fixtures.fileURL('debugger','probe-max-hit.js').href;
13+
14+
// --max-hit written before --cond. The condition still filters index !== 1, so
15+
// the only recorded hit carries value 1 rather than the loop's first iteration.
16+
spawnSyncAndAssert(process.execPath,[
17+
'inspect',
18+
'--json',
19+
'--probe','probe-max-hit.js:5',
20+
'--expr','index',
21+
'--max-hit','5',
22+
'--cond','index === 1',
23+
'probe-max-hit.js',
24+
],{ cwd },{
25+
stdout(output){
26+
assertProbeJson(output,{
27+
v: 2,
28+
probes: [{
29+
expr: 'index',
30+
condition: 'index === 1',
31+
maxHit: 5,
32+
target: {suffix: 'probe-max-hit.js',line: 5},
33+
}],
34+
results: [
35+
{
36+
probe: 0,
37+
event: 'hit',
38+
hit: 1,
39+
location: {url: probeUrl,line: 5,column: 3},
40+
result: {type: 'number',value: 1,description: '1'},
41+
},
42+
{event: 'completed'},
43+
],
44+
});
45+
},
46+
trim: true,
47+
});

0 commit comments

Comments
Β (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Commit 445bcce

Browse files
joyeecheungaduh95
authored andcommitted
inspector: add --cond to node inspect probe mode
On a hot path, the probe can record every hit and require filtering afterwards. This patch adds a per-probe `--cond <expr>` option that allows limiting the hit to only when the expression is truthy at the probe location. V8 evaluates it as the breakpoint's native condition, so the target is not paused when it does not hold, and a condition that throws is treated as false. Since in CDP, a location can only carry one breakpoint per URL pattern, probes sharing a location must share one condition (or none). Conflicting conditions are rejected. Example: ```js // app.js let total = 0; for (let i = 0; i < 10; i++) { total += i; // line 4 } ``` ``` $ out/Release/node inspect --probe app.js:4 --expr 'total' \ --cond 'i % 3 === 0' app.js ``` ``` Hit 1 at file:///path/to/app.js:3:3 total = 0 Hit 2 at file:///path/to/app.js:3:3 total = 3 Hit 3 at file:///path/to/app.js:3:3 total = 15 Hit 4 at file:///path/to/app.js:3:3 total = 36 Completed ``` Signed-off-by: Joyee Cheung <joyeec9h3@gmail.com> PR-URL: #64328 Refs: #63646 Reviewed-By: James M Snell <jasnell@gmail.com>
1 parent 5d90e48 commit 445bcce

9 files changed

Lines changed: 348 additions & 17 deletions

β€Ždoc/api/debugger.mdβ€Ž

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ debug>
236236
added:
237237
- v24.16.0
238238
changes:
239+
- version: REPLACEME
240+
pr-url: https://github.com/nodejs/node/pull/64328
241+
description: Add per-probe `--cond <expr>` option to only record a hit when the
242+
condition is truthy at the probe location.
239243
- version: v24.19.0
240244
pr-url: https://github.com/nodejs/node/pull/63704
241245
description: Add per-probe `--max-hit <n>` option to limit evaluated hits and finish
@@ -268,8 +272,8 @@ printf-style debugging without having to modify the application code and
268272
clean up afterwards. It also supports structured JSON output for tool use.
269273

270274
```console
271-
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
272-
[--probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>] ...]
275+
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>]
276+
[--probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>] ...]
273277
[--json] [--preview] [--timeout=<ms>] [--port=<port>]
274278
[--] [<node-option> ...] <script> [<script-args> ...]
275279
```
@@ -282,6 +286,9 @@ $ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
282286
*`--expr <expr>`: JavaScript expression to evaluate whenever execution reaches
283287
the location specified by the preceding `--probe`.
284288
Must immediately follow the `--probe` it belongs to.
289+
*`--cond <expr>`: An optional condition for the probe location. The probe only
290+
records a hit when `<expr>` is truthy at the location. A condition that throws
291+
is treated as false.
285292
*`--max-hit <n>`: An optional per-probe limit on the number of times the probe
286293
can be hit. When not specified, there's no hit limit. When any probe reaches
287294
its hit limit, the probing process will detach and report the results. The process
@@ -297,14 +304,17 @@ $ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
297304
will listen. Defaults to `0`, which requests a random port.
298305
*`--` is optional unless the child needs its own Node.js flags.
299306

300-
Additional rules about the `--probe` and `--expr` arguments:
307+
Additional rules about the composition of the options:
301308

302309
*`--probe <file>:<line>[:<col>]` and `--expr <expr>` are strict pairs. Each
303310
`--probe` must be followed immediately by exactly one `--expr`.
304-
*`--max-hit <n>` is an optional per-probe option that applies to the most recent
305-
`--probe`/`--expr` pair. It may not appear before the first `--probe` or
306-
between a `--probe` and its matching `--expr`, and may be given at most once
307-
per probe.
311+
*`--cond <expr>` and `--max-hit <n>` are optional modifiers written _after_ the
312+
`--probe`/`--expr` pair they apply to, each at most once per pair. They may not
313+
appear before the first `--probe` or between a `--probe` and its matching
314+
`--expr`.
315+
*`--max-hit` scopes to the `--probe`/`--expr` pair it follows, so pairs
316+
sharing a location may set different limits. `--cond` scopes to the whole
317+
location, probes sharing a location must share one condition (or none).
308318
*`--timeout`, `--json`, `--preview`, and `--port` are global probe options
309319
for the whole probe session. They may appear before or between probe pairs,
310320
but not between a `--probe` and its matching `--expr`.
@@ -379,6 +389,7 @@ $ node inspect --json --probe cli.js:5 --expr 'rss' cli.js
379389
"suffix": "cli.js",
380390
"line": 5
381391
}
392+
// `condition` is present only when the probe was given a --cond expression.
382393
// `maxHit` is present only when the probe was given a --max-hit limit.
383394
}
384395
],
@@ -480,6 +491,10 @@ When multiple `--probe`/`--expr` pairs share the same `--probe`, the
480491
expressions will be evaluated on the same pause in the order they appear
481492
on the command line.
482493

494+
For each location, there can only be at most one `--cond` (or none).
495+
Multiple `--probe`/`--expr` pairs with conflicting conditions
496+
at the same location will be rejected at launch time.
497+
483498
```js
484499
// app.js
485500
constx= { x:42 }; // line 2
@@ -563,6 +578,37 @@ that only matches the intended file:
563578
$ node inspect --probe src/utils.js:10 --expr 'x' main.js # matches only src/utils.js
564579
```
565580

581+
### Probe examples
582+
583+
#### Probing a variable conditionally
584+
585+
```js
586+
// app.js
587+
let total =0;
588+
for (let i =0; i <10; i++) {
589+
total += i; // line 4
590+
}
591+
```
592+
593+
```console
594+
$ out/Release/node inspect --probe app.js:4 --expr 'total' \
595+
--cond 'i % 3 === 0' app.js
596+
```
597+
598+
```text
599+
Hit 1 at file:///path/to/app.js:3:3
600+
total = 0
601+
Hit 2 at file:///path/to/app.js:3:3
602+
total = 3
603+
Hit 3 at file:///path/to/app.js:3:3
604+
total = 15
605+
Hit 4 at file:///path/to/app.js:3:3
606+
total = 36
607+
Completed
608+
```
609+
610+
<!-- TODO(joyeecheung): add more examples for different options -->
611+
566612
## Advanced usage
567613

568614
### V8 inspector integration for Node.js

β€Žlib/internal/debugger/inspect.jsβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ function parseInteractiveArgs(args) {
268268

269269
constkInspectArgOptions={
270270
'__proto__': null,
271+
'cond': {type: 'string'},
271272
'expr': {type: 'string'},
272273
'help': {type: 'boolean',short: 'h'},
273274
'json': {type: 'boolean'},

β€Žlib/internal/debugger/inspect_helpers.jsβ€Ž

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ function writeInspectUsageAndExit(invokedAs, message, exitCode) {
6969
}
7070
out.write(`Usage: ${invokedAs} [--port=<port>] [<node-option> ...]
7171
[<script> [<script-args>] | <host>:<port> | -p <pid>]
72-
${invokedAs} --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
73-
[--probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>] ...]
72+
${invokedAs} --probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>]
73+
[--probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>] ...]
7474
[--json] [--preview] [--timeout=<ms>] [--port=<port>]
7575
[--] [<node-option> ...] <script> [<script-args> ...]
7676
@@ -109,6 +109,9 @@ Options:
109109
preceding --probe each time execution reaches it.
110110
Avoid probing let/const-bound variables at their
111111
declaration site or a ReferenceError may be thrown.
112+
--cond <expr> Optional condition for the probe location. The probe only
113+
records a hit when <expr> is truthy at the location. A
114+
condition that throws is treated as false.
112115
--max-hit <n> Per-probe limit on evaluated hits. When not specified,
113116
there's no hit limit. When any probe reaches its hit LIMIT,
114117
the probing process will detach and report the results.
@@ -121,6 +124,9 @@ Options:
121124
Semantics:
122125
* Multiple --probe/--expr pairs are allowed. Same-location --probes share
123126
a pause and scope, their --exprs are evaluated in command-line order.
127+
* --max-hit scopes to one --probe/--expr pair, so same-location pairs may set
128+
different limits. --cond scopes to the location, probes sharing a location
129+
must all share one condition (or none).
124130
* --probe utils.js:<line>[:<col>] matches every loaded utils.js. Pass a
125131
fuller path e.g. src/utils.js to narrow the match.
126132
* Use -- before any Node.js flags intended for the child process.

β€Žlib/internal/debugger/inspect_probe.jsβ€Ž

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const {
2121
StringPrototypeIncludes,
2222
StringPrototypeSlice,
2323
StringPrototypeStartsWith,
24+
StringPrototypeTrim,
2425
Symbol,
2526
}=primordials;
2627

@@ -87,6 +88,8 @@ const kInspectPortRegex = /^--inspect-port=(\d+)$/;
8788
* @typedef {object} Probe
8889
* @property {string} expr Expression to evaluate on hit.
8990
* @property {ProbeTarget} target User's original --probe request shape.
91+
* @property {string} [condition] Condition from --cond. V8 only breaks when it is truthy.
92+
* Scoped to the location, so probes sharing a location all carry the same value.
9093
* @property {number} maxHit Per-probe hit limit from --max-hit. Infinity when unlimited.
9194
* @property {number} hits Count of hits observed.
9295
*/
@@ -130,6 +133,12 @@ function formatTargetText(target) {
130133
returncolumn===undefined ? `${suffix}:${line}` : `${suffix}:${line}:${column}`;
131134
}
132135

136+
// Identity of a probe location. Probes sharing a key share one V8 breakpoint,
137+
// so this must stay in sync between condition validation and breakpoint setup.
138+
functionlocationKey(target){
139+
return`${target.suffix}\n${target.line}\n${target.column??''}`;
140+
}
141+
133142
functionformatPendingProbeLocations(probes,pending){
134143
constseen=newSafeSet();
135144
for(constprobeIndexofpending){
@@ -391,6 +400,22 @@ function parseProbeTokens(tokens, args) {
391400
probe.maxHit=parseUnsignedInteger(token.value,'max-hit');
392401
break;
393402
}
403+
case'cond': {
404+
if(probes.length===0){
405+
thrownewERR_DEBUGGER_STARTUP_ERROR('Unexpected --cond before --probe');
406+
}
407+
// A blank condition does not act as a real predicate in V8 (an empty
408+
// string always breaks), so reject it rather than silently mislead.
409+
if(token.value===undefined||StringPrototypeTrim(token.value)===''){
410+
thrownewERR_DEBUGGER_STARTUP_ERROR(`Missing value for ${token.rawName}`);
411+
}
412+
constprobe=probes[probes.length-1];
413+
if(probe.condition!==undefined){
414+
thrownewERR_DEBUGGER_STARTUP_ERROR('A --probe can have at most one --cond');
415+
}
416+
probe.condition=token.value;
417+
break;
418+
}
394419
default:
395420
if(probes.length>0){
396421
thrownewERR_DEBUGGER_STARTUP_ERROR(
@@ -410,6 +435,24 @@ function parseProbeTokens(tokens, args) {
410435
'Probe mode requires at least one --probe <loc> --expr <expr> group');
411436
}
412437

438+
// V8 allows only one breakpoint per location, so probes sharing a location
439+
// cannot carry different conditions.
440+
constconditionByLocation=newSafeMap();
441+
for(const{ target, condition }ofprobes){
442+
constkey=locationKey(target);
443+
// All probes at the same location must share one condition. We split the
444+
// existence check since if one probe does not have a condition (= undefined),
445+
// then all probes at the location must also omit it.
446+
if(conditionByLocation.has(key)){
447+
if(conditionByLocation.get(key)!==condition){
448+
thrownewERR_DEBUGGER_STARTUP_ERROR(
449+
`Probes at ${formatTargetText(target)} must use the same --cond (or none)`);
450+
}
451+
}else{
452+
conditionByLocation.set(key,condition);
453+
}
454+
}
455+
413456
constchildArgv=ArrayPrototypeSlice(args,childStartIndex);
414457
if(childArgv.length===0){
415458
thrownewERR_DEBUGGER_STARTUP_ERROR('Probe mode requires a child script');
@@ -479,8 +522,8 @@ class ProbeInspectorSession {
479522
this.resolveCompletion=resolve;
480523
/** @type {Probe[]} */
481524
this.probes=ArrayPrototypeMap(options.probes,
482-
({ expr, target, maxHit })=>
483-
({ expr, target,maxHit: maxHit??Infinity,hits: 0}));
525+
({ expr, target, maxHit, condition})=>
526+
({ expr, target,condition,maxHit: maxHit??Infinity,hits: 0}));
484527
this.onChildOutput=FunctionPrototypeBind(this.onChildOutput,this);
485528
this.onChildExit=FunctionPrototypeBind(this.onChildExit,this);
486529
this.onClientClose=FunctionPrototypeBind(this.onClientClose,this);
@@ -887,17 +930,19 @@ class ProbeInspectorSession {
887930
constuniqueTargets=newSafeMap();
888931

889932
for(letprobeIndex=0;probeIndex<this.probes.length;probeIndex++){
890-
const{ target }=this.probes[probeIndex];
891-
constkey=`${target.suffix}\n${target.line}\n${target.column??''}`;
933+
const{ target, condition }=this.probes[probeIndex];
934+
// Probes at the same location share one V8 breakpoint. parseProbeTokens has
935+
// already ensured they carry the same condition.
936+
constkey=locationKey(target);
892937
letentry=uniqueTargets.get(key);
893938
if(entry===undefined){
894-
entry={ target,probeIndices: []};
939+
entry={ target,condition,probeIndices: []};
895940
uniqueTargets.set(key,entry);
896941
}
897942
ArrayPrototypePush(entry.probeIndices,probeIndex);
898943
}
899944

900-
for(const{ target, probeIndices }ofuniqueTargets.values()){
945+
for(const{ target,condition,probeIndices }ofuniqueTargets.values()){
901946
// On Windows, normalize backslashes to forward slashes so the regex matches
902947
// V8 script URLs which always use forward slashes.
903948
constnormalizedFile=process.platform==='win32' ?
@@ -918,6 +963,9 @@ class ProbeInspectorSession {
918963
// the inspector bind to the first executable column.
919964
params.columnNumber=target.column-1;
920965
}
966+
if(condition!==undefined){
967+
params.condition=condition;
968+
}
921969

922970
constresult=awaitthis.callCdp('Debugger.setBreakpointByUrl',params);
923971
debug('breakpoint set: id=%s urlRegex=%s locations=%j',
@@ -943,9 +991,10 @@ class ProbeInspectorSession {
943991
code: exitCode,
944992
report: {
945993
v: kProbeVersion,
946-
probes: ArrayPrototypeMap(this.probes,({ expr, target, maxHit })=>{
947-
// Omit an unlimited maxHit, as Infinity would serialize to null in JSON.
994+
probes: ArrayPrototypeMap(this.probes,({ expr, target, maxHit, condition })=>{
948995
constprobe={ expr, target };
996+
if(condition!==undefined){probe.condition=condition;}
997+
// Omit an unlimited maxHit, as Infinity would serialize to null in JSON.
949998
if(maxHit!==Infinity){probe.maxHit=maxHit;}
950999
returnprobe;
9511000
}),
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// This tests that probe mode rejects malformed --cond usage.
2+
'use strict';
3+
4+
constcommon=require('../common');
5+
common.skipIfInspectorDisabled();
6+
7+
constfixtures=require('../common/fixtures');
8+
const{ assertProbeCliError }=require('../common/debugger-probe');
9+
10+
constcwd=fixtures.path('debugger');
11+
12+
assertProbeCliError(
13+
['--cond','x','--probe','probe.js:12','--expr','finalValue','probe.js'],
14+
/Unexpected--condbefore--probe/,{ cwd });
15+
16+
assertProbeCliError(
17+
['--probe','probe.js:12','--cond','x','--expr','finalValue','probe.js'],
18+
/Each--probemustbefollowedimmediatelyby--expr/,{ cwd });
19+
20+
assertProbeCliError(
21+
['--probe','probe.js:12','--expr','finalValue','--cond','x','--cond','y','probe.js'],
22+
/A--probecanhaveatmostone--cond/,{ cwd });
23+
24+
assertProbeCliError(
25+
['--probe','probe.js:12','--expr','finalValue','--cond',' ','probe.js'],
26+
/Missingvaluefor--cond/,{ cwd });
27+
28+
assertProbeCliError(
29+
['--probe','probe.js:12','--expr','finalValue','--cond'],
30+
/Missingvaluefor--cond/,{ cwd });
31+
32+
assertProbeCliError(
33+
['--probe','probe.js:12','--expr','a','--cond','x',
34+
'--probe','probe.js:12','--expr','b','--cond','y','probe.js'],
35+
/Probesatprobe\.js:12mustusethesame--cond\(ornone\)/,{ cwd });
36+
37+
assertProbeCliError(
38+
['--probe','probe.js:12','--expr','a','--cond','x',
39+
'--probe','probe.js:12','--expr','b','probe.js'],
40+
/Probesatprobe\.js:12mustusethesame--cond\(ornone\)/,{ cwd });
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// This tests that --cond and --max-hit work together.
2+
'use strict';
3+
4+
constcommon=require('../common');
5+
common.skipIfInspectorDisabled();
6+
7+
constfixtures=require('../common/fixtures');
8+
const{ spawnSyncAndAssert }=require('../common/child_process');
9+
const{ assertProbeJson }=require('../common/debugger-probe');
10+
11+
constcwd=fixtures.path('debugger');
12+
constprobeUrl=fixtures.fileURL('debugger','probe-max-hit.js').href;
13+
14+
// --max-hit written before --cond. The condition still filters index !== 1, so
15+
// the only recorded hit carries value 1 rather than the loop's first iteration.
16+
spawnSyncAndAssert(process.execPath,[
17+
'inspect',
18+
'--json',
19+
'--probe','probe-max-hit.js:5',
20+
'--expr','index',
21+
'--max-hit','5',
22+
'--cond','index === 1',
23+
'probe-max-hit.js',
24+
],{ cwd },{
25+
stdout(output){
26+
assertProbeJson(output,{
27+
v: 2,
28+
probes: [{
29+
expr: 'index',
30+
condition: 'index === 1',
31+
maxHit: 5,
32+
target: {suffix: 'probe-max-hit.js',line: 5},
33+
}],
34+
results: [
35+
{
36+
probe: 0,
37+
event: 'hit',
38+
hit: 1,
39+
location: {url: probeUrl,line: 5,column: 3},
40+
result: {type: 'number',value: 1,description: '1'},
41+
},
42+
{event: 'completed'},
43+
],
44+
});
45+
},
46+
trim: true,
47+
});

0 commit comments

Comments
Β (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 445bcce

Browse files
joyeecheungaduh95
authored andcommitted
inspector: add --cond to node inspect probe mode
On a hot path, the probe can record every hit and require filtering afterwards. This patch adds a per-probe `--cond <expr>` option that allows limiting the hit to only when the expression is truthy at the probe location. V8 evaluates it as the breakpoint's native condition, so the target is not paused when it does not hold, and a condition that throws is treated as false. Since in CDP, a location can only carry one breakpoint per URL pattern, probes sharing a location must share one condition (or none). Conflicting conditions are rejected. Example: ```js // app.js let total = 0; for (let i = 0; i < 10; i++) { total += i; // line 4 } ``` ``` $ out/Release/node inspect --probe app.js:4 --expr 'total' \ --cond 'i % 3 === 0' app.js ``` ``` Hit 1 at file:///path/to/app.js:3:3 total = 0 Hit 2 at file:///path/to/app.js:3:3 total = 3 Hit 3 at file:///path/to/app.js:3:3 total = 15 Hit 4 at file:///path/to/app.js:3:3 total = 36 Completed ``` Signed-off-by: Joyee Cheung <joyeec9h3@gmail.com> PR-URL: #64328 Refs: #63646 Reviewed-By: James M Snell <jasnell@gmail.com>
1 parent 5d90e48 commit 445bcce

9 files changed

Lines changed: 348 additions & 17 deletions

β€Ždoc/api/debugger.mdβ€Ž

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ debug>
236236
added:
237237
- v24.16.0
238238
changes:
239+
- version: REPLACEME
240+
pr-url: https://github.com/nodejs/node/pull/64328
241+
description: Add per-probe `--cond <expr>` option to only record a hit when the
242+
condition is truthy at the probe location.
239243
- version: v24.19.0
240244
pr-url: https://github.com/nodejs/node/pull/63704
241245
description: Add per-probe `--max-hit <n>` option to limit evaluated hits and finish
@@ -268,8 +272,8 @@ printf-style debugging without having to modify the application code and
268272
clean up afterwards. It also supports structured JSON output for tool use.
269273

270274
```console
271-
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
272-
[--probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>] ...]
275+
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>]
276+
[--probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>] ...]
273277
[--json] [--preview] [--timeout=<ms>] [--port=<port>]
274278
[--] [<node-option> ...] <script> [<script-args> ...]
275279
```
@@ -282,6 +286,9 @@ $ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
282286
*`--expr <expr>`: JavaScript expression to evaluate whenever execution reaches
283287
the location specified by the preceding `--probe`.
284288
Must immediately follow the `--probe` it belongs to.
289+
*`--cond <expr>`: An optional condition for the probe location. The probe only
290+
records a hit when `<expr>` is truthy at the location. A condition that throws
291+
is treated as false.
285292
*`--max-hit <n>`: An optional per-probe limit on the number of times the probe
286293
can be hit. When not specified, there's no hit limit. When any probe reaches
287294
its hit limit, the probing process will detach and report the results. The process
@@ -297,14 +304,17 @@ $ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
297304
will listen. Defaults to `0`, which requests a random port.
298305
*`--` is optional unless the child needs its own Node.js flags.
299306

300-
Additional rules about the `--probe` and `--expr` arguments:
307+
Additional rules about the composition of the options:
301308

302309
*`--probe <file>:<line>[:<col>]` and `--expr <expr>` are strict pairs. Each
303310
`--probe` must be followed immediately by exactly one `--expr`.
304-
*`--max-hit <n>` is an optional per-probe option that applies to the most recent
305-
`--probe`/`--expr` pair. It may not appear before the first `--probe` or
306-
between a `--probe` and its matching `--expr`, and may be given at most once
307-
per probe.
311+
*`--cond <expr>` and `--max-hit <n>` are optional modifiers written _after_ the
312+
`--probe`/`--expr` pair they apply to, each at most once per pair. They may not
313+
appear before the first `--probe` or between a `--probe` and its matching
314+
`--expr`.
315+
*`--max-hit` scopes to the `--probe`/`--expr` pair it follows, so pairs
316+
sharing a location may set different limits. `--cond` scopes to the whole
317+
location, probes sharing a location must share one condition (or none).
308318
*`--timeout`, `--json`, `--preview`, and `--port` are global probe options
309319
for the whole probe session. They may appear before or between probe pairs,
310320
but not between a `--probe` and its matching `--expr`.
@@ -379,6 +389,7 @@ $ node inspect --json --probe cli.js:5 --expr 'rss' cli.js
379389
"suffix": "cli.js",
380390
"line": 5
381391
}
392+
// `condition` is present only when the probe was given a --cond expression.
382393
// `maxHit` is present only when the probe was given a --max-hit limit.
383394
}
384395
],
@@ -480,6 +491,10 @@ When multiple `--probe`/`--expr` pairs share the same `--probe`, the
480491
expressions will be evaluated on the same pause in the order they appear
481492
on the command line.
482493

494+
For each location, there can only be at most one `--cond` (or none).
495+
Multiple `--probe`/`--expr` pairs with conflicting conditions
496+
at the same location will be rejected at launch time.
497+
483498
```js
484499
// app.js
485500
constx= { x:42 }; // line 2
@@ -563,6 +578,37 @@ that only matches the intended file:
563578
$ node inspect --probe src/utils.js:10 --expr 'x' main.js # matches only src/utils.js
564579
```
565580

581+
### Probe examples
582+
583+
#### Probing a variable conditionally
584+
585+
```js
586+
// app.js
587+
let total =0;
588+
for (let i =0; i <10; i++) {
589+
total += i; // line 4
590+
}
591+
```
592+
593+
```console
594+
$ out/Release/node inspect --probe app.js:4 --expr 'total' \
595+
--cond 'i % 3 === 0' app.js
596+
```
597+
598+
```text
599+
Hit 1 at file:///path/to/app.js:3:3
600+
total = 0
601+
Hit 2 at file:///path/to/app.js:3:3
602+
total = 3
603+
Hit 3 at file:///path/to/app.js:3:3
604+
total = 15
605+
Hit 4 at file:///path/to/app.js:3:3
606+
total = 36
607+
Completed
608+
```
609+
610+
<!-- TODO(joyeecheung): add more examples for different options -->
611+
566612
## Advanced usage
567613

568614
### V8 inspector integration for Node.js

β€Žlib/internal/debugger/inspect.jsβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ function parseInteractiveArgs(args) {
268268

269269
constkInspectArgOptions={
270270
'__proto__': null,
271+
'cond': {type: 'string'},
271272
'expr': {type: 'string'},
272273
'help': {type: 'boolean',short: 'h'},
273274
'json': {type: 'boolean'},

β€Žlib/internal/debugger/inspect_helpers.jsβ€Ž

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ function writeInspectUsageAndExit(invokedAs, message, exitCode) {
6969
}
7070
out.write(`Usage: ${invokedAs} [--port=<port>] [<node-option> ...]
7171
[<script> [<script-args>] | <host>:<port> | -p <pid>]
72-
${invokedAs} --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
73-
[--probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>] ...]
72+
${invokedAs} --probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>]
73+
[--probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>] ...]
7474
[--json] [--preview] [--timeout=<ms>] [--port=<port>]
7575
[--] [<node-option> ...] <script> [<script-args> ...]
7676
@@ -109,6 +109,9 @@ Options:
109109
preceding --probe each time execution reaches it.
110110
Avoid probing let/const-bound variables at their
111111
declaration site or a ReferenceError may be thrown.
112+
--cond <expr> Optional condition for the probe location. The probe only
113+
records a hit when <expr> is truthy at the location. A
114+
condition that throws is treated as false.
112115
--max-hit <n> Per-probe limit on evaluated hits. When not specified,
113116
there's no hit limit. When any probe reaches its hit LIMIT,
114117
the probing process will detach and report the results.
@@ -121,6 +124,9 @@ Options:
121124
Semantics:
122125
* Multiple --probe/--expr pairs are allowed. Same-location --probes share
123126
a pause and scope, their --exprs are evaluated in command-line order.
127+
* --max-hit scopes to one --probe/--expr pair, so same-location pairs may set
128+
different limits. --cond scopes to the location, probes sharing a location
129+
must all share one condition (or none).
124130
* --probe utils.js:<line>[:<col>] matches every loaded utils.js. Pass a
125131
fuller path e.g. src/utils.js to narrow the match.
126132
* Use -- before any Node.js flags intended for the child process.

β€Žlib/internal/debugger/inspect_probe.jsβ€Ž

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const {
2121
StringPrototypeIncludes,
2222
StringPrototypeSlice,
2323
StringPrototypeStartsWith,
24+
StringPrototypeTrim,
2425
Symbol,
2526
}=primordials;
2627

@@ -87,6 +88,8 @@ const kInspectPortRegex = /^--inspect-port=(\d+)$/;
8788
* @typedef {object} Probe
8889
* @property {string} expr Expression to evaluate on hit.
8990
* @property {ProbeTarget} target User's original --probe request shape.
91+
* @property {string} [condition] Condition from --cond. V8 only breaks when it is truthy.
92+
* Scoped to the location, so probes sharing a location all carry the same value.
9093
* @property {number} maxHit Per-probe hit limit from --max-hit. Infinity when unlimited.
9194
* @property {number} hits Count of hits observed.
9295
*/
@@ -130,6 +133,12 @@ function formatTargetText(target) {
130133
returncolumn===undefined ? `${suffix}:${line}` : `${suffix}:${line}:${column}`;
131134
}
132135

136+
// Identity of a probe location. Probes sharing a key share one V8 breakpoint,
137+
// so this must stay in sync between condition validation and breakpoint setup.
138+
functionlocationKey(target){
139+
return`${target.suffix}\n${target.line}\n${target.column??''}`;
140+
}
141+
133142
functionformatPendingProbeLocations(probes,pending){
134143
constseen=newSafeSet();
135144
for(constprobeIndexofpending){
@@ -391,6 +400,22 @@ function parseProbeTokens(tokens, args) {
391400
probe.maxHit=parseUnsignedInteger(token.value,'max-hit');
392401
break;
393402
}
403+
case'cond': {
404+
if(probes.length===0){
405+
thrownewERR_DEBUGGER_STARTUP_ERROR('Unexpected --cond before --probe');
406+
}
407+
// A blank condition does not act as a real predicate in V8 (an empty
408+
// string always breaks), so reject it rather than silently mislead.
409+
if(token.value===undefined||StringPrototypeTrim(token.value)===''){
410+
thrownewERR_DEBUGGER_STARTUP_ERROR(`Missing value for ${token.rawName}`);
411+
}
412+
constprobe=probes[probes.length-1];
413+
if(probe.condition!==undefined){
414+
thrownewERR_DEBUGGER_STARTUP_ERROR('A --probe can have at most one --cond');
415+
}
416+
probe.condition=token.value;
417+
break;
418+
}
394419
default:
395420
if(probes.length>0){
396421
thrownewERR_DEBUGGER_STARTUP_ERROR(
@@ -410,6 +435,24 @@ function parseProbeTokens(tokens, args) {
410435
'Probe mode requires at least one --probe <loc> --expr <expr> group');
411436
}
412437

438+
// V8 allows only one breakpoint per location, so probes sharing a location
439+
// cannot carry different conditions.
440+
constconditionByLocation=newSafeMap();
441+
for(const{ target, condition }ofprobes){
442+
constkey=locationKey(target);
443+
// All probes at the same location must share one condition. We split the
444+
// existence check since if one probe does not have a condition (= undefined),
445+
// then all probes at the location must also omit it.
446+
if(conditionByLocation.has(key)){
447+
if(conditionByLocation.get(key)!==condition){
448+
thrownewERR_DEBUGGER_STARTUP_ERROR(
449+
`Probes at ${formatTargetText(target)} must use the same --cond (or none)`);
450+
}
451+
}else{
452+
conditionByLocation.set(key,condition);
453+
}
454+
}
455+
413456
constchildArgv=ArrayPrototypeSlice(args,childStartIndex);
414457
if(childArgv.length===0){
415458
thrownewERR_DEBUGGER_STARTUP_ERROR('Probe mode requires a child script');
@@ -479,8 +522,8 @@ class ProbeInspectorSession {
479522
this.resolveCompletion=resolve;
480523
/** @type {Probe[]} */
481524
this.probes=ArrayPrototypeMap(options.probes,
482-
({ expr, target, maxHit })=>
483-
({ expr, target,maxHit: maxHit??Infinity,hits: 0}));
525+
({ expr, target, maxHit, condition})=>
526+
({ expr, target,condition,maxHit: maxHit??Infinity,hits: 0}));
484527
this.onChildOutput=FunctionPrototypeBind(this.onChildOutput,this);
485528
this.onChildExit=FunctionPrototypeBind(this.onChildExit,this);
486529
this.onClientClose=FunctionPrototypeBind(this.onClientClose,this);
@@ -887,17 +930,19 @@ class ProbeInspectorSession {
887930
constuniqueTargets=newSafeMap();
888931

889932
for(letprobeIndex=0;probeIndex<this.probes.length;probeIndex++){
890-
const{ target }=this.probes[probeIndex];
891-
constkey=`${target.suffix}\n${target.line}\n${target.column??''}`;
933+
const{ target, condition }=this.probes[probeIndex];
934+
// Probes at the same location share one V8 breakpoint. parseProbeTokens has
935+
// already ensured they carry the same condition.
936+
constkey=locationKey(target);
892937
letentry=uniqueTargets.get(key);
893938
if(entry===undefined){
894-
entry={ target,probeIndices: []};
939+
entry={ target,condition,probeIndices: []};
895940
uniqueTargets.set(key,entry);
896941
}
897942
ArrayPrototypePush(entry.probeIndices,probeIndex);
898943
}
899944

900-
for(const{ target, probeIndices }ofuniqueTargets.values()){
945+
for(const{ target,condition,probeIndices }ofuniqueTargets.values()){
901946
// On Windows, normalize backslashes to forward slashes so the regex matches
902947
// V8 script URLs which always use forward slashes.
903948
constnormalizedFile=process.platform==='win32' ?
@@ -918,6 +963,9 @@ class ProbeInspectorSession {
918963
// the inspector bind to the first executable column.
919964
params.columnNumber=target.column-1;
920965
}
966+
if(condition!==undefined){
967+
params.condition=condition;
968+
}
921969

922970
constresult=awaitthis.callCdp('Debugger.setBreakpointByUrl',params);
923971
debug('breakpoint set: id=%s urlRegex=%s locations=%j',
@@ -943,9 +991,10 @@ class ProbeInspectorSession {
943991
code: exitCode,
944992
report: {
945993
v: kProbeVersion,
946-
probes: ArrayPrototypeMap(this.probes,({ expr, target, maxHit })=>{
947-
// Omit an unlimited maxHit, as Infinity would serialize to null in JSON.
994+
probes: ArrayPrototypeMap(this.probes,({ expr, target, maxHit, condition })=>{
948995
constprobe={ expr, target };
996+
if(condition!==undefined){probe.condition=condition;}
997+
// Omit an unlimited maxHit, as Infinity would serialize to null in JSON.
949998
if(maxHit!==Infinity){probe.maxHit=maxHit;}
950999
returnprobe;
9511000
}),
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// This tests that probe mode rejects malformed --cond usage.
2+
'use strict';
3+
4+
constcommon=require('../common');
5+
common.skipIfInspectorDisabled();
6+
7+
constfixtures=require('../common/fixtures');
8+
const{ assertProbeCliError }=require('../common/debugger-probe');
9+
10+
constcwd=fixtures.path('debugger');
11+
12+
assertProbeCliError(
13+
['--cond','x','--probe','probe.js:12','--expr','finalValue','probe.js'],
14+
/Unexpected--condbefore--probe/,{ cwd });
15+
16+
assertProbeCliError(
17+
['--probe','probe.js:12','--cond','x','--expr','finalValue','probe.js'],
18+
/Each--probemustbefollowedimmediatelyby--expr/,{ cwd });
19+
20+
assertProbeCliError(
21+
['--probe','probe.js:12','--expr','finalValue','--cond','x','--cond','y','probe.js'],
22+
/A--probecanhaveatmostone--cond/,{ cwd });
23+
24+
assertProbeCliError(
25+
['--probe','probe.js:12','--expr','finalValue','--cond',' ','probe.js'],
26+
/Missingvaluefor--cond/,{ cwd });
27+
28+
assertProbeCliError(
29+
['--probe','probe.js:12','--expr','finalValue','--cond'],
30+
/Missingvaluefor--cond/,{ cwd });
31+
32+
assertProbeCliError(
33+
['--probe','probe.js:12','--expr','a','--cond','x',
34+
'--probe','probe.js:12','--expr','b','--cond','y','probe.js'],
35+
/Probesatprobe\.js:12mustusethesame--cond\(ornone\)/,{ cwd });
36+
37+
assertProbeCliError(
38+
['--probe','probe.js:12','--expr','a','--cond','x',
39+
'--probe','probe.js:12','--expr','b','probe.js'],
40+
/Probesatprobe\.js:12mustusethesame--cond\(ornone\)/,{ cwd });
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// This tests that --cond and --max-hit work together.
2+
'use strict';
3+
4+
constcommon=require('../common');
5+
common.skipIfInspectorDisabled();
6+
7+
constfixtures=require('../common/fixtures');
8+
const{ spawnSyncAndAssert }=require('../common/child_process');
9+
const{ assertProbeJson }=require('../common/debugger-probe');
10+
11+
constcwd=fixtures.path('debugger');
12+
constprobeUrl=fixtures.fileURL('debugger','probe-max-hit.js').href;
13+
14+
// --max-hit written before --cond. The condition still filters index !== 1, so
15+
// the only recorded hit carries value 1 rather than the loop's first iteration.
16+
spawnSyncAndAssert(process.execPath,[
17+
'inspect',
18+
'--json',
19+
'--probe','probe-max-hit.js:5',
20+
'--expr','index',
21+
'--max-hit','5',
22+
'--cond','index === 1',
23+
'probe-max-hit.js',
24+
],{ cwd },{
25+
stdout(output){
26+
assertProbeJson(output,{
27+
v: 2,
28+
probes: [{
29+
expr: 'index',
30+
condition: 'index === 1',
31+
maxHit: 5,
32+
target: {suffix: 'probe-max-hit.js',line: 5},
33+
}],
34+
results: [
35+
{
36+
probe: 0,
37+
event: 'hit',
38+
hit: 1,
39+
location: {url: probeUrl,line: 5,column: 3},
40+
result: {type: 'number',value: 1,description: '1'},
41+
},
42+
{event: 'completed'},
43+
],
44+
});
45+
},
46+
trim: true,
47+
});

0 commit comments

Comments
Β (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 445bcce

Browse files
joyeecheungaduh95
authored andcommitted
inspector: add --cond to node inspect probe mode
On a hot path, the probe can record every hit and require filtering afterwards. This patch adds a per-probe `--cond <expr>` option that allows limiting the hit to only when the expression is truthy at the probe location. V8 evaluates it as the breakpoint's native condition, so the target is not paused when it does not hold, and a condition that throws is treated as false. Since in CDP, a location can only carry one breakpoint per URL pattern, probes sharing a location must share one condition (or none). Conflicting conditions are rejected. Example: ```js // app.js let total = 0; for (let i = 0; i < 10; i++) { total += i; // line 4 } ``` ``` $ out/Release/node inspect --probe app.js:4 --expr 'total' \ --cond 'i % 3 === 0' app.js ``` ``` Hit 1 at file:///path/to/app.js:3:3 total = 0 Hit 2 at file:///path/to/app.js:3:3 total = 3 Hit 3 at file:///path/to/app.js:3:3 total = 15 Hit 4 at file:///path/to/app.js:3:3 total = 36 Completed ``` Signed-off-by: Joyee Cheung <joyeec9h3@gmail.com> PR-URL: #64328 Refs: #63646 Reviewed-By: James M Snell <jasnell@gmail.com>
1 parent 5d90e48 commit 445bcce

9 files changed

Lines changed: 348 additions & 17 deletions

β€Ždoc/api/debugger.mdβ€Ž

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ debug>
236236
added:
237237
- v24.16.0
238238
changes:
239+
- version: REPLACEME
240+
pr-url: https://github.com/nodejs/node/pull/64328
241+
description: Add per-probe `--cond <expr>` option to only record a hit when the
242+
condition is truthy at the probe location.
239243
- version: v24.19.0
240244
pr-url: https://github.com/nodejs/node/pull/63704
241245
description: Add per-probe `--max-hit <n>` option to limit evaluated hits and finish
@@ -268,8 +272,8 @@ printf-style debugging without having to modify the application code and
268272
clean up afterwards. It also supports structured JSON output for tool use.
269273

270274
```console
271-
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
272-
[--probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>] ...]
275+
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>]
276+
[--probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>] ...]
273277
[--json] [--preview] [--timeout=<ms>] [--port=<port>]
274278
[--] [<node-option> ...] <script> [<script-args> ...]
275279
```
@@ -282,6 +286,9 @@ $ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
282286
*`--expr <expr>`: JavaScript expression to evaluate whenever execution reaches
283287
the location specified by the preceding `--probe`.
284288
Must immediately follow the `--probe` it belongs to.
289+
*`--cond <expr>`: An optional condition for the probe location. The probe only
290+
records a hit when `<expr>` is truthy at the location. A condition that throws
291+
is treated as false.
285292
*`--max-hit <n>`: An optional per-probe limit on the number of times the probe
286293
can be hit. When not specified, there's no hit limit. When any probe reaches
287294
its hit limit, the probing process will detach and report the results. The process
@@ -297,14 +304,17 @@ $ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
297304
will listen. Defaults to `0`, which requests a random port.
298305
*`--` is optional unless the child needs its own Node.js flags.
299306

300-
Additional rules about the `--probe` and `--expr` arguments:
307+
Additional rules about the composition of the options:
301308

302309
*`--probe <file>:<line>[:<col>]` and `--expr <expr>` are strict pairs. Each
303310
`--probe` must be followed immediately by exactly one `--expr`.
304-
*`--max-hit <n>` is an optional per-probe option that applies to the most recent
305-
`--probe`/`--expr` pair. It may not appear before the first `--probe` or
306-
between a `--probe` and its matching `--expr`, and may be given at most once
307-
per probe.
311+
*`--cond <expr>` and `--max-hit <n>` are optional modifiers written _after_ the
312+
`--probe`/`--expr` pair they apply to, each at most once per pair. They may not
313+
appear before the first `--probe` or between a `--probe` and its matching
314+
`--expr`.
315+
*`--max-hit` scopes to the `--probe`/`--expr` pair it follows, so pairs
316+
sharing a location may set different limits. `--cond` scopes to the whole
317+
location, probes sharing a location must share one condition (or none).
308318
*`--timeout`, `--json`, `--preview`, and `--port` are global probe options
309319
for the whole probe session. They may appear before or between probe pairs,
310320
but not between a `--probe` and its matching `--expr`.
@@ -379,6 +389,7 @@ $ node inspect --json --probe cli.js:5 --expr 'rss' cli.js
379389
"suffix": "cli.js",
380390
"line": 5
381391
}
392+
// `condition` is present only when the probe was given a --cond expression.
382393
// `maxHit` is present only when the probe was given a --max-hit limit.
383394
}
384395
],
@@ -480,6 +491,10 @@ When multiple `--probe`/`--expr` pairs share the same `--probe`, the
480491
expressions will be evaluated on the same pause in the order they appear
481492
on the command line.
482493

494+
For each location, there can only be at most one `--cond` (or none).
495+
Multiple `--probe`/`--expr` pairs with conflicting conditions
496+
at the same location will be rejected at launch time.
497+
483498
```js
484499
// app.js
485500
constx= { x:42 }; // line 2
@@ -563,6 +578,37 @@ that only matches the intended file:
563578
$ node inspect --probe src/utils.js:10 --expr 'x' main.js # matches only src/utils.js
564579
```
565580

581+
### Probe examples
582+
583+
#### Probing a variable conditionally
584+
585+
```js
586+
// app.js
587+
let total =0;
588+
for (let i =0; i <10; i++) {
589+
total += i; // line 4
590+
}
591+
```
592+
593+
```console
594+
$ out/Release/node inspect --probe app.js:4 --expr 'total' \
595+
--cond 'i % 3 === 0' app.js
596+
```
597+
598+
```text
599+
Hit 1 at file:///path/to/app.js:3:3
600+
total = 0
601+
Hit 2 at file:///path/to/app.js:3:3
602+
total = 3
603+
Hit 3 at file:///path/to/app.js:3:3
604+
total = 15
605+
Hit 4 at file:///path/to/app.js:3:3
606+
total = 36
607+
Completed
608+
```
609+
610+
<!-- TODO(joyeecheung): add more examples for different options -->
611+
566612
## Advanced usage
567613

568614
### V8 inspector integration for Node.js

β€Žlib/internal/debugger/inspect.jsβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ function parseInteractiveArgs(args) {
268268

269269
constkInspectArgOptions={
270270
'__proto__': null,
271+
'cond': {type: 'string'},
271272
'expr': {type: 'string'},
272273
'help': {type: 'boolean',short: 'h'},
273274
'json': {type: 'boolean'},

β€Žlib/internal/debugger/inspect_helpers.jsβ€Ž

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ function writeInspectUsageAndExit(invokedAs, message, exitCode) {
6969
}
7070
out.write(`Usage: ${invokedAs} [--port=<port>] [<node-option> ...]
7171
[<script> [<script-args>] | <host>:<port> | -p <pid>]
72-
${invokedAs} --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
73-
[--probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>] ...]
72+
${invokedAs} --probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>]
73+
[--probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>] ...]
7474
[--json] [--preview] [--timeout=<ms>] [--port=<port>]
7575
[--] [<node-option> ...] <script> [<script-args> ...]
7676
@@ -109,6 +109,9 @@ Options:
109109
preceding --probe each time execution reaches it.
110110
Avoid probing let/const-bound variables at their
111111
declaration site or a ReferenceError may be thrown.
112+
--cond <expr> Optional condition for the probe location. The probe only
113+
records a hit when <expr> is truthy at the location. A
114+
condition that throws is treated as false.
112115
--max-hit <n> Per-probe limit on evaluated hits. When not specified,
113116
there's no hit limit. When any probe reaches its hit LIMIT,
114117
the probing process will detach and report the results.
@@ -121,6 +124,9 @@ Options:
121124
Semantics:
122125
* Multiple --probe/--expr pairs are allowed. Same-location --probes share
123126
a pause and scope, their --exprs are evaluated in command-line order.
127+
* --max-hit scopes to one --probe/--expr pair, so same-location pairs may set
128+
different limits. --cond scopes to the location, probes sharing a location
129+
must all share one condition (or none).
124130
* --probe utils.js:<line>[:<col>] matches every loaded utils.js. Pass a
125131
fuller path e.g. src/utils.js to narrow the match.
126132
* Use -- before any Node.js flags intended for the child process.

β€Žlib/internal/debugger/inspect_probe.jsβ€Ž

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const {
2121
StringPrototypeIncludes,
2222
StringPrototypeSlice,
2323
StringPrototypeStartsWith,
24+
StringPrototypeTrim,
2425
Symbol,
2526
}=primordials;
2627

@@ -87,6 +88,8 @@ const kInspectPortRegex = /^--inspect-port=(\d+)$/;
8788
* @typedef {object} Probe
8889
* @property {string} expr Expression to evaluate on hit.
8990
* @property {ProbeTarget} target User's original --probe request shape.
91+
* @property {string} [condition] Condition from --cond. V8 only breaks when it is truthy.
92+
* Scoped to the location, so probes sharing a location all carry the same value.
9093
* @property {number} maxHit Per-probe hit limit from --max-hit. Infinity when unlimited.
9194
* @property {number} hits Count of hits observed.
9295
*/
@@ -130,6 +133,12 @@ function formatTargetText(target) {
130133
returncolumn===undefined ? `${suffix}:${line}` : `${suffix}:${line}:${column}`;
131134
}
132135

136+
// Identity of a probe location. Probes sharing a key share one V8 breakpoint,
137+
// so this must stay in sync between condition validation and breakpoint setup.
138+
functionlocationKey(target){
139+
return`${target.suffix}\n${target.line}\n${target.column??''}`;
140+
}
141+
133142
functionformatPendingProbeLocations(probes,pending){
134143
constseen=newSafeSet();
135144
for(constprobeIndexofpending){
@@ -391,6 +400,22 @@ function parseProbeTokens(tokens, args) {
391400
probe.maxHit=parseUnsignedInteger(token.value,'max-hit');
392401
break;
393402
}
403+
case'cond': {
404+
if(probes.length===0){
405+
thrownewERR_DEBUGGER_STARTUP_ERROR('Unexpected --cond before --probe');
406+
}
407+
// A blank condition does not act as a real predicate in V8 (an empty
408+
// string always breaks), so reject it rather than silently mislead.
409+
if(token.value===undefined||StringPrototypeTrim(token.value)===''){
410+
thrownewERR_DEBUGGER_STARTUP_ERROR(`Missing value for ${token.rawName}`);
411+
}
412+
constprobe=probes[probes.length-1];
413+
if(probe.condition!==undefined){
414+
thrownewERR_DEBUGGER_STARTUP_ERROR('A --probe can have at most one --cond');
415+
}
416+
probe.condition=token.value;
417+
break;
418+
}
394419
default:
395420
if(probes.length>0){
396421
thrownewERR_DEBUGGER_STARTUP_ERROR(
@@ -410,6 +435,24 @@ function parseProbeTokens(tokens, args) {
410435
'Probe mode requires at least one --probe <loc> --expr <expr> group');
411436
}
412437

438+
// V8 allows only one breakpoint per location, so probes sharing a location
439+
// cannot carry different conditions.
440+
constconditionByLocation=newSafeMap();
441+
for(const{ target, condition }ofprobes){
442+
constkey=locationKey(target);
443+
// All probes at the same location must share one condition. We split the
444+
// existence check since if one probe does not have a condition (= undefined),
445+
// then all probes at the location must also omit it.
446+
if(conditionByLocation.has(key)){
447+
if(conditionByLocation.get(key)!==condition){
448+
thrownewERR_DEBUGGER_STARTUP_ERROR(
449+
`Probes at ${formatTargetText(target)} must use the same --cond (or none)`);
450+
}
451+
}else{
452+
conditionByLocation.set(key,condition);
453+
}
454+
}
455+
413456
constchildArgv=ArrayPrototypeSlice(args,childStartIndex);
414457
if(childArgv.length===0){
415458
thrownewERR_DEBUGGER_STARTUP_ERROR('Probe mode requires a child script');
@@ -479,8 +522,8 @@ class ProbeInspectorSession {
479522
this.resolveCompletion=resolve;
480523
/** @type {Probe[]} */
481524
this.probes=ArrayPrototypeMap(options.probes,
482-
({ expr, target, maxHit })=>
483-
({ expr, target,maxHit: maxHit??Infinity,hits: 0}));
525+
({ expr, target, maxHit, condition})=>
526+
({ expr, target,condition,maxHit: maxHit??Infinity,hits: 0}));
484527
this.onChildOutput=FunctionPrototypeBind(this.onChildOutput,this);
485528
this.onChildExit=FunctionPrototypeBind(this.onChildExit,this);
486529
this.onClientClose=FunctionPrototypeBind(this.onClientClose,this);
@@ -887,17 +930,19 @@ class ProbeInspectorSession {
887930
constuniqueTargets=newSafeMap();
888931

889932
for(letprobeIndex=0;probeIndex<this.probes.length;probeIndex++){
890-
const{ target }=this.probes[probeIndex];
891-
constkey=`${target.suffix}\n${target.line}\n${target.column??''}`;
933+
const{ target, condition }=this.probes[probeIndex];
934+
// Probes at the same location share one V8 breakpoint. parseProbeTokens has
935+
// already ensured they carry the same condition.
936+
constkey=locationKey(target);
892937
letentry=uniqueTargets.get(key);
893938
if(entry===undefined){
894-
entry={ target,probeIndices: []};
939+
entry={ target,condition,probeIndices: []};
895940
uniqueTargets.set(key,entry);
896941
}
897942
ArrayPrototypePush(entry.probeIndices,probeIndex);
898943
}
899944

900-
for(const{ target, probeIndices }ofuniqueTargets.values()){
945+
for(const{ target,condition,probeIndices }ofuniqueTargets.values()){
901946
// On Windows, normalize backslashes to forward slashes so the regex matches
902947
// V8 script URLs which always use forward slashes.
903948
constnormalizedFile=process.platform==='win32' ?
@@ -918,6 +963,9 @@ class ProbeInspectorSession {
918963
// the inspector bind to the first executable column.
919964
params.columnNumber=target.column-1;
920965
}
966+
if(condition!==undefined){
967+
params.condition=condition;
968+
}
921969

922970
constresult=awaitthis.callCdp('Debugger.setBreakpointByUrl',params);
923971
debug('breakpoint set: id=%s urlRegex=%s locations=%j',
@@ -943,9 +991,10 @@ class ProbeInspectorSession {
943991
code: exitCode,
944992
report: {
945993
v: kProbeVersion,
946-
probes: ArrayPrototypeMap(this.probes,({ expr, target, maxHit })=>{
947-
// Omit an unlimited maxHit, as Infinity would serialize to null in JSON.
994+
probes: ArrayPrototypeMap(this.probes,({ expr, target, maxHit, condition })=>{
948995
constprobe={ expr, target };
996+
if(condition!==undefined){probe.condition=condition;}
997+
// Omit an unlimited maxHit, as Infinity would serialize to null in JSON.
949998
if(maxHit!==Infinity){probe.maxHit=maxHit;}
950999
returnprobe;
9511000
}),
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// This tests that probe mode rejects malformed --cond usage.
2+
'use strict';
3+
4+
constcommon=require('../common');
5+
common.skipIfInspectorDisabled();
6+
7+
constfixtures=require('../common/fixtures');
8+
const{ assertProbeCliError }=require('../common/debugger-probe');
9+
10+
constcwd=fixtures.path('debugger');
11+
12+
assertProbeCliError(
13+
['--cond','x','--probe','probe.js:12','--expr','finalValue','probe.js'],
14+
/Unexpected--condbefore--probe/,{ cwd });
15+
16+
assertProbeCliError(
17+
['--probe','probe.js:12','--cond','x','--expr','finalValue','probe.js'],
18+
/Each--probemustbefollowedimmediatelyby--expr/,{ cwd });
19+
20+
assertProbeCliError(
21+
['--probe','probe.js:12','--expr','finalValue','--cond','x','--cond','y','probe.js'],
22+
/A--probecanhaveatmostone--cond/,{ cwd });
23+
24+
assertProbeCliError(
25+
['--probe','probe.js:12','--expr','finalValue','--cond',' ','probe.js'],
26+
/Missingvaluefor--cond/,{ cwd });
27+
28+
assertProbeCliError(
29+
['--probe','probe.js:12','--expr','finalValue','--cond'],
30+
/Missingvaluefor--cond/,{ cwd });
31+
32+
assertProbeCliError(
33+
['--probe','probe.js:12','--expr','a','--cond','x',
34+
'--probe','probe.js:12','--expr','b','--cond','y','probe.js'],
35+
/Probesatprobe\.js:12mustusethesame--cond\(ornone\)/,{ cwd });
36+
37+
assertProbeCliError(
38+
['--probe','probe.js:12','--expr','a','--cond','x',
39+
'--probe','probe.js:12','--expr','b','probe.js'],
40+
/Probesatprobe\.js:12mustusethesame--cond\(ornone\)/,{ cwd });
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// This tests that --cond and --max-hit work together.
2+
'use strict';
3+
4+
constcommon=require('../common');
5+
common.skipIfInspectorDisabled();
6+
7+
constfixtures=require('../common/fixtures');
8+
const{ spawnSyncAndAssert }=require('../common/child_process');
9+
const{ assertProbeJson }=require('../common/debugger-probe');
10+
11+
constcwd=fixtures.path('debugger');
12+
constprobeUrl=fixtures.fileURL('debugger','probe-max-hit.js').href;
13+
14+
// --max-hit written before --cond. The condition still filters index !== 1, so
15+
// the only recorded hit carries value 1 rather than the loop's first iteration.
16+
spawnSyncAndAssert(process.execPath,[
17+
'inspect',
18+
'--json',
19+
'--probe','probe-max-hit.js:5',
20+
'--expr','index',
21+
'--max-hit','5',
22+
'--cond','index === 1',
23+
'probe-max-hit.js',
24+
],{ cwd },{
25+
stdout(output){
26+
assertProbeJson(output,{
27+
v: 2,
28+
probes: [{
29+
expr: 'index',
30+
condition: 'index === 1',
31+
maxHit: 5,
32+
target: {suffix: 'probe-max-hit.js',line: 5},
33+
}],
34+
results: [
35+
{
36+
probe: 0,
37+
event: 'hit',
38+
hit: 1,
39+
location: {url: probeUrl,line: 5,column: 3},
40+
result: {type: 'number',value: 1,description: '1'},
41+
},
42+
{event: 'completed'},
43+
],
44+
});
45+
},
46+
trim: true,
47+
});

0 commit comments

Comments
Β (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Commit 445bcce

Browse files
joyeecheungaduh95
authored andcommitted
inspector: add --cond to node inspect probe mode
On a hot path, the probe can record every hit and require filtering afterwards. This patch adds a per-probe `--cond <expr>` option that allows limiting the hit to only when the expression is truthy at the probe location. V8 evaluates it as the breakpoint's native condition, so the target is not paused when it does not hold, and a condition that throws is treated as false. Since in CDP, a location can only carry one breakpoint per URL pattern, probes sharing a location must share one condition (or none). Conflicting conditions are rejected. Example: ```js // app.js let total = 0; for (let i = 0; i < 10; i++) { total += i; // line 4 } ``` ``` $ out/Release/node inspect --probe app.js:4 --expr 'total' \ --cond 'i % 3 === 0' app.js ``` ``` Hit 1 at file:///path/to/app.js:3:3 total = 0 Hit 2 at file:///path/to/app.js:3:3 total = 3 Hit 3 at file:///path/to/app.js:3:3 total = 15 Hit 4 at file:///path/to/app.js:3:3 total = 36 Completed ``` Signed-off-by: Joyee Cheung <joyeec9h3@gmail.com> PR-URL: #64328 Refs: #63646 Reviewed-By: James M Snell <jasnell@gmail.com>
1 parent 5d90e48 commit 445bcce

9 files changed

Lines changed: 348 additions & 17 deletions

β€Ždoc/api/debugger.mdβ€Ž

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ debug>
236236
added:
237237
- v24.16.0
238238
changes:
239+
- version: REPLACEME
240+
pr-url: https://github.com/nodejs/node/pull/64328
241+
description: Add per-probe `--cond <expr>` option to only record a hit when the
242+
condition is truthy at the probe location.
239243
- version: v24.19.0
240244
pr-url: https://github.com/nodejs/node/pull/63704
241245
description: Add per-probe `--max-hit <n>` option to limit evaluated hits and finish
@@ -268,8 +272,8 @@ printf-style debugging without having to modify the application code and
268272
clean up afterwards. It also supports structured JSON output for tool use.
269273

270274
```console
271-
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
272-
[--probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>] ...]
275+
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>]
276+
[--probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>] ...]
273277
[--json] [--preview] [--timeout=<ms>] [--port=<port>]
274278
[--] [<node-option> ...] <script> [<script-args> ...]
275279
```
@@ -282,6 +286,9 @@ $ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
282286
*`--expr <expr>`: JavaScript expression to evaluate whenever execution reaches
283287
the location specified by the preceding `--probe`.
284288
Must immediately follow the `--probe` it belongs to.
289+
*`--cond <expr>`: An optional condition for the probe location. The probe only
290+
records a hit when `<expr>` is truthy at the location. A condition that throws
291+
is treated as false.
285292
*`--max-hit <n>`: An optional per-probe limit on the number of times the probe
286293
can be hit. When not specified, there's no hit limit. When any probe reaches
287294
its hit limit, the probing process will detach and report the results. The process
@@ -297,14 +304,17 @@ $ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
297304
will listen. Defaults to `0`, which requests a random port.
298305
*`--` is optional unless the child needs its own Node.js flags.
299306

300-
Additional rules about the `--probe` and `--expr` arguments:
307+
Additional rules about the composition of the options:
301308

302309
*`--probe <file>:<line>[:<col>]` and `--expr <expr>` are strict pairs. Each
303310
`--probe` must be followed immediately by exactly one `--expr`.
304-
*`--max-hit <n>` is an optional per-probe option that applies to the most recent
305-
`--probe`/`--expr` pair. It may not appear before the first `--probe` or
306-
between a `--probe` and its matching `--expr`, and may be given at most once
307-
per probe.
311+
*`--cond <expr>` and `--max-hit <n>` are optional modifiers written _after_ the
312+
`--probe`/`--expr` pair they apply to, each at most once per pair. They may not
313+
appear before the first `--probe` or between a `--probe` and its matching
314+
`--expr`.
315+
*`--max-hit` scopes to the `--probe`/`--expr` pair it follows, so pairs
316+
sharing a location may set different limits. `--cond` scopes to the whole
317+
location, probes sharing a location must share one condition (or none).
308318
*`--timeout`, `--json`, `--preview`, and `--port` are global probe options
309319
for the whole probe session. They may appear before or between probe pairs,
310320
but not between a `--probe` and its matching `--expr`.
@@ -379,6 +389,7 @@ $ node inspect --json --probe cli.js:5 --expr 'rss' cli.js
379389
"suffix": "cli.js",
380390
"line": 5
381391
}
392+
// `condition` is present only when the probe was given a --cond expression.
382393
// `maxHit` is present only when the probe was given a --max-hit limit.
383394
}
384395
],
@@ -480,6 +491,10 @@ When multiple `--probe`/`--expr` pairs share the same `--probe`, the
480491
expressions will be evaluated on the same pause in the order they appear
481492
on the command line.
482493

494+
For each location, there can only be at most one `--cond` (or none).
495+
Multiple `--probe`/`--expr` pairs with conflicting conditions
496+
at the same location will be rejected at launch time.
497+
483498
```js
484499
// app.js
485500
constx= { x:42 }; // line 2
@@ -563,6 +578,37 @@ that only matches the intended file:
563578
$ node inspect --probe src/utils.js:10 --expr 'x' main.js # matches only src/utils.js
564579
```
565580

581+
### Probe examples
582+
583+
#### Probing a variable conditionally
584+
585+
```js
586+
// app.js
587+
let total =0;
588+
for (let i =0; i <10; i++) {
589+
total += i; // line 4
590+
}
591+
```
592+
593+
```console
594+
$ out/Release/node inspect --probe app.js:4 --expr 'total' \
595+
--cond 'i % 3 === 0' app.js
596+
```
597+
598+
```text
599+
Hit 1 at file:///path/to/app.js:3:3
600+
total = 0
601+
Hit 2 at file:///path/to/app.js:3:3
602+
total = 3
603+
Hit 3 at file:///path/to/app.js:3:3
604+
total = 15
605+
Hit 4 at file:///path/to/app.js:3:3
606+
total = 36
607+
Completed
608+
```
609+
610+
<!-- TODO(joyeecheung): add more examples for different options -->
611+
566612
## Advanced usage
567613

568614
### V8 inspector integration for Node.js

β€Žlib/internal/debugger/inspect.jsβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ function parseInteractiveArgs(args) {
268268

269269
constkInspectArgOptions={
270270
'__proto__': null,
271+
'cond': {type: 'string'},
271272
'expr': {type: 'string'},
272273
'help': {type: 'boolean',short: 'h'},
273274
'json': {type: 'boolean'},

β€Žlib/internal/debugger/inspect_helpers.jsβ€Ž

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ function writeInspectUsageAndExit(invokedAs, message, exitCode) {
6969
}
7070
out.write(`Usage: ${invokedAs} [--port=<port>] [<node-option> ...]
7171
[<script> [<script-args>] | <host>:<port> | -p <pid>]
72-
${invokedAs} --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
73-
[--probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>] ...]
72+
${invokedAs} --probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>]
73+
[--probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>] ...]
7474
[--json] [--preview] [--timeout=<ms>] [--port=<port>]
7575
[--] [<node-option> ...] <script> [<script-args> ...]
7676
@@ -109,6 +109,9 @@ Options:
109109
preceding --probe each time execution reaches it.
110110
Avoid probing let/const-bound variables at their
111111
declaration site or a ReferenceError may be thrown.
112+
--cond <expr> Optional condition for the probe location. The probe only
113+
records a hit when <expr> is truthy at the location. A
114+
condition that throws is treated as false.
112115
--max-hit <n> Per-probe limit on evaluated hits. When not specified,
113116
there's no hit limit. When any probe reaches its hit LIMIT,
114117
the probing process will detach and report the results.
@@ -121,6 +124,9 @@ Options:
121124
Semantics:
122125
* Multiple --probe/--expr pairs are allowed. Same-location --probes share
123126
a pause and scope, their --exprs are evaluated in command-line order.
127+
* --max-hit scopes to one --probe/--expr pair, so same-location pairs may set
128+
different limits. --cond scopes to the location, probes sharing a location
129+
must all share one condition (or none).
124130
* --probe utils.js:<line>[:<col>] matches every loaded utils.js. Pass a
125131
fuller path e.g. src/utils.js to narrow the match.
126132
* Use -- before any Node.js flags intended for the child process.

β€Žlib/internal/debugger/inspect_probe.jsβ€Ž

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const {
2121
StringPrototypeIncludes,
2222
StringPrototypeSlice,
2323
StringPrototypeStartsWith,
24+
StringPrototypeTrim,
2425
Symbol,
2526
}=primordials;
2627

@@ -87,6 +88,8 @@ const kInspectPortRegex = /^--inspect-port=(\d+)$/;
8788
* @typedef {object} Probe
8889
* @property {string} expr Expression to evaluate on hit.
8990
* @property {ProbeTarget} target User's original --probe request shape.
91+
* @property {string} [condition] Condition from --cond. V8 only breaks when it is truthy.
92+
* Scoped to the location, so probes sharing a location all carry the same value.
9093
* @property {number} maxHit Per-probe hit limit from --max-hit. Infinity when unlimited.
9194
* @property {number} hits Count of hits observed.
9295
*/
@@ -130,6 +133,12 @@ function formatTargetText(target) {
130133
returncolumn===undefined ? `${suffix}:${line}` : `${suffix}:${line}:${column}`;
131134
}
132135

136+
// Identity of a probe location. Probes sharing a key share one V8 breakpoint,
137+
// so this must stay in sync between condition validation and breakpoint setup.
138+
functionlocationKey(target){
139+
return`${target.suffix}\n${target.line}\n${target.column??''}`;
140+
}
141+
133142
functionformatPendingProbeLocations(probes,pending){
134143
constseen=newSafeSet();
135144
for(constprobeIndexofpending){
@@ -391,6 +400,22 @@ function parseProbeTokens(tokens, args) {
391400
probe.maxHit=parseUnsignedInteger(token.value,'max-hit');
392401
break;
393402
}
403+
case'cond': {
404+
if(probes.length===0){
405+
thrownewERR_DEBUGGER_STARTUP_ERROR('Unexpected --cond before --probe');
406+
}
407+
// A blank condition does not act as a real predicate in V8 (an empty
408+
// string always breaks), so reject it rather than silently mislead.
409+
if(token.value===undefined||StringPrototypeTrim(token.value)===''){
410+
thrownewERR_DEBUGGER_STARTUP_ERROR(`Missing value for ${token.rawName}`);
411+
}
412+
constprobe=probes[probes.length-1];
413+
if(probe.condition!==undefined){
414+
thrownewERR_DEBUGGER_STARTUP_ERROR('A --probe can have at most one --cond');
415+
}
416+
probe.condition=token.value;
417+
break;
418+
}
394419
default:
395420
if(probes.length>0){
396421
thrownewERR_DEBUGGER_STARTUP_ERROR(
@@ -410,6 +435,24 @@ function parseProbeTokens(tokens, args) {
410435
'Probe mode requires at least one --probe <loc> --expr <expr> group');
411436
}
412437

438+
// V8 allows only one breakpoint per location, so probes sharing a location
439+
// cannot carry different conditions.
440+
constconditionByLocation=newSafeMap();
441+
for(const{ target, condition }ofprobes){
442+
constkey=locationKey(target);
443+
// All probes at the same location must share one condition. We split the
444+
// existence check since if one probe does not have a condition (= undefined),
445+
// then all probes at the location must also omit it.
446+
if(conditionByLocation.has(key)){
447+
if(conditionByLocation.get(key)!==condition){
448+
thrownewERR_DEBUGGER_STARTUP_ERROR(
449+
`Probes at ${formatTargetText(target)} must use the same --cond (or none)`);
450+
}
451+
}else{
452+
conditionByLocation.set(key,condition);
453+
}
454+
}
455+
413456
constchildArgv=ArrayPrototypeSlice(args,childStartIndex);
414457
if(childArgv.length===0){
415458
thrownewERR_DEBUGGER_STARTUP_ERROR('Probe mode requires a child script');
@@ -479,8 +522,8 @@ class ProbeInspectorSession {
479522
this.resolveCompletion=resolve;
480523
/** @type {Probe[]} */
481524
this.probes=ArrayPrototypeMap(options.probes,
482-
({ expr, target, maxHit })=>
483-
({ expr, target,maxHit: maxHit??Infinity,hits: 0}));
525+
({ expr, target, maxHit, condition})=>
526+
({ expr, target,condition,maxHit: maxHit??Infinity,hits: 0}));
484527
this.onChildOutput=FunctionPrototypeBind(this.onChildOutput,this);
485528
this.onChildExit=FunctionPrototypeBind(this.onChildExit,this);
486529
this.onClientClose=FunctionPrototypeBind(this.onClientClose,this);
@@ -887,17 +930,19 @@ class ProbeInspectorSession {
887930
constuniqueTargets=newSafeMap();
888931

889932
for(letprobeIndex=0;probeIndex<this.probes.length;probeIndex++){
890-
const{ target }=this.probes[probeIndex];
891-
constkey=`${target.suffix}\n${target.line}\n${target.column??''}`;
933+
const{ target, condition }=this.probes[probeIndex];
934+
// Probes at the same location share one V8 breakpoint. parseProbeTokens has
935+
// already ensured they carry the same condition.
936+
constkey=locationKey(target);
892937
letentry=uniqueTargets.get(key);
893938
if(entry===undefined){
894-
entry={ target,probeIndices: []};
939+
entry={ target,condition,probeIndices: []};
895940
uniqueTargets.set(key,entry);
896941
}
897942
ArrayPrototypePush(entry.probeIndices,probeIndex);
898943
}
899944

900-
for(const{ target, probeIndices }ofuniqueTargets.values()){
945+
for(const{ target,condition,probeIndices }ofuniqueTargets.values()){
901946
// On Windows, normalize backslashes to forward slashes so the regex matches
902947
// V8 script URLs which always use forward slashes.
903948
constnormalizedFile=process.platform==='win32' ?
@@ -918,6 +963,9 @@ class ProbeInspectorSession {
918963
// the inspector bind to the first executable column.
919964
params.columnNumber=target.column-1;
920965
}
966+
if(condition!==undefined){
967+
params.condition=condition;
968+
}
921969

922970
constresult=awaitthis.callCdp('Debugger.setBreakpointByUrl',params);
923971
debug('breakpoint set: id=%s urlRegex=%s locations=%j',
@@ -943,9 +991,10 @@ class ProbeInspectorSession {
943991
code: exitCode,
944992
report: {
945993
v: kProbeVersion,
946-
probes: ArrayPrototypeMap(this.probes,({ expr, target, maxHit })=>{
947-
// Omit an unlimited maxHit, as Infinity would serialize to null in JSON.
994+
probes: ArrayPrototypeMap(this.probes,({ expr, target, maxHit, condition })=>{
948995
constprobe={ expr, target };
996+
if(condition!==undefined){probe.condition=condition;}
997+
// Omit an unlimited maxHit, as Infinity would serialize to null in JSON.
949998
if(maxHit!==Infinity){probe.maxHit=maxHit;}
950999
returnprobe;
9511000
}),
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// This tests that probe mode rejects malformed --cond usage.
2+
'use strict';
3+
4+
constcommon=require('../common');
5+
common.skipIfInspectorDisabled();
6+
7+
constfixtures=require('../common/fixtures');
8+
const{ assertProbeCliError }=require('../common/debugger-probe');
9+
10+
constcwd=fixtures.path('debugger');
11+
12+
assertProbeCliError(
13+
['--cond','x','--probe','probe.js:12','--expr','finalValue','probe.js'],
14+
/Unexpected--condbefore--probe/,{ cwd });
15+
16+
assertProbeCliError(
17+
['--probe','probe.js:12','--cond','x','--expr','finalValue','probe.js'],
18+
/Each--probemustbefollowedimmediatelyby--expr/,{ cwd });
19+
20+
assertProbeCliError(
21+
['--probe','probe.js:12','--expr','finalValue','--cond','x','--cond','y','probe.js'],
22+
/A--probecanhaveatmostone--cond/,{ cwd });
23+
24+
assertProbeCliError(
25+
['--probe','probe.js:12','--expr','finalValue','--cond',' ','probe.js'],
26+
/Missingvaluefor--cond/,{ cwd });
27+
28+
assertProbeCliError(
29+
['--probe','probe.js:12','--expr','finalValue','--cond'],
30+
/Missingvaluefor--cond/,{ cwd });
31+
32+
assertProbeCliError(
33+
['--probe','probe.js:12','--expr','a','--cond','x',
34+
'--probe','probe.js:12','--expr','b','--cond','y','probe.js'],
35+
/Probesatprobe\.js:12mustusethesame--cond\(ornone\)/,{ cwd });
36+
37+
assertProbeCliError(
38+
['--probe','probe.js:12','--expr','a','--cond','x',
39+
'--probe','probe.js:12','--expr','b','probe.js'],
40+
/Probesatprobe\.js:12mustusethesame--cond\(ornone\)/,{ cwd });
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// This tests that --cond and --max-hit work together.
2+
'use strict';
3+
4+
constcommon=require('../common');
5+
common.skipIfInspectorDisabled();
6+
7+
constfixtures=require('../common/fixtures');
8+
const{ spawnSyncAndAssert }=require('../common/child_process');
9+
const{ assertProbeJson }=require('../common/debugger-probe');
10+
11+
constcwd=fixtures.path('debugger');
12+
constprobeUrl=fixtures.fileURL('debugger','probe-max-hit.js').href;
13+
14+
// --max-hit written before --cond. The condition still filters index !== 1, so
15+
// the only recorded hit carries value 1 rather than the loop's first iteration.
16+
spawnSyncAndAssert(process.execPath,[
17+
'inspect',
18+
'--json',
19+
'--probe','probe-max-hit.js:5',
20+
'--expr','index',
21+
'--max-hit','5',
22+
'--cond','index === 1',
23+
'probe-max-hit.js',
24+
],{ cwd },{
25+
stdout(output){
26+
assertProbeJson(output,{
27+
v: 2,
28+
probes: [{
29+
expr: 'index',
30+
condition: 'index === 1',
31+
maxHit: 5,
32+
target: {suffix: 'probe-max-hit.js',line: 5},
33+
}],
34+
results: [
35+
{
36+
probe: 0,
37+
event: 'hit',
38+
hit: 1,
39+
location: {url: probeUrl,line: 5,column: 3},
40+
result: {type: 'number',value: 1,description: '1'},
41+
},
42+
{event: 'completed'},
43+
],
44+
});
45+
},
46+
trim: true,
47+
});

0 commit comments

Comments
Β (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 445bcce

Browse files
joyeecheungaduh95
authored andcommitted
inspector: add --cond to node inspect probe mode
On a hot path, the probe can record every hit and require filtering afterwards. This patch adds a per-probe `--cond <expr>` option that allows limiting the hit to only when the expression is truthy at the probe location. V8 evaluates it as the breakpoint's native condition, so the target is not paused when it does not hold, and a condition that throws is treated as false. Since in CDP, a location can only carry one breakpoint per URL pattern, probes sharing a location must share one condition (or none). Conflicting conditions are rejected. Example: ```js // app.js let total = 0; for (let i = 0; i < 10; i++) { total += i; // line 4 } ``` ``` $ out/Release/node inspect --probe app.js:4 --expr 'total' \ --cond 'i % 3 === 0' app.js ``` ``` Hit 1 at file:///path/to/app.js:3:3 total = 0 Hit 2 at file:///path/to/app.js:3:3 total = 3 Hit 3 at file:///path/to/app.js:3:3 total = 15 Hit 4 at file:///path/to/app.js:3:3 total = 36 Completed ``` Signed-off-by: Joyee Cheung <joyeec9h3@gmail.com> PR-URL: #64328 Refs: #63646 Reviewed-By: James M Snell <jasnell@gmail.com>
1 parent 5d90e48 commit 445bcce

9 files changed

Lines changed: 348 additions & 17 deletions

β€Ždoc/api/debugger.mdβ€Ž

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ debug>
236236
added:
237237
- v24.16.0
238238
changes:
239+
- version: REPLACEME
240+
pr-url: https://github.com/nodejs/node/pull/64328
241+
description: Add per-probe `--cond <expr>` option to only record a hit when the
242+
condition is truthy at the probe location.
239243
- version: v24.19.0
240244
pr-url: https://github.com/nodejs/node/pull/63704
241245
description: Add per-probe `--max-hit <n>` option to limit evaluated hits and finish
@@ -268,8 +272,8 @@ printf-style debugging without having to modify the application code and
268272
clean up afterwards. It also supports structured JSON output for tool use.
269273

270274
```console
271-
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
272-
[--probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>] ...]
275+
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>]
276+
[--probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>] ...]
273277
[--json] [--preview] [--timeout=<ms>] [--port=<port>]
274278
[--] [<node-option> ...] <script> [<script-args> ...]
275279
```
@@ -282,6 +286,9 @@ $ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
282286
*`--expr <expr>`: JavaScript expression to evaluate whenever execution reaches
283287
the location specified by the preceding `--probe`.
284288
Must immediately follow the `--probe` it belongs to.
289+
*`--cond <expr>`: An optional condition for the probe location. The probe only
290+
records a hit when `<expr>` is truthy at the location. A condition that throws
291+
is treated as false.
285292
*`--max-hit <n>`: An optional per-probe limit on the number of times the probe
286293
can be hit. When not specified, there's no hit limit. When any probe reaches
287294
its hit limit, the probing process will detach and report the results. The process
@@ -297,14 +304,17 @@ $ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
297304
will listen. Defaults to `0`, which requests a random port.
298305
*`--` is optional unless the child needs its own Node.js flags.
299306

300-
Additional rules about the `--probe` and `--expr` arguments:
307+
Additional rules about the composition of the options:
301308

302309
*`--probe <file>:<line>[:<col>]` and `--expr <expr>` are strict pairs. Each
303310
`--probe` must be followed immediately by exactly one `--expr`.
304-
*`--max-hit <n>` is an optional per-probe option that applies to the most recent
305-
`--probe`/`--expr` pair. It may not appear before the first `--probe` or
306-
between a `--probe` and its matching `--expr`, and may be given at most once
307-
per probe.
311+
*`--cond <expr>` and `--max-hit <n>` are optional modifiers written _after_ the
312+
`--probe`/`--expr` pair they apply to, each at most once per pair. They may not
313+
appear before the first `--probe` or between a `--probe` and its matching
314+
`--expr`.
315+
*`--max-hit` scopes to the `--probe`/`--expr` pair it follows, so pairs
316+
sharing a location may set different limits. `--cond` scopes to the whole
317+
location, probes sharing a location must share one condition (or none).
308318
*`--timeout`, `--json`, `--preview`, and `--port` are global probe options
309319
for the whole probe session. They may appear before or between probe pairs,
310320
but not between a `--probe` and its matching `--expr`.
@@ -379,6 +389,7 @@ $ node inspect --json --probe cli.js:5 --expr 'rss' cli.js
379389
"suffix": "cli.js",
380390
"line": 5
381391
}
392+
// `condition` is present only when the probe was given a --cond expression.
382393
// `maxHit` is present only when the probe was given a --max-hit limit.
383394
}
384395
],
@@ -480,6 +491,10 @@ When multiple `--probe`/`--expr` pairs share the same `--probe`, the
480491
expressions will be evaluated on the same pause in the order they appear
481492
on the command line.
482493

494+
For each location, there can only be at most one `--cond` (or none).
495+
Multiple `--probe`/`--expr` pairs with conflicting conditions
496+
at the same location will be rejected at launch time.
497+
483498
```js
484499
// app.js
485500
constx= { x:42 }; // line 2
@@ -563,6 +578,37 @@ that only matches the intended file:
563578
$ node inspect --probe src/utils.js:10 --expr 'x' main.js # matches only src/utils.js
564579
```
565580

581+
### Probe examples
582+
583+
#### Probing a variable conditionally
584+
585+
```js
586+
// app.js
587+
let total =0;
588+
for (let i =0; i <10; i++) {
589+
total += i; // line 4
590+
}
591+
```
592+
593+
```console
594+
$ out/Release/node inspect --probe app.js:4 --expr 'total' \
595+
--cond 'i % 3 === 0' app.js
596+
```
597+
598+
```text
599+
Hit 1 at file:///path/to/app.js:3:3
600+
total = 0
601+
Hit 2 at file:///path/to/app.js:3:3
602+
total = 3
603+
Hit 3 at file:///path/to/app.js:3:3
604+
total = 15
605+
Hit 4 at file:///path/to/app.js:3:3
606+
total = 36
607+
Completed
608+
```
609+
610+
<!-- TODO(joyeecheung): add more examples for different options -->
611+
566612
## Advanced usage
567613

568614
### V8 inspector integration for Node.js

β€Žlib/internal/debugger/inspect.jsβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ function parseInteractiveArgs(args) {
268268

269269
constkInspectArgOptions={
270270
'__proto__': null,
271+
'cond': {type: 'string'},
271272
'expr': {type: 'string'},
272273
'help': {type: 'boolean',short: 'h'},
273274
'json': {type: 'boolean'},

β€Žlib/internal/debugger/inspect_helpers.jsβ€Ž

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ function writeInspectUsageAndExit(invokedAs, message, exitCode) {
6969
}
7070
out.write(`Usage: ${invokedAs} [--port=<port>] [<node-option> ...]
7171
[<script> [<script-args>] | <host>:<port> | -p <pid>]
72-
${invokedAs} --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
73-
[--probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>] ...]
72+
${invokedAs} --probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>]
73+
[--probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>] ...]
7474
[--json] [--preview] [--timeout=<ms>] [--port=<port>]
7575
[--] [<node-option> ...] <script> [<script-args> ...]
7676
@@ -109,6 +109,9 @@ Options:
109109
preceding --probe each time execution reaches it.
110110
Avoid probing let/const-bound variables at their
111111
declaration site or a ReferenceError may be thrown.
112+
--cond <expr> Optional condition for the probe location. The probe only
113+
records a hit when <expr> is truthy at the location. A
114+
condition that throws is treated as false.
112115
--max-hit <n> Per-probe limit on evaluated hits. When not specified,
113116
there's no hit limit. When any probe reaches its hit LIMIT,
114117
the probing process will detach and report the results.
@@ -121,6 +124,9 @@ Options:
121124
Semantics:
122125
* Multiple --probe/--expr pairs are allowed. Same-location --probes share
123126
a pause and scope, their --exprs are evaluated in command-line order.
127+
* --max-hit scopes to one --probe/--expr pair, so same-location pairs may set
128+
different limits. --cond scopes to the location, probes sharing a location
129+
must all share one condition (or none).
124130
* --probe utils.js:<line>[:<col>] matches every loaded utils.js. Pass a
125131
fuller path e.g. src/utils.js to narrow the match.
126132
* Use -- before any Node.js flags intended for the child process.

β€Žlib/internal/debugger/inspect_probe.jsβ€Ž

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const {
2121
StringPrototypeIncludes,
2222
StringPrototypeSlice,
2323
StringPrototypeStartsWith,
24+
StringPrototypeTrim,
2425
Symbol,
2526
}=primordials;
2627

@@ -87,6 +88,8 @@ const kInspectPortRegex = /^--inspect-port=(\d+)$/;
8788
* @typedef {object} Probe
8889
* @property {string} expr Expression to evaluate on hit.
8990
* @property {ProbeTarget} target User's original --probe request shape.
91+
* @property {string} [condition] Condition from --cond. V8 only breaks when it is truthy.
92+
* Scoped to the location, so probes sharing a location all carry the same value.
9093
* @property {number} maxHit Per-probe hit limit from --max-hit. Infinity when unlimited.
9194
* @property {number} hits Count of hits observed.
9295
*/
@@ -130,6 +133,12 @@ function formatTargetText(target) {
130133
returncolumn===undefined ? `${suffix}:${line}` : `${suffix}:${line}:${column}`;
131134
}
132135

136+
// Identity of a probe location. Probes sharing a key share one V8 breakpoint,
137+
// so this must stay in sync between condition validation and breakpoint setup.
138+
functionlocationKey(target){
139+
return`${target.suffix}\n${target.line}\n${target.column??''}`;
140+
}
141+
133142
functionformatPendingProbeLocations(probes,pending){
134143
constseen=newSafeSet();
135144
for(constprobeIndexofpending){
@@ -391,6 +400,22 @@ function parseProbeTokens(tokens, args) {
391400
probe.maxHit=parseUnsignedInteger(token.value,'max-hit');
392401
break;
393402
}
403+
case'cond': {
404+
if(probes.length===0){
405+
thrownewERR_DEBUGGER_STARTUP_ERROR('Unexpected --cond before --probe');
406+
}
407+
// A blank condition does not act as a real predicate in V8 (an empty
408+
// string always breaks), so reject it rather than silently mislead.
409+
if(token.value===undefined||StringPrototypeTrim(token.value)===''){
410+
thrownewERR_DEBUGGER_STARTUP_ERROR(`Missing value for ${token.rawName}`);
411+
}
412+
constprobe=probes[probes.length-1];
413+
if(probe.condition!==undefined){
414+
thrownewERR_DEBUGGER_STARTUP_ERROR('A --probe can have at most one --cond');
415+
}
416+
probe.condition=token.value;
417+
break;
418+
}
394419
default:
395420
if(probes.length>0){
396421
thrownewERR_DEBUGGER_STARTUP_ERROR(
@@ -410,6 +435,24 @@ function parseProbeTokens(tokens, args) {
410435
'Probe mode requires at least one --probe <loc> --expr <expr> group');
411436
}
412437

438+
// V8 allows only one breakpoint per location, so probes sharing a location
439+
// cannot carry different conditions.
440+
constconditionByLocation=newSafeMap();
441+
for(const{ target, condition }ofprobes){
442+
constkey=locationKey(target);
443+
// All probes at the same location must share one condition. We split the
444+
// existence check since if one probe does not have a condition (= undefined),
445+
// then all probes at the location must also omit it.
446+
if(conditionByLocation.has(key)){
447+
if(conditionByLocation.get(key)!==condition){
448+
thrownewERR_DEBUGGER_STARTUP_ERROR(
449+
`Probes at ${formatTargetText(target)} must use the same --cond (or none)`);
450+
}
451+
}else{
452+
conditionByLocation.set(key,condition);
453+
}
454+
}
455+
413456
constchildArgv=ArrayPrototypeSlice(args,childStartIndex);
414457
if(childArgv.length===0){
415458
thrownewERR_DEBUGGER_STARTUP_ERROR('Probe mode requires a child script');
@@ -479,8 +522,8 @@ class ProbeInspectorSession {
479522
this.resolveCompletion=resolve;
480523
/** @type {Probe[]} */
481524
this.probes=ArrayPrototypeMap(options.probes,
482-
({ expr, target, maxHit })=>
483-
({ expr, target,maxHit: maxHit??Infinity,hits: 0}));
525+
({ expr, target, maxHit, condition})=>
526+
({ expr, target,condition,maxHit: maxHit??Infinity,hits: 0}));
484527
this.onChildOutput=FunctionPrototypeBind(this.onChildOutput,this);
485528
this.onChildExit=FunctionPrototypeBind(this.onChildExit,this);
486529
this.onClientClose=FunctionPrototypeBind(this.onClientClose,this);
@@ -887,17 +930,19 @@ class ProbeInspectorSession {
887930
constuniqueTargets=newSafeMap();
888931

889932
for(letprobeIndex=0;probeIndex<this.probes.length;probeIndex++){
890-
const{ target }=this.probes[probeIndex];
891-
constkey=`${target.suffix}\n${target.line}\n${target.column??''}`;
933+
const{ target, condition }=this.probes[probeIndex];
934+
// Probes at the same location share one V8 breakpoint. parseProbeTokens has
935+
// already ensured they carry the same condition.
936+
constkey=locationKey(target);
892937
letentry=uniqueTargets.get(key);
893938
if(entry===undefined){
894-
entry={ target,probeIndices: []};
939+
entry={ target,condition,probeIndices: []};
895940
uniqueTargets.set(key,entry);
896941
}
897942
ArrayPrototypePush(entry.probeIndices,probeIndex);
898943
}
899944

900-
for(const{ target, probeIndices }ofuniqueTargets.values()){
945+
for(const{ target,condition,probeIndices }ofuniqueTargets.values()){
901946
// On Windows, normalize backslashes to forward slashes so the regex matches
902947
// V8 script URLs which always use forward slashes.
903948
constnormalizedFile=process.platform==='win32' ?
@@ -918,6 +963,9 @@ class ProbeInspectorSession {
918963
// the inspector bind to the first executable column.
919964
params.columnNumber=target.column-1;
920965
}
966+
if(condition!==undefined){
967+
params.condition=condition;
968+
}
921969

922970
constresult=awaitthis.callCdp('Debugger.setBreakpointByUrl',params);
923971
debug('breakpoint set: id=%s urlRegex=%s locations=%j',
@@ -943,9 +991,10 @@ class ProbeInspectorSession {
943991
code: exitCode,
944992
report: {
945993
v: kProbeVersion,
946-
probes: ArrayPrototypeMap(this.probes,({ expr, target, maxHit })=>{
947-
// Omit an unlimited maxHit, as Infinity would serialize to null in JSON.
994+
probes: ArrayPrototypeMap(this.probes,({ expr, target, maxHit, condition })=>{
948995
constprobe={ expr, target };
996+
if(condition!==undefined){probe.condition=condition;}
997+
// Omit an unlimited maxHit, as Infinity would serialize to null in JSON.
949998
if(maxHit!==Infinity){probe.maxHit=maxHit;}
950999
returnprobe;
9511000
}),
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// This tests that probe mode rejects malformed --cond usage.
2+
'use strict';
3+
4+
constcommon=require('../common');
5+
common.skipIfInspectorDisabled();
6+
7+
constfixtures=require('../common/fixtures');
8+
const{ assertProbeCliError }=require('../common/debugger-probe');
9+
10+
constcwd=fixtures.path('debugger');
11+
12+
assertProbeCliError(
13+
['--cond','x','--probe','probe.js:12','--expr','finalValue','probe.js'],
14+
/Unexpected--condbefore--probe/,{ cwd });
15+
16+
assertProbeCliError(
17+
['--probe','probe.js:12','--cond','x','--expr','finalValue','probe.js'],
18+
/Each--probemustbefollowedimmediatelyby--expr/,{ cwd });
19+
20+
assertProbeCliError(
21+
['--probe','probe.js:12','--expr','finalValue','--cond','x','--cond','y','probe.js'],
22+
/A--probecanhaveatmostone--cond/,{ cwd });
23+
24+
assertProbeCliError(
25+
['--probe','probe.js:12','--expr','finalValue','--cond',' ','probe.js'],
26+
/Missingvaluefor--cond/,{ cwd });
27+
28+
assertProbeCliError(
29+
['--probe','probe.js:12','--expr','finalValue','--cond'],
30+
/Missingvaluefor--cond/,{ cwd });
31+
32+
assertProbeCliError(
33+
['--probe','probe.js:12','--expr','a','--cond','x',
34+
'--probe','probe.js:12','--expr','b','--cond','y','probe.js'],
35+
/Probesatprobe\.js:12mustusethesame--cond\(ornone\)/,{ cwd });
36+
37+
assertProbeCliError(
38+
['--probe','probe.js:12','--expr','a','--cond','x',
39+
'--probe','probe.js:12','--expr','b','probe.js'],
40+
/Probesatprobe\.js:12mustusethesame--cond\(ornone\)/,{ cwd });
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// This tests that --cond and --max-hit work together.
2+
'use strict';
3+
4+
constcommon=require('../common');
5+
common.skipIfInspectorDisabled();
6+
7+
constfixtures=require('../common/fixtures');
8+
const{ spawnSyncAndAssert }=require('../common/child_process');
9+
const{ assertProbeJson }=require('../common/debugger-probe');
10+
11+
constcwd=fixtures.path('debugger');
12+
constprobeUrl=fixtures.fileURL('debugger','probe-max-hit.js').href;
13+
14+
// --max-hit written before --cond. The condition still filters index !== 1, so
15+
// the only recorded hit carries value 1 rather than the loop's first iteration.
16+
spawnSyncAndAssert(process.execPath,[
17+
'inspect',
18+
'--json',
19+
'--probe','probe-max-hit.js:5',
20+
'--expr','index',
21+
'--max-hit','5',
22+
'--cond','index === 1',
23+
'probe-max-hit.js',
24+
],{ cwd },{
25+
stdout(output){
26+
assertProbeJson(output,{
27+
v: 2,
28+
probes: [{
29+
expr: 'index',
30+
condition: 'index === 1',
31+
maxHit: 5,
32+
target: {suffix: 'probe-max-hit.js',line: 5},
33+
}],
34+
results: [
35+
{
36+
probe: 0,
37+
event: 'hit',
38+
hit: 1,
39+
location: {url: probeUrl,line: 5,column: 3},
40+
result: {type: 'number',value: 1,description: '1'},
41+
},
42+
{event: 'completed'},
43+
],
44+
});
45+
},
46+
trim: true,
47+
});

0 commit comments

Comments
Β (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 445bcce

Browse files
joyeecheungaduh95
authored andcommitted
inspector: add --cond to node inspect probe mode
On a hot path, the probe can record every hit and require filtering afterwards. This patch adds a per-probe `--cond <expr>` option that allows limiting the hit to only when the expression is truthy at the probe location. V8 evaluates it as the breakpoint's native condition, so the target is not paused when it does not hold, and a condition that throws is treated as false. Since in CDP, a location can only carry one breakpoint per URL pattern, probes sharing a location must share one condition (or none). Conflicting conditions are rejected. Example: ```js // app.js let total = 0; for (let i = 0; i < 10; i++) { total += i; // line 4 } ``` ``` $ out/Release/node inspect --probe app.js:4 --expr 'total' \ --cond 'i % 3 === 0' app.js ``` ``` Hit 1 at file:///path/to/app.js:3:3 total = 0 Hit 2 at file:///path/to/app.js:3:3 total = 3 Hit 3 at file:///path/to/app.js:3:3 total = 15 Hit 4 at file:///path/to/app.js:3:3 total = 36 Completed ``` Signed-off-by: Joyee Cheung <joyeec9h3@gmail.com> PR-URL: #64328 Refs: #63646 Reviewed-By: James M Snell <jasnell@gmail.com>
1 parent 5d90e48 commit 445bcce

9 files changed

Lines changed: 348 additions & 17 deletions

β€Ždoc/api/debugger.mdβ€Ž

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ debug>
236236
added:
237237
- v24.16.0
238238
changes:
239+
- version: REPLACEME
240+
pr-url: https://github.com/nodejs/node/pull/64328
241+
description: Add per-probe `--cond <expr>` option to only record a hit when the
242+
condition is truthy at the probe location.
239243
- version: v24.19.0
240244
pr-url: https://github.com/nodejs/node/pull/63704
241245
description: Add per-probe `--max-hit <n>` option to limit evaluated hits and finish
@@ -268,8 +272,8 @@ printf-style debugging without having to modify the application code and
268272
clean up afterwards. It also supports structured JSON output for tool use.
269273

270274
```console
271-
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
272-
[--probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>] ...]
275+
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>]
276+
[--probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>] ...]
273277
[--json] [--preview] [--timeout=<ms>] [--port=<port>]
274278
[--] [<node-option> ...] <script> [<script-args> ...]
275279
```
@@ -282,6 +286,9 @@ $ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
282286
*`--expr <expr>`: JavaScript expression to evaluate whenever execution reaches
283287
the location specified by the preceding `--probe`.
284288
Must immediately follow the `--probe` it belongs to.
289+
*`--cond <expr>`: An optional condition for the probe location. The probe only
290+
records a hit when `<expr>` is truthy at the location. A condition that throws
291+
is treated as false.
285292
*`--max-hit <n>`: An optional per-probe limit on the number of times the probe
286293
can be hit. When not specified, there's no hit limit. When any probe reaches
287294
its hit limit, the probing process will detach and report the results. The process
@@ -297,14 +304,17 @@ $ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
297304
will listen. Defaults to `0`, which requests a random port.
298305
*`--` is optional unless the child needs its own Node.js flags.
299306

300-
Additional rules about the `--probe` and `--expr` arguments:
307+
Additional rules about the composition of the options:
301308

302309
*`--probe <file>:<line>[:<col>]` and `--expr <expr>` are strict pairs. Each
303310
`--probe` must be followed immediately by exactly one `--expr`.
304-
*`--max-hit <n>` is an optional per-probe option that applies to the most recent
305-
`--probe`/`--expr` pair. It may not appear before the first `--probe` or
306-
between a `--probe` and its matching `--expr`, and may be given at most once
307-
per probe.
311+
*`--cond <expr>` and `--max-hit <n>` are optional modifiers written _after_ the
312+
`--probe`/`--expr` pair they apply to, each at most once per pair. They may not
313+
appear before the first `--probe` or between a `--probe` and its matching
314+
`--expr`.
315+
*`--max-hit` scopes to the `--probe`/`--expr` pair it follows, so pairs
316+
sharing a location may set different limits. `--cond` scopes to the whole
317+
location, probes sharing a location must share one condition (or none).
308318
*`--timeout`, `--json`, `--preview`, and `--port` are global probe options
309319
for the whole probe session. They may appear before or between probe pairs,
310320
but not between a `--probe` and its matching `--expr`.
@@ -379,6 +389,7 @@ $ node inspect --json --probe cli.js:5 --expr 'rss' cli.js
379389
"suffix": "cli.js",
380390
"line": 5
381391
}
392+
// `condition` is present only when the probe was given a --cond expression.
382393
// `maxHit` is present only when the probe was given a --max-hit limit.
383394
}
384395
],
@@ -480,6 +491,10 @@ When multiple `--probe`/`--expr` pairs share the same `--probe`, the
480491
expressions will be evaluated on the same pause in the order they appear
481492
on the command line.
482493

494+
For each location, there can only be at most one `--cond` (or none).
495+
Multiple `--probe`/`--expr` pairs with conflicting conditions
496+
at the same location will be rejected at launch time.
497+
483498
```js
484499
// app.js
485500
constx= { x:42 }; // line 2
@@ -563,6 +578,37 @@ that only matches the intended file:
563578
$ node inspect --probe src/utils.js:10 --expr 'x' main.js # matches only src/utils.js
564579
```
565580

581+
### Probe examples
582+
583+
#### Probing a variable conditionally
584+
585+
```js
586+
// app.js
587+
let total =0;
588+
for (let i =0; i <10; i++) {
589+
total += i; // line 4
590+
}
591+
```
592+
593+
```console
594+
$ out/Release/node inspect --probe app.js:4 --expr 'total' \
595+
--cond 'i % 3 === 0' app.js
596+
```
597+
598+
```text
599+
Hit 1 at file:///path/to/app.js:3:3
600+
total = 0
601+
Hit 2 at file:///path/to/app.js:3:3
602+
total = 3
603+
Hit 3 at file:///path/to/app.js:3:3
604+
total = 15
605+
Hit 4 at file:///path/to/app.js:3:3
606+
total = 36
607+
Completed
608+
```
609+
610+
<!-- TODO(joyeecheung): add more examples for different options -->
611+
566612
## Advanced usage
567613

568614
### V8 inspector integration for Node.js

β€Žlib/internal/debugger/inspect.jsβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ function parseInteractiveArgs(args) {
268268

269269
constkInspectArgOptions={
270270
'__proto__': null,
271+
'cond': {type: 'string'},
271272
'expr': {type: 'string'},
272273
'help': {type: 'boolean',short: 'h'},
273274
'json': {type: 'boolean'},

β€Žlib/internal/debugger/inspect_helpers.jsβ€Ž

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ function writeInspectUsageAndExit(invokedAs, message, exitCode) {
6969
}
7070
out.write(`Usage: ${invokedAs} [--port=<port>] [<node-option> ...]
7171
[<script> [<script-args>] | <host>:<port> | -p <pid>]
72-
${invokedAs} --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
73-
[--probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>] ...]
72+
${invokedAs} --probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>]
73+
[--probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>] ...]
7474
[--json] [--preview] [--timeout=<ms>] [--port=<port>]
7575
[--] [<node-option> ...] <script> [<script-args> ...]
7676
@@ -109,6 +109,9 @@ Options:
109109
preceding --probe each time execution reaches it.
110110
Avoid probing let/const-bound variables at their
111111
declaration site or a ReferenceError may be thrown.
112+
--cond <expr> Optional condition for the probe location. The probe only
113+
records a hit when <expr> is truthy at the location. A
114+
condition that throws is treated as false.
112115
--max-hit <n> Per-probe limit on evaluated hits. When not specified,
113116
there's no hit limit. When any probe reaches its hit LIMIT,
114117
the probing process will detach and report the results.
@@ -121,6 +124,9 @@ Options:
121124
Semantics:
122125
* Multiple --probe/--expr pairs are allowed. Same-location --probes share
123126
a pause and scope, their --exprs are evaluated in command-line order.
127+
* --max-hit scopes to one --probe/--expr pair, so same-location pairs may set
128+
different limits. --cond scopes to the location, probes sharing a location
129+
must all share one condition (or none).
124130
* --probe utils.js:<line>[:<col>] matches every loaded utils.js. Pass a
125131
fuller path e.g. src/utils.js to narrow the match.
126132
* Use -- before any Node.js flags intended for the child process.

β€Žlib/internal/debugger/inspect_probe.jsβ€Ž

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const {
2121
StringPrototypeIncludes,
2222
StringPrototypeSlice,
2323
StringPrototypeStartsWith,
24+
StringPrototypeTrim,
2425
Symbol,
2526
}=primordials;
2627

@@ -87,6 +88,8 @@ const kInspectPortRegex = /^--inspect-port=(\d+)$/;
8788
* @typedef {object} Probe
8889
* @property {string} expr Expression to evaluate on hit.
8990
* @property {ProbeTarget} target User's original --probe request shape.
91+
* @property {string} [condition] Condition from --cond. V8 only breaks when it is truthy.
92+
* Scoped to the location, so probes sharing a location all carry the same value.
9093
* @property {number} maxHit Per-probe hit limit from --max-hit. Infinity when unlimited.
9194
* @property {number} hits Count of hits observed.
9295
*/
@@ -130,6 +133,12 @@ function formatTargetText(target) {
130133
returncolumn===undefined ? `${suffix}:${line}` : `${suffix}:${line}:${column}`;
131134
}
132135

136+
// Identity of a probe location. Probes sharing a key share one V8 breakpoint,
137+
// so this must stay in sync between condition validation and breakpoint setup.
138+
functionlocationKey(target){
139+
return`${target.suffix}\n${target.line}\n${target.column??''}`;
140+
}
141+
133142
functionformatPendingProbeLocations(probes,pending){
134143
constseen=newSafeSet();
135144
for(constprobeIndexofpending){
@@ -391,6 +400,22 @@ function parseProbeTokens(tokens, args) {
391400
probe.maxHit=parseUnsignedInteger(token.value,'max-hit');
392401
break;
393402
}
403+
case'cond': {
404+
if(probes.length===0){
405+
thrownewERR_DEBUGGER_STARTUP_ERROR('Unexpected --cond before --probe');
406+
}
407+
// A blank condition does not act as a real predicate in V8 (an empty
408+
// string always breaks), so reject it rather than silently mislead.
409+
if(token.value===undefined||StringPrototypeTrim(token.value)===''){
410+
thrownewERR_DEBUGGER_STARTUP_ERROR(`Missing value for ${token.rawName}`);
411+
}
412+
constprobe=probes[probes.length-1];
413+
if(probe.condition!==undefined){
414+
thrownewERR_DEBUGGER_STARTUP_ERROR('A --probe can have at most one --cond');
415+
}
416+
probe.condition=token.value;
417+
break;
418+
}
394419
default:
395420
if(probes.length>0){
396421
thrownewERR_DEBUGGER_STARTUP_ERROR(
@@ -410,6 +435,24 @@ function parseProbeTokens(tokens, args) {
410435
'Probe mode requires at least one --probe <loc> --expr <expr> group');
411436
}
412437

438+
// V8 allows only one breakpoint per location, so probes sharing a location
439+
// cannot carry different conditions.
440+
constconditionByLocation=newSafeMap();
441+
for(const{ target, condition }ofprobes){
442+
constkey=locationKey(target);
443+
// All probes at the same location must share one condition. We split the
444+
// existence check since if one probe does not have a condition (= undefined),
445+
// then all probes at the location must also omit it.
446+
if(conditionByLocation.has(key)){
447+
if(conditionByLocation.get(key)!==condition){
448+
thrownewERR_DEBUGGER_STARTUP_ERROR(
449+
`Probes at ${formatTargetText(target)} must use the same --cond (or none)`);
450+
}
451+
}else{
452+
conditionByLocation.set(key,condition);
453+
}
454+
}
455+
413456
constchildArgv=ArrayPrototypeSlice(args,childStartIndex);
414457
if(childArgv.length===0){
415458
thrownewERR_DEBUGGER_STARTUP_ERROR('Probe mode requires a child script');
@@ -479,8 +522,8 @@ class ProbeInspectorSession {
479522
this.resolveCompletion=resolve;
480523
/** @type {Probe[]} */
481524
this.probes=ArrayPrototypeMap(options.probes,
482-
({ expr, target, maxHit })=>
483-
({ expr, target,maxHit: maxHit??Infinity,hits: 0}));
525+
({ expr, target, maxHit, condition})=>
526+
({ expr, target,condition,maxHit: maxHit??Infinity,hits: 0}));
484527
this.onChildOutput=FunctionPrototypeBind(this.onChildOutput,this);
485528
this.onChildExit=FunctionPrototypeBind(this.onChildExit,this);
486529
this.onClientClose=FunctionPrototypeBind(this.onClientClose,this);
@@ -887,17 +930,19 @@ class ProbeInspectorSession {
887930
constuniqueTargets=newSafeMap();
888931

889932
for(letprobeIndex=0;probeIndex<this.probes.length;probeIndex++){
890-
const{ target }=this.probes[probeIndex];
891-
constkey=`${target.suffix}\n${target.line}\n${target.column??''}`;
933+
const{ target, condition }=this.probes[probeIndex];
934+
// Probes at the same location share one V8 breakpoint. parseProbeTokens has
935+
// already ensured they carry the same condition.
936+
constkey=locationKey(target);
892937
letentry=uniqueTargets.get(key);
893938
if(entry===undefined){
894-
entry={ target,probeIndices: []};
939+
entry={ target,condition,probeIndices: []};
895940
uniqueTargets.set(key,entry);
896941
}
897942
ArrayPrototypePush(entry.probeIndices,probeIndex);
898943
}
899944

900-
for(const{ target, probeIndices }ofuniqueTargets.values()){
945+
for(const{ target,condition,probeIndices }ofuniqueTargets.values()){
901946
// On Windows, normalize backslashes to forward slashes so the regex matches
902947
// V8 script URLs which always use forward slashes.
903948
constnormalizedFile=process.platform==='win32' ?
@@ -918,6 +963,9 @@ class ProbeInspectorSession {
918963
// the inspector bind to the first executable column.
919964
params.columnNumber=target.column-1;
920965
}
966+
if(condition!==undefined){
967+
params.condition=condition;
968+
}
921969

922970
constresult=awaitthis.callCdp('Debugger.setBreakpointByUrl',params);
923971
debug('breakpoint set: id=%s urlRegex=%s locations=%j',
@@ -943,9 +991,10 @@ class ProbeInspectorSession {
943991
code: exitCode,
944992
report: {
945993
v: kProbeVersion,
946-
probes: ArrayPrototypeMap(this.probes,({ expr, target, maxHit })=>{
947-
// Omit an unlimited maxHit, as Infinity would serialize to null in JSON.
994+
probes: ArrayPrototypeMap(this.probes,({ expr, target, maxHit, condition })=>{
948995
constprobe={ expr, target };
996+
if(condition!==undefined){probe.condition=condition;}
997+
// Omit an unlimited maxHit, as Infinity would serialize to null in JSON.
949998
if(maxHit!==Infinity){probe.maxHit=maxHit;}
950999
returnprobe;
9511000
}),
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// This tests that probe mode rejects malformed --cond usage.
2+
'use strict';
3+
4+
constcommon=require('../common');
5+
common.skipIfInspectorDisabled();
6+
7+
constfixtures=require('../common/fixtures');
8+
const{ assertProbeCliError }=require('../common/debugger-probe');
9+
10+
constcwd=fixtures.path('debugger');
11+
12+
assertProbeCliError(
13+
['--cond','x','--probe','probe.js:12','--expr','finalValue','probe.js'],
14+
/Unexpected--condbefore--probe/,{ cwd });
15+
16+
assertProbeCliError(
17+
['--probe','probe.js:12','--cond','x','--expr','finalValue','probe.js'],
18+
/Each--probemustbefollowedimmediatelyby--expr/,{ cwd });
19+
20+
assertProbeCliError(
21+
['--probe','probe.js:12','--expr','finalValue','--cond','x','--cond','y','probe.js'],
22+
/A--probecanhaveatmostone--cond/,{ cwd });
23+
24+
assertProbeCliError(
25+
['--probe','probe.js:12','--expr','finalValue','--cond',' ','probe.js'],
26+
/Missingvaluefor--cond/,{ cwd });
27+
28+
assertProbeCliError(
29+
['--probe','probe.js:12','--expr','finalValue','--cond'],
30+
/Missingvaluefor--cond/,{ cwd });
31+
32+
assertProbeCliError(
33+
['--probe','probe.js:12','--expr','a','--cond','x',
34+
'--probe','probe.js:12','--expr','b','--cond','y','probe.js'],
35+
/Probesatprobe\.js:12mustusethesame--cond\(ornone\)/,{ cwd });
36+
37+
assertProbeCliError(
38+
['--probe','probe.js:12','--expr','a','--cond','x',
39+
'--probe','probe.js:12','--expr','b','probe.js'],
40+
/Probesatprobe\.js:12mustusethesame--cond\(ornone\)/,{ cwd });
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// This tests that --cond and --max-hit work together.
2+
'use strict';
3+
4+
constcommon=require('../common');
5+
common.skipIfInspectorDisabled();
6+
7+
constfixtures=require('../common/fixtures');
8+
const{ spawnSyncAndAssert }=require('../common/child_process');
9+
const{ assertProbeJson }=require('../common/debugger-probe');
10+
11+
constcwd=fixtures.path('debugger');
12+
constprobeUrl=fixtures.fileURL('debugger','probe-max-hit.js').href;
13+
14+
// --max-hit written before --cond. The condition still filters index !== 1, so
15+
// the only recorded hit carries value 1 rather than the loop's first iteration.
16+
spawnSyncAndAssert(process.execPath,[
17+
'inspect',
18+
'--json',
19+
'--probe','probe-max-hit.js:5',
20+
'--expr','index',
21+
'--max-hit','5',
22+
'--cond','index === 1',
23+
'probe-max-hit.js',
24+
],{ cwd },{
25+
stdout(output){
26+
assertProbeJson(output,{
27+
v: 2,
28+
probes: [{
29+
expr: 'index',
30+
condition: 'index === 1',
31+
maxHit: 5,
32+
target: {suffix: 'probe-max-hit.js',line: 5},
33+
}],
34+
results: [
35+
{
36+
probe: 0,
37+
event: 'hit',
38+
hit: 1,
39+
location: {url: probeUrl,line: 5,column: 3},
40+
result: {type: 'number',value: 1,description: '1'},
41+
},
42+
{event: 'completed'},
43+
],
44+
});
45+
},
46+
trim: true,
47+
});

0 commit comments

Comments
Β (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Commit 445bcce

Browse files
joyeecheungaduh95
authored andcommitted
inspector: add --cond to node inspect probe mode
On a hot path, the probe can record every hit and require filtering afterwards. This patch adds a per-probe `--cond <expr>` option that allows limiting the hit to only when the expression is truthy at the probe location. V8 evaluates it as the breakpoint's native condition, so the target is not paused when it does not hold, and a condition that throws is treated as false. Since in CDP, a location can only carry one breakpoint per URL pattern, probes sharing a location must share one condition (or none). Conflicting conditions are rejected. Example: ```js // app.js let total = 0; for (let i = 0; i < 10; i++) { total += i; // line 4 } ``` ``` $ out/Release/node inspect --probe app.js:4 --expr 'total' \ --cond 'i % 3 === 0' app.js ``` ``` Hit 1 at file:///path/to/app.js:3:3 total = 0 Hit 2 at file:///path/to/app.js:3:3 total = 3 Hit 3 at file:///path/to/app.js:3:3 total = 15 Hit 4 at file:///path/to/app.js:3:3 total = 36 Completed ``` Signed-off-by: Joyee Cheung <joyeec9h3@gmail.com> PR-URL: #64328 Refs: #63646 Reviewed-By: James M Snell <jasnell@gmail.com>
1 parent 5d90e48 commit 445bcce

9 files changed

Lines changed: 348 additions & 17 deletions

β€Ždoc/api/debugger.mdβ€Ž

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ debug>
236236
added:
237237
- v24.16.0
238238
changes:
239+
- version: REPLACEME
240+
pr-url: https://github.com/nodejs/node/pull/64328
241+
description: Add per-probe `--cond <expr>` option to only record a hit when the
242+
condition is truthy at the probe location.
239243
- version: v24.19.0
240244
pr-url: https://github.com/nodejs/node/pull/63704
241245
description: Add per-probe `--max-hit <n>` option to limit evaluated hits and finish
@@ -268,8 +272,8 @@ printf-style debugging without having to modify the application code and
268272
clean up afterwards. It also supports structured JSON output for tool use.
269273

270274
```console
271-
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
272-
[--probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>] ...]
275+
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>]
276+
[--probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>] ...]
273277
[--json] [--preview] [--timeout=<ms>] [--port=<port>]
274278
[--] [<node-option> ...] <script> [<script-args> ...]
275279
```
@@ -282,6 +286,9 @@ $ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
282286
*`--expr <expr>`: JavaScript expression to evaluate whenever execution reaches
283287
the location specified by the preceding `--probe`.
284288
Must immediately follow the `--probe` it belongs to.
289+
*`--cond <expr>`: An optional condition for the probe location. The probe only
290+
records a hit when `<expr>` is truthy at the location. A condition that throws
291+
is treated as false.
285292
*`--max-hit <n>`: An optional per-probe limit on the number of times the probe
286293
can be hit. When not specified, there's no hit limit. When any probe reaches
287294
its hit limit, the probing process will detach and report the results. The process
@@ -297,14 +304,17 @@ $ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
297304
will listen. Defaults to `0`, which requests a random port.
298305
*`--` is optional unless the child needs its own Node.js flags.
299306

300-
Additional rules about the `--probe` and `--expr` arguments:
307+
Additional rules about the composition of the options:
301308

302309
*`--probe <file>:<line>[:<col>]` and `--expr <expr>` are strict pairs. Each
303310
`--probe` must be followed immediately by exactly one `--expr`.
304-
*`--max-hit <n>` is an optional per-probe option that applies to the most recent
305-
`--probe`/`--expr` pair. It may not appear before the first `--probe` or
306-
between a `--probe` and its matching `--expr`, and may be given at most once
307-
per probe.
311+
*`--cond <expr>` and `--max-hit <n>` are optional modifiers written _after_ the
312+
`--probe`/`--expr` pair they apply to, each at most once per pair. They may not
313+
appear before the first `--probe` or between a `--probe` and its matching
314+
`--expr`.
315+
*`--max-hit` scopes to the `--probe`/`--expr` pair it follows, so pairs
316+
sharing a location may set different limits. `--cond` scopes to the whole
317+
location, probes sharing a location must share one condition (or none).
308318
*`--timeout`, `--json`, `--preview`, and `--port` are global probe options
309319
for the whole probe session. They may appear before or between probe pairs,
310320
but not between a `--probe` and its matching `--expr`.
@@ -379,6 +389,7 @@ $ node inspect --json --probe cli.js:5 --expr 'rss' cli.js
379389
"suffix": "cli.js",
380390
"line": 5
381391
}
392+
// `condition` is present only when the probe was given a --cond expression.
382393
// `maxHit` is present only when the probe was given a --max-hit limit.
383394
}
384395
],
@@ -480,6 +491,10 @@ When multiple `--probe`/`--expr` pairs share the same `--probe`, the
480491
expressions will be evaluated on the same pause in the order they appear
481492
on the command line.
482493

494+
For each location, there can only be at most one `--cond` (or none).
495+
Multiple `--probe`/`--expr` pairs with conflicting conditions
496+
at the same location will be rejected at launch time.
497+
483498
```js
484499
// app.js
485500
constx= { x:42 }; // line 2
@@ -563,6 +578,37 @@ that only matches the intended file:
563578
$ node inspect --probe src/utils.js:10 --expr 'x' main.js # matches only src/utils.js
564579
```
565580

581+
### Probe examples
582+
583+
#### Probing a variable conditionally
584+
585+
```js
586+
// app.js
587+
let total =0;
588+
for (let i =0; i <10; i++) {
589+
total += i; // line 4
590+
}
591+
```
592+
593+
```console
594+
$ out/Release/node inspect --probe app.js:4 --expr 'total' \
595+
--cond 'i % 3 === 0' app.js
596+
```
597+
598+
```text
599+
Hit 1 at file:///path/to/app.js:3:3
600+
total = 0
601+
Hit 2 at file:///path/to/app.js:3:3
602+
total = 3
603+
Hit 3 at file:///path/to/app.js:3:3
604+
total = 15
605+
Hit 4 at file:///path/to/app.js:3:3
606+
total = 36
607+
Completed
608+
```
609+
610+
<!-- TODO(joyeecheung): add more examples for different options -->
611+
566612
## Advanced usage
567613

568614
### V8 inspector integration for Node.js

β€Žlib/internal/debugger/inspect.jsβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ function parseInteractiveArgs(args) {
268268

269269
constkInspectArgOptions={
270270
'__proto__': null,
271+
'cond': {type: 'string'},
271272
'expr': {type: 'string'},
272273
'help': {type: 'boolean',short: 'h'},
273274
'json': {type: 'boolean'},

β€Žlib/internal/debugger/inspect_helpers.jsβ€Ž

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ function writeInspectUsageAndExit(invokedAs, message, exitCode) {
6969
}
7070
out.write(`Usage: ${invokedAs} [--port=<port>] [<node-option> ...]
7171
[<script> [<script-args>] | <host>:<port> | -p <pid>]
72-
${invokedAs} --probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>]
73-
[--probe <file>:<line>[:<col>] --expr <expr> [--max-hit <n>] ...]
72+
${invokedAs} --probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>]
73+
[--probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>] ...]
7474
[--json] [--preview] [--timeout=<ms>] [--port=<port>]
7575
[--] [<node-option> ...] <script> [<script-args> ...]
7676
@@ -109,6 +109,9 @@ Options:
109109
preceding --probe each time execution reaches it.
110110
Avoid probing let/const-bound variables at their
111111
declaration site or a ReferenceError may be thrown.
112+
--cond <expr> Optional condition for the probe location. The probe only
113+
records a hit when <expr> is truthy at the location. A
114+
condition that throws is treated as false.
112115
--max-hit <n> Per-probe limit on evaluated hits. When not specified,
113116
there's no hit limit. When any probe reaches its hit LIMIT,
114117
the probing process will detach and report the results.
@@ -121,6 +124,9 @@ Options:
121124
Semantics:
122125
* Multiple --probe/--expr pairs are allowed. Same-location --probes share
123126
a pause and scope, their --exprs are evaluated in command-line order.
127+
* --max-hit scopes to one --probe/--expr pair, so same-location pairs may set
128+
different limits. --cond scopes to the location, probes sharing a location
129+
must all share one condition (or none).
124130
* --probe utils.js:<line>[:<col>] matches every loaded utils.js. Pass a
125131
fuller path e.g. src/utils.js to narrow the match.
126132
* Use -- before any Node.js flags intended for the child process.

β€Žlib/internal/debugger/inspect_probe.jsβ€Ž

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const {
2121
StringPrototypeIncludes,
2222
StringPrototypeSlice,
2323
StringPrototypeStartsWith,
24+
StringPrototypeTrim,
2425
Symbol,
2526
}=primordials;
2627

@@ -87,6 +88,8 @@ const kInspectPortRegex = /^--inspect-port=(\d+)$/;
8788
* @typedef {object} Probe
8889
* @property {string} expr Expression to evaluate on hit.
8990
* @property {ProbeTarget} target User's original --probe request shape.
91+
* @property {string} [condition] Condition from --cond. V8 only breaks when it is truthy.
92+
* Scoped to the location, so probes sharing a location all carry the same value.
9093
* @property {number} maxHit Per-probe hit limit from --max-hit. Infinity when unlimited.
9194
* @property {number} hits Count of hits observed.
9295
*/
@@ -130,6 +133,12 @@ function formatTargetText(target) {
130133
returncolumn===undefined ? `${suffix}:${line}` : `${suffix}:${line}:${column}`;
131134
}
132135

136+
// Identity of a probe location. Probes sharing a key share one V8 breakpoint,
137+
// so this must stay in sync between condition validation and breakpoint setup.
138+
functionlocationKey(target){
139+
return`${target.suffix}\n${target.line}\n${target.column??''}`;
140+
}
141+
133142
functionformatPendingProbeLocations(probes,pending){
134143
constseen=newSafeSet();
135144
for(constprobeIndexofpending){
@@ -391,6 +400,22 @@ function parseProbeTokens(tokens, args) {
391400
probe.maxHit=parseUnsignedInteger(token.value,'max-hit');
392401
break;
393402
}
403+
case'cond': {
404+
if(probes.length===0){
405+
thrownewERR_DEBUGGER_STARTUP_ERROR('Unexpected --cond before --probe');
406+
}
407+
// A blank condition does not act as a real predicate in V8 (an empty
408+
// string always breaks), so reject it rather than silently mislead.
409+
if(token.value===undefined||StringPrototypeTrim(token.value)===''){
410+
thrownewERR_DEBUGGER_STARTUP_ERROR(`Missing value for ${token.rawName}`);
411+
}
412+
constprobe=probes[probes.length-1];
413+
if(probe.condition!==undefined){
414+
thrownewERR_DEBUGGER_STARTUP_ERROR('A --probe can have at most one --cond');
415+
}
416+
probe.condition=token.value;
417+
break;
418+
}
394419
default:
395420
if(probes.length>0){
396421
thrownewERR_DEBUGGER_STARTUP_ERROR(
@@ -410,6 +435,24 @@ function parseProbeTokens(tokens, args) {
410435
'Probe mode requires at least one --probe <loc> --expr <expr> group');
411436
}
412437

438+
// V8 allows only one breakpoint per location, so probes sharing a location
439+
// cannot carry different conditions.
440+
constconditionByLocation=newSafeMap();
441+
for(const{ target, condition }ofprobes){
442+
constkey=locationKey(target);
443+
// All probes at the same location must share one condition. We split the
444+
// existence check since if one probe does not have a condition (= undefined),
445+
// then all probes at the location must also omit it.
446+
if(conditionByLocation.has(key)){
447+
if(conditionByLocation.get(key)!==condition){
448+
thrownewERR_DEBUGGER_STARTUP_ERROR(
449+
`Probes at ${formatTargetText(target)} must use the same --cond (or none)`);
450+
}
451+
}else{
452+
conditionByLocation.set(key,condition);
453+
}
454+
}
455+
413456
constchildArgv=ArrayPrototypeSlice(args,childStartIndex);
414457
if(childArgv.length===0){
415458
thrownewERR_DEBUGGER_STARTUP_ERROR('Probe mode requires a child script');
@@ -479,8 +522,8 @@ class ProbeInspectorSession {
479522
this.resolveCompletion=resolve;
480523
/** @type {Probe[]} */
481524
this.probes=ArrayPrototypeMap(options.probes,
482-
({ expr, target, maxHit })=>
483-
({ expr, target,maxHit: maxHit??Infinity,hits: 0}));
525+
({ expr, target, maxHit, condition})=>
526+
({ expr, target,condition,maxHit: maxHit??Infinity,hits: 0}));
484527
this.onChildOutput=FunctionPrototypeBind(this.onChildOutput,this);
485528
this.onChildExit=FunctionPrototypeBind(this.onChildExit,this);
486529
this.onClientClose=FunctionPrototypeBind(this.onClientClose,this);
@@ -887,17 +930,19 @@ class ProbeInspectorSession {
887930
constuniqueTargets=newSafeMap();
888931

889932
for(letprobeIndex=0;probeIndex<this.probes.length;probeIndex++){
890-
const{ target }=this.probes[probeIndex];
891-
constkey=`${target.suffix}\n${target.line}\n${target.column??''}`;
933+
const{ target, condition }=this.probes[probeIndex];
934+
// Probes at the same location share one V8 breakpoint. parseProbeTokens has
935+
// already ensured they carry the same condition.
936+
constkey=locationKey(target);
892937
letentry=uniqueTargets.get(key);
893938
if(entry===undefined){
894-
entry={ target,probeIndices: []};
939+
entry={ target,condition,probeIndices: []};
895940
uniqueTargets.set(key,entry);
896941
}
897942
ArrayPrototypePush(entry.probeIndices,probeIndex);
898943
}
899944

900-
for(const{ target, probeIndices }ofuniqueTargets.values()){
945+
for(const{ target,condition,probeIndices }ofuniqueTargets.values()){
901946
// On Windows, normalize backslashes to forward slashes so the regex matches
902947
// V8 script URLs which always use forward slashes.
903948
constnormalizedFile=process.platform==='win32' ?
@@ -918,6 +963,9 @@ class ProbeInspectorSession {
918963
// the inspector bind to the first executable column.
919964
params.columnNumber=target.column-1;
920965
}
966+
if(condition!==undefined){
967+
params.condition=condition;
968+
}
921969

922970
constresult=awaitthis.callCdp('Debugger.setBreakpointByUrl',params);
923971
debug('breakpoint set: id=%s urlRegex=%s locations=%j',
@@ -943,9 +991,10 @@ class ProbeInspectorSession {
943991
code: exitCode,
944992
report: {
945993
v: kProbeVersion,
946-
probes: ArrayPrototypeMap(this.probes,({ expr, target, maxHit })=>{
947-
// Omit an unlimited maxHit, as Infinity would serialize to null in JSON.
994+
probes: ArrayPrototypeMap(this.probes,({ expr, target, maxHit, condition })=>{
948995
constprobe={ expr, target };
996+
if(condition!==undefined){probe.condition=condition;}
997+
// Omit an unlimited maxHit, as Infinity would serialize to null in JSON.
949998
if(maxHit!==Infinity){probe.maxHit=maxHit;}
950999
returnprobe;
9511000
}),
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// This tests that probe mode rejects malformed --cond usage.
2+
'use strict';
3+
4+
constcommon=require('../common');
5+
common.skipIfInspectorDisabled();
6+
7+
constfixtures=require('../common/fixtures');
8+
const{ assertProbeCliError }=require('../common/debugger-probe');
9+
10+
constcwd=fixtures.path('debugger');
11+
12+
assertProbeCliError(
13+
['--cond','x','--probe','probe.js:12','--expr','finalValue','probe.js'],
14+
/Unexpected--condbefore--probe/,{ cwd });
15+
16+
assertProbeCliError(
17+
['--probe','probe.js:12','--cond','x','--expr','finalValue','probe.js'],
18+
/Each--probemustbefollowedimmediatelyby--expr/,{ cwd });
19+
20+
assertProbeCliError(
21+
['--probe','probe.js:12','--expr','finalValue','--cond','x','--cond','y','probe.js'],
22+
/A--probecanhaveatmostone--cond/,{ cwd });
23+
24+
assertProbeCliError(
25+
['--probe','probe.js:12','--expr','finalValue','--cond',' ','probe.js'],
26+
/Missingvaluefor--cond/,{ cwd });
27+
28+
assertProbeCliError(
29+
['--probe','probe.js:12','--expr','finalValue','--cond'],
30+
/Missingvaluefor--cond/,{ cwd });
31+
32+
assertProbeCliError(
33+
['--probe','probe.js:12','--expr','a','--cond','x',
34+
'--probe','probe.js:12','--expr','b','--cond','y','probe.js'],
35+
/Probesatprobe\.js:12mustusethesame--cond\(ornone\)/,{ cwd });
36+
37+
assertProbeCliError(
38+
['--probe','probe.js:12','--expr','a','--cond','x',
39+
'--probe','probe.js:12','--expr','b','probe.js'],
40+
/Probesatprobe\.js:12mustusethesame--cond\(ornone\)/,{ cwd });
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// This tests that --cond and --max-hit work together.
2+
'use strict';
3+
4+
constcommon=require('../common');
5+
common.skipIfInspectorDisabled();
6+
7+
constfixtures=require('../common/fixtures');
8+
const{ spawnSyncAndAssert }=require('../common/child_process');
9+
const{ assertProbeJson }=require('../common/debugger-probe');
10+
11+
constcwd=fixtures.path('debugger');
12+
constprobeUrl=fixtures.fileURL('debugger','probe-max-hit.js').href;
13+
14+
// --max-hit written before --cond. The condition still filters index !== 1, so
15+
// the only recorded hit carries value 1 rather than the loop's first iteration.
16+
spawnSyncAndAssert(process.execPath,[
17+
'inspect',
18+
'--json',
19+
'--probe','probe-max-hit.js:5',
20+
'--expr','index',
21+
'--max-hit','5',
22+
'--cond','index === 1',
23+
'probe-max-hit.js',
24+
],{ cwd },{
25+
stdout(output){
26+
assertProbeJson(output,{
27+
v: 2,
28+
probes: [{
29+
expr: 'index',
30+
condition: 'index === 1',
31+
maxHit: 5,
32+
target: {suffix: 'probe-max-hit.js',line: 5},
33+
}],
34+
results: [
35+
{
36+
probe: 0,
37+
event: 'hit',
38+
hit: 1,
39+
location: {url: probeUrl,line: 5,column: 3},
40+
result: {type: 'number',value: 1,description: '1'},
41+
},
42+
{event: 'completed'},
43+
],
44+
});
45+
},
46+
trim: true,
47+
});

0 commit comments

Comments
Β (0)