Skip to content

Commit 5b94c46

Browse files
committed
inspector: support attaching to a running process in probe mode
Probe mode currently only launches a new process from the entry point script. This change lets it attach to an already-running process, so a long-lived application can be inspected without restarting it. Running `node inspect --probe` without specifying an entry point script instructs Node.js to attach to a running Node.js process and probe. The way attaching is done is specified through command line arguments: - `--pid <pid>` activates the target's inspector (if it's not already activated) and connects to it. - `--host <host>` and `--port <port>` specify an address where an inspector is listening. The host defaults to 127.0.0.1 and the port defaults to 9229. When the session ends, probe mode resumes and detaches instead of killing a process it does not own. The report now includes a top-level `target` field describing what was probed, and each probe contains a `locations` field about where it bound. An attach session ends when a probe reaches `--max-hit` (reported as `completed`), when the target finishes on its own (`completed`, or `miss` with the pending probes, and the target is then allowed to exit), or when `--timeout` fires (reported as `timeout`, the probing process exit with 0 and reports the hits recorded so far). `--timeout` now is also applied to the startup phase in both modes, so a target that never responds before the breakpoints are bound is reported as an error instead of hanging.
1 parent e3b3984 commit 5b94c46

25 files changed

Lines changed: 1025 additions & 98 deletions

‎doc/api/debugger.md‎

Lines changed: 126 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,11 @@ added:
237237
- v26.1.0
238238
- v24.16.0
239239
changes:
240+
- version: REPLACEME
241+
pr-url: REPLACEME
242+
description: Add `--pid <pid>` and `--host <host>`/`--port <port>` to probe a process that's
243+
already running. The report now includes a top-level `target` field to describe what was
244+
probed, and a `locations` field showing which probe is bound to which location.
240245
- version:
241246
- v26.6.0
242247
- v24.20.0
@@ -270,8 +275,10 @@ changes:
270275
`node inspect` supports a non-interactive probe mode for inspecting runtime values
271276
in an application via the flag `--probe`.
272277

273-
Currently, probe mode only supports launching a new process from the entry point
274-
script specified on the command line.
278+
There are two ways to use probe mode:
279+
280+
* Launch a new process and probe it by specifying the entry point script.
281+
* Attach to and probe a running process using `--pid <pid>` or `--host <host>`/`--port <port>`.
275282

276283
The probe mode sets one or more source breakpoints, evaluates specified
277284
expressions whenever the execution reaches a breakpoint, and prints one
@@ -281,10 +288,18 @@ printf-style debugging without having to modify the application code and
281288
clean up afterwards. It also supports structured JSON output for tool use.
282289

283290
```console
291+
# To launch a new process and probe it:
284292
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>]
285293
[--probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>] ...]
286294
[--json] [--preview] [--timeout=<ms>] [--port=<port>]
287295
[--] [<node-option> ...] <script> [<script-args> ...]
296+
297+
# To activate the inspector in an already-running process and probe it:
298+
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [...] --pid <pid> [--host <host>] [--port <port>]
299+
300+
# To probe a running process whose inspector is already listening for connections:
301+
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [...] --host <host> [--port <port>]
302+
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [...] --port <port> [--host <host>]
288303
```
289304

290305
* `--probe <file>:<line>[:<col>]`: Source location of the probe. When execution
@@ -300,17 +315,32 @@ $ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--ma
300315
is treated as false.
301316
* `--max-hit <n>`: An optional per-probe limit on the number of times the probe
302317
can be hit. When not specified, there's no hit limit. When any probe reaches
303-
its hit limit, the probing process will detach and report the results. The process
304-
being probed will continue to run. If any other probe is never reached by the time
305-
the session ends, it will be reported as a missed probe.
306-
* `--timeout=<ms>`: A global wall-clock deadline for the entire probe session.
307-
The default is `30000`. This can be used to probe a long-running application
308-
that can be terminated externally.
318+
its hit limit, the probing session finishes and reports the results. If the
319+
probed process was launched by probe mode, it's then terminated. If the process
320+
was already running before being attached for probing, it is resumed and left running.
321+
If any other probe is never reached by the time the session ends, the missed probes will
322+
be reported.
323+
* `--timeout=<ms>`: A deadline in milliseconds. The default is `30000`. It is
324+
applied first to the target responding to the inspector and binding the
325+
probes, then to the probing session itself, so a run can take up to about
326+
twice the limit to time out. If the target does not respond in time during startup,
327+
the report ends with an `error` and the probing process exits with a non-zero
328+
code. If the process was attached to instead of launched by probe mode,
329+
`--timeout` is an alternative to `--max-hit` for ending a probing session
330+
without terminating the probed process.
309331
* `--json`: If used, prints a structured JSON report instead of the default text report.
310332
* `--preview`: If used, non-primitive values will include CDP property previews for
311333
object-like JSON probe values.
312-
* `--port=<port>`: Selects the local inspector port where the probing session
313-
will listen. Defaults to `0`, which requests a random port.
334+
* `--port=<port>`: When the probe mode launches the probed process, it specifies the port
335+
the launched child process' inspector listens on and defaults to `0`, which requests a
336+
random port. When the probe mode attaches to a running process, this specifies the port to connect to
337+
and defaults to `9229`, while `0` is invalid.
338+
* `--host <host>`: In attach mode, the host to connect to. Defaults to
339+
`127.0.0.1`. It is an error to combine it with an entry point script.
340+
* `--pid <pid>`: Attach to a running Node.js process by PID instead of
341+
launching a child. This activates the inspector of the target process if it
342+
is not active yet, then connects to `--host`:`--port`. See
343+
[Attaching to a running process](#attaching-to-a-running-process).
314344
* `--` is optional unless the child needs its own Node.js flags.
315345

316346
Additional rules about the composition of the options:
@@ -339,6 +369,65 @@ $ node inspect --probe app.js:10 --expr "user"
339369
--json --preview -- --no-warnings app.js --arg-for-app=foo
340370
```
341371

372+
### Attaching to a running process
373+
374+
When the entry point script is not specified and `--probe` is used, probe mode can attach
375+
to a process that is already running instead of launching a child process.
376+
This is useful for probing a long-running application, when users want to inspect runtime
377+
values without restarting it.
378+
379+
When `--pid` is specified, probe mode activates the inspector of the process by
380+
sending `SIGUSR1` on POSIX platforms. The process must be a Node.js process,
381+
otherwise it may be terminated by `SIGUSR1`. The probing process will retry
382+
connecting using `--host` and `--port` until the timeout fires.
383+
If `--pid` is not specified, the inspector in the target process must already be listening.
384+
If the connection is refused, an error is reported immediately.
385+
386+
```console
387+
# Probing a process by PID, which may not already have the inspector listening.
388+
# End the probing session when the specified probe is reached 5 times.
389+
$ node inspect --probe app.js:42 --expr "req.url" --max-hit 5 --pid 12345
390+
391+
# Probing a process that's already listening for inspector connections at 127.0.0.1:9229.
392+
# End the probing session when the specified timeout is reached.
393+
$ node inspect --probe app.js:42 --expr "req.url" --timeout 50000 --host 127.0.0.1 --port 9229
394+
```
395+
396+
Probing by attaching to a process differs from launching one in several ways.
397+
398+
* The scripts are already loaded in the process when the breakpoints are bound,
399+
and the `locations` field for each probe in the report shows where it bound.
400+
An empty list indicates that no loaded script matched the probe, e.g.
401+
when there's a typo in the path or the line.
402+
* When the probing session ends, it resumes and detaches from the probed process.
403+
The target should continue running and **its inspector will remain activated**.
404+
Probe mode does not kill a process it did not launch.
405+
* If the target is waiting for `--inspect-brk` or `--inspect-wait` when the
406+
session attaches to it, these would be released so that the probes can run.
407+
* An attach session ends when one of the following happens:
408+
* A probe reaches `--max-hit`. The report ends with `completed`.
409+
* The target finishes on its own. The report ends with `completed`, or
410+
with `miss` when some probes are never hit. The target is then allowed to
411+
exit.
412+
* The target exits with an uncaught exception. The report ends with `probe_target_exit`
413+
and an `error` that describes the exception. The probing process exits with code 0.
414+
* `--timeout` fires. The report ends with `timeout` and the hits recorded
415+
so far, and the probing process exits with code 0. If a probe expression
416+
is still running at that point, the session reports an `error` and exits
417+
with code 1 instead. The expression is terminated after at most another
418+
`--timeout`, unless it is blocked inside a native call, and the target
419+
then resumes.
420+
* The target does not respond during startup within `--timeout`, for
421+
example because its main thread is blocked for synchronous work. The
422+
report then ends with an `error`, and the probing process exits with code 1.
423+
* If the target is killed during the session, the session ends with an
424+
`error`. If it's attached via `--pid`, this is reported as
425+
`probe_target_exit` with exit code 0. If it's attached via `--host` and `--port`,
426+
an exit cannot be distinguished from a network failure, so it is reported as
427+
`probe_failure` with a non-zero exit code.
428+
* The target's stdout and stderr are not captured, so the terminal `error`
429+
events have no `error.stderr` field.
430+
342431
### Probe output format
343432

344433
When the probe session ends, the probing process prints a final report of all the probe hits and results.
@@ -380,12 +469,19 @@ When `--json` is used, the output shape looks like this:
380469

381470
```console
382471
$ node inspect --json --probe cli.js:5 --expr 'rss' cli.js
383-
{"v":2,"probes":[{"expr":"rss","target":{"suffix":"cli.js","line":5}}],"results":[{"probe":0,"event":"hit","hit":1,"location":{"url":"file:///path/to/cli.js","line":5,"column":3},"result":{"type":"number","value":55443456,"description":"55443456"}},{"probe":0,"event":"hit","hit":2,"location":{"url":"file:///path/to/cli.js","line":5,"column":3},"result":{"type":"number","value":55574528,"description":"55574528"}},{"event":"completed"}]}
472+
{"v":2,"target":{"argv":["cli.js"]},"probes":[{"expr":"rss","target":{"suffix":"cli.js","line":5},"locations":[{"url":"file:///path/to/cli.js","line":5,"column":3}]}],"results":[{"probe":0,"event":"hit","hit":1,"location":{"url":"file:///path/to/cli.js","line":5,"column":3},"result":{"type":"number","value":55443456,"description":"55443456"}},{"probe":0,"event":"hit","hit":2,"location":{"url":"file:///path/to/cli.js","line":5,"column":3},"result":{"type":"number","value":55574528,"description":"55574528"}},{"event":"completed"}]}
384473
```
385474

386475
```json
387476
{
388477
"v": 2, // Probe JSON schema version.
478+
"target": {
479+
// Describes what was probed. The shape depends on how the session was started:
480+
// launch mode: { "argv": ["cli.js"] }
481+
// --pid <pid>: { "pid": 12345, "host": "127.0.0.1", "port": 9229 }
482+
// --host/--port: { "host": "127.0.0.1", "port": 9229 }
483+
"argv": ["cli.js"]
484+
},
389485
"probes": [
390486
{
391487
"expr": "rss", // The expression paired with --probe.
@@ -397,7 +493,13 @@ $ node inspect --json --probe cli.js:5 --expr 'rss' cli.js
397493
// from the target and will be reported in each hit's `location` field.
398494
"suffix": "cli.js",
399495
"line": 5
400-
}
496+
},
497+
// Locations where the probe's breakpoint is bound. The shape is the same as the `location`
498+
// of a hit. There is one entry per matching script, and it's empty when no loaded script
499+
// matches the target, or when the session ends before the probe is bound.
500+
"locations": [
501+
{ "url": "file:///path/to/cli.js", "line": 5, "column": 3 }
502+
]
401503
// `condition` is present only when the probe was given a --cond expression.
402504
// `maxHit` is present only when the probe was given a --max-hit limit.
403505
}
@@ -477,18 +579,23 @@ $ node inspect --json --probe cli.js:5 --expr 'rss' cli.js
477579

478580
### Output and exit codes
479581

480-
Probe mode only prints the final probe report to stdout, and otherwise silences
481-
stdout/stderr from the child process. When the probing session ends,
582+
Probe mode only prints the final probe report to stdout. If the probed process is launched by
583+
probe mode, the stdout/stderr of the child process is silenced. When the probing session ends,
482584
the probing process typically exits with code `0` and prints a final report to
483585
stdout. If the child process exits with a non-zero code before the probe
484586
session ends, or the probe session cannot complete for another reason, the
485587
final report records a terminal `error` event.
486588

487-
When `error.code` is `'probe_failure'` or `'probe_timeout'`, the probing process
488-
exits with a non-zero code, indicating recorded hits may be incomplete.
489-
In this case, `error.message` will contain recovery hints, and `error.probe`,
490-
when present, is an index into the report's `probes` array that identifies
491-
the possible culprit probe on a best-effort basis to help guide debugging.
589+
When `error.code` is `'probe_failure'`, the probing process exits with a
590+
non-zero code, indicating recorded hits may be incomplete. In this case,
591+
`error.message` will contain recovery hints, and `error.probe`, when present,
592+
is an index into the report's `probes` array that identifies the possible
593+
culprit probe on a best-effort basis to help guide debugging.
594+
595+
When `error.code` is `'probe_timeout'`, the probing process exits with a
596+
non-zero code if it launched the process, and with `0` if it attached to a
597+
running process, where the timeout is considered a normal end of the session. See
598+
[Attaching to a running process](#attaching-to-a-running-process).
492599

493600
Invalid arguments and fatal launch or connect failures may cause the
494601
probing process to exit with a non-zero code and print an error message

‎lib/internal/debugger/inspect.js‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,10 +271,12 @@ const kInspectArgOptions = {
271271
'cond': { type: 'string' },
272272
'expr': { type: 'string' },
273273
'help': { type: 'boolean', short: 'h' },
274+
'host': { type: 'string' },
274275
'json': { type: 'boolean' },
275-
// Port, timeout, and max-hit use type 'string' because parseArgs has no
276-
// numeric type; the values are parsed to integers by parseProbeTokens().
276+
// Port, timeout, max-hit, and pid use type 'string' because parseArgs has
277+
// no numeric type; the values are parsed to integers by parseProbeTokens().
277278
'max-hit': { type: 'string' },
279+
'pid': { type: 'string' },
278280
'port': { type: 'string' },
279281
'preview': { type: 'boolean' },
280282
'probe': { type: 'string' },
@@ -310,6 +312,10 @@ function parseInspectMode(args) {
310312
}
311313
return { mode: 'probe', tokens, args };
312314
}
315+
// These are only accepted in probe mode.
316+
if (token.name === 'host' || token.name === 'pid') {
317+
return { mode: 'probe', tokens, args };
318+
}
313319
}
314320
if (token.kind === 'option-terminator' || token.kind === 'positional') {
315321
break;

‎lib/internal/debugger/inspect_client.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,9 @@ class Client extends EventEmitter {
348348

349349
this._socket = socket;
350350
socket.on('data', this.handleChunk);
351+
// A target that is terminated can reset the connection. Ignore the error and
352+
// let the 'close' event handle it.
353+
socket.on('error', (error) => debuglog('socket error %o', error));
351354
socket.on('close', () => {
352355
this.emit('close');
353356
});

‎lib/internal/debugger/inspect_helpers.js‎

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -261,10 +261,10 @@ function writeInspectUsageAndExit(invokedAs, message, exitCode) {
261261
}
262262
out.write(`Usage: ${invokedAs} [--port=<port>] [<node-option> ...]
263263
[<script> [<script-args>] | <host>:<port> | -p <pid>]
264-
${invokedAs} --probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>]
265-
[--probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>] ...]
266-
[--json] [--preview] [--timeout=<ms>] [--port=<port>]
267-
[--] [<node-option> ...] <script> [<script-args> ...]
264+
${invokedAs} --probe <file>:<line>[:<col>] --expr <expr>
265+
[<probe-option> ...]
266+
[<script> [<script-args> ...]
267+
| --pid <pid> | --host <host> | --port <port>]
268268
269269
Interactive mode: Starts a live debugging session.
270270
@@ -284,11 +284,35 @@ Semantics:
284284
Non-interactive probe mode: Evaluates expressions whenever execution reaches
285285
specified source locations and prints all the evaluation results to stdout.
286286
287-
Example:
287+
Usage:
288+
# Launch a script and probe it:
289+
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>]
290+
[--probe <file>:<line>[:<col>] --expr <expr> [--cond <expr>] [--max-hit <n>] ...]
291+
[--json] [--preview] [--timeout=<ms>] [--port=<port>]
292+
[--] [<node-option> ...] <script> [<script-args> ...]
293+
294+
# Activate the inspector in a running process and probe it:
295+
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [...] --pid <pid> [--host <host>] [--port <port>]
296+
297+
# Probe a running process whose inspector is already listening:
298+
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [...] --host <host> [--port <port>]
299+
$ node inspect --probe <file>:<line>[:<col>] --expr <expr> [...] --port <port> [--host <host>]
300+
301+
Examples:
302+
# Launch app.js with --no-warnings. Print "user" whenever line 10 runs, and
303+
# "config.options" whenever column 15 of line 5 in src/utils.js runs:
288304
$ node inspect --probe app.js:10 --expr "user"
289305
--probe src/utils.js:5:15 --expr "config.options"
290306
--json --preview -- --no-warnings app.js --arg-for-app=foo
291307
308+
# Attach to the Node.js process with PID 12345, activating its inspector if
309+
# needed, and detach once line 42 of app.js has run 5 times:
310+
$ node inspect --probe app.js:42 --expr "req.url" --max-hit 5 --pid 12345
311+
312+
# Attach to the process whose inspector listens at 127.0.0.1:9229, and
313+
# detach after 50 seconds:
314+
$ node inspect --probe app.js:42 --expr "req.url" --timeout 50000 --host 127.0.0.1 --port 9229
315+
292316
Options:
293317
--probe <file>:<line>[:<col>]
294318
Source location of the probe. <file> is matched as a
@@ -305,13 +329,20 @@ Options:
305329
records a hit when <expr> is truthy at the location. A
306330
condition that throws is treated as false.
307331
--max-hit <n> Per-probe limit on evaluated hits. When not specified,
308-
there's no hit limit. When any probe reaches its hit LIMIT,
309-
the probing process will detach and report the results.
310-
The probed process will continue to run.
332+
there's no hit limit. When any probe reaches its hit limit,
333+
the session finishes and reports the results. If the
334+
process is launched by the probe mode, it is then
335+
terminated, otherwise it is resumed and left to run.
311336
--json Output JSON if specified, otherwise human-readable text.
312337
--preview Include V8 object previews in JSON output.
313-
--timeout <ms> Global session timeout (default: 30000).
314-
--port <port> Inspector port for the debuggee (default: 0 = random).
338+
--timeout <ms> Deadline in milliseconds (default: 30000), applied first to
339+
startup and probe binding, then to the probing session.
340+
--port <port> Inspector port of the probed process. Defaults to 0 (random)
341+
when launching a script, and to 9229 when attaching to a
342+
running process.
343+
--host <host> Inspector host of the process to attach to (default: 127.0.0.1).
344+
--pid <pid> PID of the Node.js process to attach to. Its inspector is
345+
activated with SIGUSR1 if needed.
315346
316347
Semantics:
317348
* Multiple --probe/--expr pairs are allowed. Same-location --probes share
@@ -322,6 +353,10 @@ Semantics:
322353
* --probe utils.js:<line>[:<col>] matches every loaded utils.js. Pass a
323354
fuller path e.g. src/utils.js to narrow the match.
324355
* Use -- before any Node.js flags intended for the child process.
356+
* When an entry point script is given, the probed process is launched.
357+
Otherwise, --pid and/or --host:--port specifies the target to attach to.
358+
The probing session ends when --max-hit or --timeout is reached, or
359+
when the target process finishes.
325360
* Target errors are surfaced in the report as a terminal 'error' event.
326361
The probing process exits 0 unless it encounters an error itself.
327362

0 commit comments

Comments
 (0)