diff --git a/.gitignore b/.gitignore index 1e15b8382..3bee4d9fc 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 7f95e643a..5c0c9be3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:`), 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:`), 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: ` (`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 diff --git a/changelog.d/1082.added.md b/changelog.d/1082.added.md new file mode 100644 index 000000000..8983606e4 --- /dev/null +++ b/changelog.d/1082.added.md @@ -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: ` 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). diff --git a/changelog.d/1094.fixed.md b/changelog.d/1094.fixed.md new file mode 100644 index 000000000..ad1534ef4 --- /dev/null +++ b/changelog.d/1094.fixed.md @@ -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. diff --git a/docs/architecture/session-tree.md b/docs/architecture/session-tree.md index ad8a6b155..ffa848d5a 100644 --- a/docs/architecture/session-tree.md +++ b/docs/architecture/session-tree.md @@ -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. @@ -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//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: `); 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. @@ -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 @@ -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 | @@ -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) diff --git a/docs/javascript/README.md b/docs/javascript/README.md index 6a7b21cbc..17acee275 100644 --- a/docs/javascript/README.md +++ b/docs/javascript/README.md @@ -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: `; `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 diff --git a/packages/shared/src/interfaces/adapter-policy-js.ts b/packages/shared/src/interfaces/adapter-policy-js.ts index 4b91a986e..501e56a8b 100644 --- a/packages/shared/src/interfaces/adapter-policy-js.ts +++ b/packages/shared/src/interfaces/adapter-policy-js.ts @@ -20,6 +20,7 @@ import type { StackFrame, Variable } from '../models/index.js'; import { toSourceBreakpoint, type BreakpointFields } from '../utils/to-source-breakpoint.js'; import { compileJsLogpoint, validateJsLogpoint } from '../utils/js-logpoint.js'; import type { DapClientBehavior, DapClientContext, ReverseRequestResult } from './dap-client-behavior.js'; +import type { ChildAdoptionContract, ChildReport } from './child-adoption.js'; /** * Every js-debug scope name this adapter knows, grouped by kind. @@ -119,6 +120,100 @@ function hasNoSource(filePath: string): boolean { return filePath === '' || filePath === ''; } +/** + * The attach a connection sends to adopt one of js-debug's pending targets — + * the program's own (the launch session's single in-session child) and, with + * the session tree, every fork after it (issue #1082). + */ +function buildJsChildStartArgs( + pendingId: string, + parentConfig: Record, + attachArguments?: Readonly> +): { command: 'launch' | 'attach'; args: Record } { + // js-debug's reverse-request values take precedence over parent extras. + // Our request/stopOnEntry intent never enters either adapter argument bag. + const config = { ...attachArguments, ...parentConfig }; + const type = typeof config.type === 'string' ? config.type : 'pwa-node'; + // Carry the parent's forwardable attach extras (localRoot/remoteRoot, + // sourceMaps, skipFiles, …) into the child — source resolution happens + // here, so this is where they take effect (issue #466). The orchestration + // keys stay pinned/excluded: address/port/attachSimplePort would make the + // child a second direct attach to the same inspector (the #124 + // fight-over-the-process failure). These are adapter connection fields, + // not a deny-list of our start-intent fields (issue #730). + const { + name: _name, + __pendingTargetId: _pendingTargetId, + host: _host, + address: _address, + port: _port, + attachSimplePort: _attachSimplePort, + attachExistingChildren: _attachExistingChildren, + continueOnAttach: _continueOnAttach, + type: _type, + ...parentExtras + } = config; + void _name; void _pendingTargetId; void _host; void _address; + void _port; void _attachSimplePort; void _attachExistingChildren; + void _continueOnAttach; void _type; + return { + command: 'attach', + args: { + ...parentExtras, + type, + request: 'attach', + __pendingTargetId: pendingId, + continueOnAttach: true // js-debug requires true to work properly + } + }; +} + +/** + * A js-debug process report (issue #1082): a `startDebugging` whose + * configuration carries a `__pendingTargetId` — the program itself on the + * root connection, a fork or a cluster worker on the program's connection, a + * grandchild on the fork's (docs/javascript/spike-notes-forks.md). The + * request says `request: "launch"` whatever the adoption is; `name` is + * `