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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ tests/**/*.js.map
# Exception: hand-written JS debuggee fixtures (not TS output) — without this
# negation, `git add` silently skips new fixtures (how PR #252 lost one)
!tests/fixtures/debug-scripts/*.js
!tests/fixtures/javascript/subprocess-tree/*.js

# Package source compilation artifacts
packages/*/src/**/*.js
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ The codebase follows a **layered architecture with dependency injection** and **
- **DapProxyWorker** (`dap-proxy-worker.ts`): Core worker handling debugging operations
- **Adapter Policies**: Language-specific behavior via policy pattern (`DefaultAdapterPolicy`, `PythonAdapterPolicy`, `JsDebugAdapterPolicy`, `RubyAdapterPolicy`, `RustAdapterPolicy`, `GoAdapterPolicy`, `JavaAdapterPolicy`, `DotnetAdapterPolicy`, `CppAdapterPolicy`, `CobolAdapterPolicy`, `DartAdapterPolicy`, `MockAdapterPolicy`); LLDB-generic pieces shared by rust/cpp/cobol live in `lldb-policy-shared.ts`. Note: Java is fully wired to `JavaAdapterPolicy` in `DapProxyWorker.selectAdapterPolicy()` (not falling through to `DefaultAdapterPolicy`).
- **ChildSessionManager** (`src/proxy/child-session-manager.ts`): Manages DAP child sessions within a single proxy process. Currently used by the js-debug adapter (`childSessionStrategy: 'launchWithPendingTarget'`), which spawns a child debug session for the actual debuggee while the parent session manages the launch orchestration.
- **ChildConnections** (`src/proxy/child-connections.ts`) and **ChildProxyView** (`src/proxy/child-proxy-view.ts`): the session tree (issue #1081). One worker serves the root and every child the adapter reports: a policy with a `childAdoption` contract (Python today) makes the worker advertise `supportsStartDebuggingRequest`, hold each report (`child_reported` status, released after 30 s with no answer) until the parent process answers `childAdopt`/`childRelease`/`childClose` on the `dap` envelope, and run the adopted child's handshake on its own connection to the same adapter (DAP trace label `child:<id>`), every event tagged with the child's session id; the parent-side `ProxyManager` demultiplexes by session id into a `ChildProxyView`, an `IProxyManager` over the same IPC channel whose `stop()` closes only that connection. js-debug's single-target adoption of the program itself stays with `ChildSessionManager` (M2 is #1082)
- **ChildConnections** (`src/proxy/child-connections.ts`) and **ChildProxyView** (`src/proxy/child-proxy-view.ts`): the session tree (issue #1081). One worker serves the root and every child the adapter reports: a policy with a `childAdoption` contract (Python and JavaScript) makes the worker advertise `supportsStartDebuggingRequest`, hold each report (`child_reported` status, released after 30 s with no answer) until the parent process answers `childAdopt`/`childRelease`/`childClose` on the `dap` envelope, and run the adopted child's handshake on its own connection to the same adapter (DAP trace label `child:<id>`), every event tagged with the child's session id; the parent-side `ProxyManager` demultiplexes by session id into a `ChildProxyView`, an `IProxyManager` over the same IPC channel whose `stop()` closes only that connection. js-debug (issue #1082): the program's own first target stays `ChildSessionManager`'s in-session adoption (the #704 launch-ready signal); every later report — a fork on the program target's connection, a grandchild on the fork's — is relayed as a child report (`childReport` → `child-report`), and every non-root connection runs `createChildSafePolicy` (`src/proxy/child-safe-policy.ts`), so no connection but the root builds a manager. The contract's `releaseLever` decides a release: debugpy's report is refused, js-debug's is acked when held (its answer decides nothing) and released by a throwaway attach-then-detach (`src/proxy/throwaway-release.ts`, #501's lever) — refusing it would park the child forever; `childOutput: 'launch-session'` makes a js child's `get_output`/resource answer `outputOn: <root id>` (`SessionManagerCore.outputOnFor`); `closeChild: 'release'` closes a js child's connection with `terminateDebuggee: false` and answers `released: true`
- Implements full Debug Adapter Protocol (DAP) communication

### Key Patterns
Expand Down
1 change: 1 addition & 0 deletions changelog.d/1082.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**JavaScript forks become child debug sessions** — a Node launch with `dapLaunchArgs.autoAttachChildProcesses: true` used to adopt the program itself and release every `fork()`, `spawn(process.execPath, …)` or `cluster` worker to run undebugged (#501); js-debug's report of each is now adopted the way debugpy's is (#1081): every process after the program's own becomes a debug session of its own, listed by `list_debug_sessions` with `parentSessionId`, stopping on the parent's breakpoints (re-sent before it runs), reported by the parent's tree-aware `wait_for_stop` with its `sessionId`, with its own `wait_for_stop` and `close_debug_session`; a grandchild hangs under the fork that started it. What js-debug's measured shapes change: the whole tree's output stays on the launch session's buffer (a child's `get_output` and resource answer `outputOn: <launch session id>` instead of an empty page), `close_debug_session` on a child releases it to run on (`released: true` — js-debug cannot end a target it adopted), and a child the server does not adopt (the cap, `DEBUG_MCP_MAX_CHILD_SESSIONS`) is released by a throwaway attach rather than a refusal, which would park it forever. The key still defaults to `false`; the default flips in the next step of #1082 (#1005, #1082).
1 change: 1 addition & 0 deletions changelog.d/1094.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**JavaScript child release and output** — child releases handle socket failures without crashing the shared worker, stop issuing requests after their deadline, and finish before the root disconnects. Shutdown also drains releases already in flight and newly reported descendants, with enough worker-exit grace for the release handshake. Children of an attached JavaScript process keep their own console output; output resources preserve child-local diagnostics alongside a launch-session output pointer.
48 changes: 30 additions & 18 deletions docs/architecture/session-tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,18 @@ in what order). It is built from the measured facts in
below cites one of those or a source named in the survey.

Status: decisions taken 2026-11-03 (listed with their owners at the end); merged in
[#1083](https://github.com/debugmcp/mcp-debugger/pull/1083). Implementation under
[#1081](https://github.com/debugmcp/mcp-debugger/issues/1081): M1 PR 1 (adoption behind an
explicit `subProcess: true`) merged in [#1087](https://github.com/debugmcp/mcp-debugger/pull/1087);
PR 2 (the tree-aware wait and the cascade answers) in [#1090](https://github.com/debugmcp/mcp-debugger/pull/1090);
the third PR (on by default, the attach measurement, the lifecycle suites) completes M1. M2 is
[#1082](https://github.com/debugmcp/mcp-debugger/issues/1082).
[#1083](https://github.com/debugmcp/mcp-debugger/pull/1083). M1
([#1081](https://github.com/debugmcp/mcp-debugger/issues/1081), Python) is complete: PR 1
(adoption behind an explicit `subProcess: true`) merged in
[#1087](https://github.com/debugmcp/mcp-debugger/pull/1087), PR 2 (the tree-aware wait and the
cascade answers) in [#1090](https://github.com/debugmcp/mcp-debugger/pull/1090), PR 3 (on by
default, the attach measurement, the lifecycle suites) in
[#1091](https://github.com/debugmcp/mcp-debugger/pull/1091). M2
([#1082](https://github.com/debugmcp/mcp-debugger/issues/1082), js-debug) ships in two PRs: the
first lands the mechanism behind an explicit `autoAttachChildProcesses: true` (every report after
the program's own is a child session, the throwaway release, `outputOn`, a child's close as a
release, the fork/grandchild suite and the parent-exit measurement below); the second flips the
launch and attach defaults and adds the attach, cluster and cascade suites.
Scope: debuggee children reported by the adapter (debugpy subprocesses, js-debug forks and, later,
browser targets). The tree is additive to the shipped tool surface: no new tool, no changed
parameter, new fields only.
Expand Down Expand Up @@ -151,13 +157,17 @@ A child's own buffer carries its own lines **where the adapter can attribute the
- debugpy can: with `redirectOutput: true` in the child's attach, the child's stdout and stderr
also arrive on the child's connection. The child's `get_output` and
`debug://sessions/<child>/output` show the child's lines; the parent's show the tree.
- js-debug cannot: all output of the tree arrives on the root connection and child sessions carry
- js-debug launches cannot: all output of the tree arrives on the root connection and child sessions carry
no `output` events at all. A js-debug child's `get_output` answers with an empty page and a note
that its output is on the launch session (`outputOn: <launch session id>`); its resource reads
the same way.

The attribution is one per-adapter policy pin (below). The per-process-only alternative (each
session carries only its own process's lines) is not implementable: js-debug offers no
the same way, preserving any diagnostics captured in the child's own buffer.
- js-debug attach roots do not own the process pipes. With the default console capture, each
child's console output arrives on its own connection; its `get_output` and resource show those
lines, without an `outputOn` pointer. This was measured with an attached Node parent and an
adopted fork during the M2 review (js-debug v1.140.0).

The attribution is one per-adapter policy pin (below), applied to launch roots. The per-process-only
alternative (each session carries only its own process's lines) is not implementable for launches: js-debug offers no
attribution, and the parent's copy of a debugpy child's lines arrives through the inherited pipe
with no pid on it.

Expand All @@ -172,10 +182,11 @@ Two measured effects the answers name:
child ends, because the child inherited the parent's stdout pipe (Python spike, cascade row 2;
the [#993](https://github.com/debugmcp/mcp-debugger/issues/993) mechanism). While that is the
case the parent row says `running` and its `wait_for_stop` reports the child.
- A js-debug program's exit kills its unreported children within 300 ms (JavaScript spike, cascade
row 2). That run did not adopt the fork: whether an already adopted child survives its parent's
exit is unmeasured and belongs in M2's lifecycle fixture. The server retains the child session
until its own terminal event, without promising that the adapter keeps its process alive.
- A js-debug program's exit ends every process under it: its unreported children within 300 ms
(JavaScript spike, cascade row 2), and an adopted child too, even one paused on a breakpoint —
its `terminated` arrives at the parent's exit, with no exit code (measured 2026-11-05 in M2's
`javascript-subprocess-tree.test.ts`, js-debug v1.140.0). The launch owns the tree. The child's
row goes `stopped` and stays listed until closed; the root ends normally with its own exit code.

### Closing

Expand Down Expand Up @@ -409,7 +420,7 @@ One row per measured fact; the "answer" column is what this design adds on top o
| event | debugpy | js-debug | answer |
|---|---|---|---|
| child exits while the parent is paused | child `terminated`; the parent, once continued, sees the exit and ends normally (row 1) | same (row 1) | the child row goes `stopped`; the parent's wait still reports the paused parent (rule 1), with the ended child in `children`; after the parent resumes, the child's end alone does not complete that wait (rule 4) |
| parent exits with a child live | the parent's `exited` is held until the child ends (inherited stdout pipe, #993); the child stays debuggable (row 2) | the program's exit kills unreported children within 300 ms (row 2); an already adopted child's survival is unmeasured, to be checked in M2 | Python: the parent row stays `running` and its wait reports the child; the child row carries `parentSessionId` of a parent that is gone when it finally ends; every adopted child stays listed and follows its own terminal events |
| parent exits with a child live | the parent's `exited` is held until the child ends (inherited stdout pipe, #993); the child stays debuggable (row 2) | the program's exit kills unreported children within 300 ms (row 2) and ends an adopted, paused child at the same moment — its `terminated` arrives with the parent's exit, no exit code (measured in M2) | Python: the parent row stays `running` and its wait reports the child; the child row carries `parentSessionId` of a parent that is gone when it finally ends. js-debug: the child row goes `stopped` with no exit code and the root ends with its own; every adopted child stays listed and follows its own terminal events |
| explicit `close_debug_session` on a child | `disconnect(terminateDebuggee: true)`: the child ends, exit 0 reported to the parent (row 4) | `disconnect` with either flag leaves the child running (row 3) | its descendants closed deepest-first, then the child; `processEnded: true` / `released: true`; ancestors, siblings and the shared worker stay open |
| `close_debug_session` on a launch root | `disconnect(terminateDebuggee: true)` on the root ends the tree (row 6) | only the root's `disconnect` ends the tree, and it never answers (row 6); `terminate` or `disconnect` on a program or child session leave the processes alive (rows 4–5) | children closed deepest-first, then the root; the worker's timeout-then-tree-kill stays the backstop |
| close or idle expiry of an attach root | `disconnect(terminateDebuggee: false)` releases each child (row 3) | each child keeps running (row 3) | detach descendants deepest-first, then the root; preserve external targets and remove the tree's rows; `released: true`; no target-killing escalation |
Expand Down Expand Up @@ -488,8 +499,9 @@ the only one; `autoAttachChildProcesses` defaults on for launch and attach with
(the `attach_to_process` schema text changes with it); the attach smoke that
today expects release ([`tests/e2e/mcp-server-smoke-javascript-attach.test.ts`](../../tests/e2e/mcp-server-smoke-javascript-attach.test.ts))
expects adoption. Reuse M1's subtree-close, idle, attach-target preservation and cleanup-isolation
cases; verify that cap `0` still adopts the program's own target but releases forks, and measure
parent exit with an already adopted, paused child. Unblocks #985 M2 (jest and vitest workers as
cases; verify that cap `0` still adopts the program's own target but releases forks (the first PR's
suite does), and measure parent exit with an already adopted, paused child (measured in the first
PR: the child ends with the parent, lifecycle row 2). Unblocks #985 M2 (jest and vitest workers as
sessions).

**M3 — browser targets.** After the [#989](https://github.com/debugmcp/mcp-debugger/issues/989)
Expand Down
19 changes: 14 additions & 5 deletions docs/javascript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,16 +206,25 @@ to inspect these decisions inside a running mcp-debugger server.

### Child Process Debugging

> One child is adopted today and every further fork is released to run undebugged ([#501](https://github.com/debugmcp/mcp-debugger/issues/501)); the [session tree](../architecture/session-tree.md) design makes each fork a session of its own, from the facts measured in [spike-notes-forks.md](spike-notes-forks.md).
With `autoAttachChildProcesses: true` every Node process the program starts — a `fork()`, a `spawn(process.execPath, …)`, a `cluster` worker, a grandchild — becomes a child debug session of its own (the [session tree](../architecture/session-tree.md), issue [#1082](https://github.com/debugmcp/mcp-debugger/issues/1082), from the facts measured in [spike-notes-forks.md](spike-notes-forks.md)): listed by `list_debug_sessions` with `parentSessionId`, stopping on the breakpoints set on the parent before launch (re-sent to each child before it runs), reported by the parent's `wait_for_stop` with its `sessionId`. Three things js-debug does differently from debugpy, all answered rather than hidden: the whole tree's output arrives on the launch session's buffer and none of it on a child's — a child's `get_output` answers `outputOn: <launch session id>`; `close_debug_session` on a child releases it to run on (`released: true`) because js-debug cannot end a target it adopted; and a child the server does not adopt (beyond `DEBUG_MCP_MAX_CHILD_SESSIONS`, default 32, `0` off) is released by a throwaway attach-then-detach ([#501](https://github.com/debugmcp/mcp-debugger/issues/501)'s lever), since refusing js-debug's report would park it forever. Function breakpoints do not carry into a child (they are armed over CDP on the program's own target); logpoints do, printing into the launch session's output. The program's exit ends every process under it, a child paused on a breakpoint included (measured): the launch owns the tree, so keep the parent alive while you inspect a child.

The adapter can attach to child processes, but `autoAttachChildProcesses` defaults to `false`. To enable automatic child process attachment, pass it explicitly in `dapLaunchArgs`:
For an `attach_to_process` root, the default console capture delivers each child's console
output to that child's own buffer and resource; it does not return an `outputOn` pointer to the
root. A launch child's output resource still includes any diagnostics recorded for that child
alongside the pointer to the launch output.

Fork logpoints use a `console.log` fallback because each fork does not have its own CDP logpoint
bridge. A child that replaces `console.log` can suppress those messages; the program target's
CDP-backed logpoints are unaffected.

The key still defaults to `false` (the default flips in M2's second PR); pass it explicitly in `dapLaunchArgs`:

```javascript
// parent.js
const { spawn } = require('child_process');
const { fork } = require('child_process');

const child = spawn('node', ['child.js']);
// Debugger will only attach to child.js if autoAttachChildProcesses is set to true
const child = fork('child.js');
// child.js becomes a child debug session when autoAttachChildProcesses is true
```

### Conditional Breakpoints
Expand Down
Loading
Loading