Skip to content

session tree M2 (1/2): a js-debug fork is a child session behind an explicit autoAttachChildProcesses (#1082) - #1094

Merged
debugmcpdev merged 6 commits into
mainfrom
feat/1082-m2-js-forks
Nov 6, 2026
Merged

debugmcpdev merged 6 commits into
mainfrom
feat/1082-m2-js-forks

Conversation

@debugmcpdev

@debugmcpdev debugmcpdev commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator

M2 of the session tree (design, #1082), first of two PRs: with autoAttachChildProcesses: true, every Node process js-debug reports after the program's own target becomes a child debug session — a fork(), a spawn(process.execPath, …), a cluster worker, a grandchild — through the mechanism M1 built for debugpy (#1081), instead of being released to run undebugged (#501). The second PR flips the launch and attach defaults, adds the attach/cluster/cascade suites and rewrites the attach smoke and the docs sweep; it closes #1082.

The rule

In a js-debug worker the root connection's first report is the program, adopted in-session by ChildSessionManager as before (the #704 launch-ready signal). Every other report, on any connection, is a child report: held by ChildConnections, adopted as a child session on a fresh connection to the same js-debug server, or released by a throwaway attach. Every non-root connection runs the child-safe policy, so no connection but the root ever builds a manager.

What js-debug's measured shapes change (docs/javascript/spike-notes-forks.md)

  • Where reports arrive. A fork is reported on the program target's connection (the manager's private client), a grandchild on the fork's. The manager relays a held request as childReport, naming the connection it must be answered on; MinimalDapClient hands it to the root's holder (child-report now carries the origin client).
  • The release lever. Every M1 release path answered the request success: false, which parks a js-debug child forever. A js-debug report is now acked the moment it is held (the ack decides nothing) and never answered again; release opens a fresh child-safe connection and runs js attach to a Node process that forks children strands those children (second startDebugging target ignored, child waits forever) #501's sequence (src/proxy/throwaway-release.ts: initialize → configurationDone → attach { __pendingTargetId, continueOnAttach: true } → ready signal → disconnect with the target kept, 20 s cap). A process the released child reports in that window is held on the session the released child was reported on. An adoption that fails lets the child go the same way (the server's release command that follows would find the report already taken).
  • The attach body. The contract gains readChildReport (the design's "report recognition" member) and a root config for buildChildAttachArguments; js-debug's is the measured second-connection body, an attach root's forwardable extras riding along (adapterConfig: unknown keys are accepted silently — a typo of a supported key (pathMapping vs pathMappings) gets no warning #466), a launch root's keys never.
  • Nested managers. ChildConnections.adopt built its clients with the raw policy, which for js-debug would construct a ChildSessionManager + CDP bridge per fork connection; createChildSafePolicy (its own module now) is what every non-root connection runs, and it reads every report through the contract.
  • Output. childOutput: 'launch-session' gets its consumer: a launch child's get_output and output resource answer outputOn: <root id>, preserving any child-local diagnostics. Children under an attach root retain their own console output, measured against js-debug v1.140.0.
  • Close. closeChild: 'release' shapes the close itself: a js child's connection closes with terminateDebuggee: false (either flag leaves the target running, measured) and the answer says released: true, in the root's cascade as well.

Measured in this PR (the fact M2 owed the design doc)

An adopted js-debug child, paused on a breakpoint, does not survive its parent's exit: its terminated arrives the moment the parent exits (no exit code), the child row goes stopped, the root ends with its own exit code. The launch owns the tree, as the spike found for unreported children. Lifecycle row 2 and the Exit section of the design doc now say so; the JavaScript README tells the agent to keep the parent alive while inspecting a child.

Also measured: logpoints set on the parent carry into a fork and print into the launch session's output; function breakpoints do not carry (they are armed over CDP on the program's own target) — documented, a follow-up if wanted.

Release and output hardening

Throwaway releases handle socket errors and closure throughout the handshake, capture ready events before the attach response, and stop sending requests after their deadline. The worker tracks release connections and drains them before root disconnect or shutdown, including reports arriving during release; JavaScript gets 25 seconds of worker-exit grace to cover the 20-second release deadline and cleanup. Regression tests cover these failure and shutdown paths, attach-child output, and output-resource diagnostics. Fork logpoints use a console.log fallback; replacing it in the child can suppress those messages, now documented.

Evidence

  • tests/adapters/javascript/integration/javascript-subprocess-tree.test.ts (seven cases on the shipped server over stdio, real js-debug v1.140.0, runs in build-and-test on both OSes): the designed flow (breakpoint in child.js only; the root's wait answers the fork's three stops with its sessionId and children; outputOn; the root ends 0), the grandchild chain (three rows, the grandchild under the fork, both waits report it), closing a paused fork (released: true, it runs on), restart (childrenClosed: 1) and root close, cap 0 (program adopted, fork released by the throwaway attach, childrenReleased: 1), the parent-exit measurement, the logpoint.
  • Unit: the contract test pins js-debug's contract with per-engine report fixtures and allows the one policy whose program is itself a pending target to combine the contract with in-session adoption; ChildConnections under the throwaway lever (hold acks once, adopt never answers again, release and the safety timer run the throwaway on a child-safe client, a report in the release window, a relayed report answered on its origin, failure and shutdown paths); the relay in the manager and the client; releaseByThrowawayAttach; the session layer's released close and outputOnFor; the get_output pointer.
  • tests/e2e/mcp-server-smoke-javascript-attach.test.ts run locally (it is not in CI): all ten rounds pass, the two js attach to a Node process that forks children strands those children (second startDebugging target ignored, child waits forever) #501 fork rounds included — with the key opted in, forks are now adopted and still complete their handshake under continueOnAttach, and detach_from_process releases them.
  • Local gates on 6b21788d: lint, typecheck:all (zero errors), clean build, personal-path audit, check:docs (150 files), and changelog:check passed. The required pre-push run passed 408 unit files (8,159 tests, 5 skipped) and 31 integration files (149 tests, 1 skipped).

Not in this PR

  • The default flip (autoAttachChildProcesses on launch and attach, the schema text and its 24 snapshots), the attach suite on examples/javascript/fork_attach_target.js, the cluster and cascade suites, the e2e attach smoke expecting adoption, the docs sweep (docs/tool-reference.md, the skill references, README.md's stale "not yet shipped" line) and the changed entry: the second PR.
  • ChildSessionManager.releaseUndebugged is unreachable for js-debug after this and stays as the manager's own invariant; removing it (and its tests) is a small follow-up.

Part of #1005. Design: docs/architecture/session-tree.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_015BDrn6AsHQ4rmTXFHzQkn8

cynarlab and others added 5 commits November 5, 2026 19:15
…hild session (#1082)

M2 of the session tree, first slice: with `autoAttachChildProcesses: true`
every process js-debug reports after the program's own target — a fork, a
spawn of process.execPath, a cluster worker, a grandchild — is adopted
through the M1 mechanism as a child debug session instead of being released
to run undebugged (#501).

The rule: in a js-debug worker the root connection's first report is the
program, adopted in-session by ChildSessionManager as before (the #704
launch-ready signal); every other report, on any connection, is a child
report held by ChildConnections. Every non-root connection runs the
child-safe policy, so no connection but the root ever builds a manager.

- The adoption contract gains `readChildReport` (report recognition) and a
  root config for `buildChildAttachArguments`; js-debug pins
  `throwaway-attach` / `launch-session` / `release` with the measured attach
  body (`{ type, request: 'attach', __pendingTargetId, continueOnAttach }`,
  an attach root's forwardable extras riding along, #466). The js policy's
  `handleReverseRequest` adopts the first pending target in-session and
  hands every later one up as a child report, unanswered.
- `createChildSafePolicy` moves to its own module: for a contract policy it
  reads every report through the contract and never adopts in-session; the
  manager's forward-to-release path goes with it. The program target's
  initialize advertises `supportsStartDebuggingRequest` too.
- A fork is reported on the program target's connection: the manager relays
  the held request as `childReport`, naming that connection, and
  MinimalDapClient hands it to the root's holder (`child-report` now carries
  the origin client, which is where the answer goes).
- The throwaway lever in ChildConnections: a js-debug report is acked the
  moment it is held (measured: the answer decides nothing; refusing parks the
  child forever) and never answered again; release opens a fresh child-safe
  connection and runs the #501 sequence (`src/proxy/throwaway-release.ts`:
  initialize → configurationDone → attach → ready signal → disconnect with
  the target kept, capped 20 s). A process the released child reports in
  that window is held on the session the released child was reported on.
- `childOutput: 'launch-session'` gets its consumer: a js child's
  `get_output` and output resource answer `outputOn: <root id>` with a note,
  since the tree's output arrives on the launch session's connection only.
- `closeChild: 'release'` now shapes the close itself: a js child's
  connection closes with `terminateDebuggee: false` (measured no-op either
  way; the flag matches the answer `released: true`), in the root's cascade
  as well.
- The contract test pins js-debug's contract and allows the one policy whose
  program is itself a pending target to combine it with in-session adoption,
  with per-engine report fixtures.

`ChildSessionManager.releaseUndebugged` is unreachable for js-debug after
this and stays as the manager's own invariant; its removal is a follow-up.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BDrn6AsHQ4rmTXFHzQkn8
…ogpoint against real js-debug (#1082)

The fixture set under tests/fixtures/javascript/subprocess-tree mirrors the
Python one: parent.js forks the child named by argv, child.js ticks three
times over a breakpoint line, child_grand.js spawns grandchild.js
synchronously, parent_exits.js exits with its fork paused, parent_two.js
and cluster_parent.js wait for the second PR's suites.

javascript-subprocess-tree.test.ts, seven cases on the shipped server over
stdio, each naming `autoAttachChildProcesses: true`:

- the designed flow: a breakpoint in child.js only, the root's wait answers
  the fork's three stops with its sessionId and `children`, the fork's own
  output answers `outputOn: <root>`, the root then ends with exit 0 and the
  whole tree's output;
- the grandchild chain: three rows, the grandchild under the fork, the
  root's and the fork's waits both report it;
- closing a paused fork: `released: true`, the fork runs on to its end;
- restart closes the previous run's fork (`childrenClosed: 1`), closing the
  root takes the fork with it;
- cap 0: the program is still adopted, the fork is released by the throwaway
  attach and completes, `childrenReleased: 1`;
- the measurement M2 owed the design doc: an adopted fork paused on a
  breakpoint ends the moment its parent exits (js-debug owns the tree);
- a logpoint set on the parent prints from the fork into the launch
  session's output.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BDrn6AsHQ4rmTXFHzQkn8
…he JavaScript child-process section (#1082)

The design doc's status names M1 complete (#1087, #1090, #1091) and M2's
two PRs; its lifecycle row 2 and the Exit section state what the suite
measured: a js-debug program's exit ends an adopted, paused child at once.
The JavaScript README's child-process section describes the tree (what a
fork answers, `outputOn`, a child's close as a release, the throwaway
release, logpoints yes and function breakpoints no, the parent's exit
ending the tree); CLAUDE.md's proxy paragraph covers the js-debug routing;
the changelog fragment says what shipped and that the default still flips
in the next step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BDrn6AsHQ4rmTXFHzQkn8
…is ignored (#1082)

The fixtures are hand-written ESM debuggees, not TypeScript output; without
the negation the ignore rule for compiled tests makes `git add` skip them
silently, the way PR #252 once lost one (the debug-scripts negation above
it is the precedent). The suite committed before this would have run on CI
against files that were never pushed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BDrn6AsHQ4rmTXFHzQkn8
…d go, and the fixtures hide their windows (#1082)

An adoption failure under the throwaway lever answered nothing (the report
was acked when held) and the server's release command that follows found the
report already taken, so the fork stayed parked forever. ChildConnections
now lets a taken report's child go by the policy's lever in both places:
release() and the adoption's failure path share giveUp(), which refuses a
debugpy report or runs the throwaway attach for a js-debug one.

The fixtures' fork/spawnSync calls pass windowsHide: true (the #843 guard
scans every tracked script under tests/), and parent_exits.js waits 12 s
before exiting so a slow CI runner can adopt the fork and observe its stop
before the parent ends it (the measured row).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BDrn6AsHQ4rmTXFHzQkn8
@codecov

codecov Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.08612% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/proxy/child-connections.ts 96.77% 2 Missing ⚠️
src/proxy/child-safe-policy.ts 95.23% 1 Missing ⚠️
src/session/session-manager-core.ts 92.30% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@debugmcpdev
debugmcpdev merged commit 65b5533 into main Nov 6, 2026
15 checks passed
@debugmcpdev
debugmcpdev deleted the feat/1082-m2-js-forks branch November 6, 2026 05:03
debugmcpdev added a commit that referenced this pull request Nov 6, 2026
…hes (#1096)

* feat(javascript): autoAttachChildProcesses defaults to true on launch and attach — every Node child is a debug session (#1082)

The session tree's M2 second slice: with the mechanism of #1094 in place
(every process js-debug reports after the program's own is adopted as a
child session, or released by a throwaway attach beyond the cap), the
guard that defaulted the key off — #501's park, which could only happen
while a report could be dropped — has nothing left to guard. The launch
transform, the attach transform and the policy's self-contained attach
guard now default `autoAttachChildProcesses` to `true`; an explicit
`false` (dapLaunchArgs on launch, adapterConfig on attach) keeps the
children undebugged as before.

The `attach_to_process` schema text says what the key does now (a child
debug session per fork, the opt-out), the `wait_for_stop` text and the
skill text name JavaScript beside Python as on by default, and the 24
tool-list snapshots move with the two strings. The unit pins of the old
default are inverted, with a launch twin.

Measured before this flip, on the self-debug suite (an mcp-debugger
attached to another): the nested server's forked proxy worker is adopted
as a child session and still acknowledges its init inside the window.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BDrn6AsHQ4rmTXFHzQkn8

* test(javascript): the attach, cascade and cluster suites of the session tree, and the attach smoke expecting adoption (#1082)

`javascript-subprocess-attach.test.ts` (real js-debug over `node --inspect`,
runs in CI on both OSes): a fork started after the attach stops on the
breakpoint set before it and the root ends when the target does; detach
releases the paused fork and keeps both rows; close releases it and
removes them; `adapterConfig: { autoAttachChildProcesses: false }` keeps
the fork undebugged; and #501's wedge guard on `fork_attach_target.js`,
whose forks now become child sessions and keep completing their IPC
handshake. Measured here as #1094's review said: under an attach root a
child's console output is on its own connection — its `get_output` has
`child start` and no `outputOn`.

`javascript-subprocess-cascade.test.ts` (no case names the key: the
default is what it proves): row 1 — a fork that ends while the parent is
paused leaves the parent paused with the fork listed `stopped`; closing
one of two forks releases that fork alone (`released: true`, js-debug
cannot end a target it adopted) and the survivor's stops keep arriving;
a cluster primary's four `cluster.fork()` workers are four child sessions
that each stop on the breakpoint in the worker branch.

The fixture `attach_parent.js` forks `child.js` after one line on stdin,
since js-debug's bootloader reaches only processes started after the
attach. The e2e attach smoke's default round now expects child rows and
flowing handshakes; its opted-in round became the opt-out round.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BDrn6AsHQ4rmTXFHzQkn8

* docs(session-tree): M2 complete — JavaScript child processes debugged by default, the opt-outs, outputOn, a js child's close as a release (#1082)

The design doc's status says M2 shipped in #1094 and this PR, and its M2
paragraph records what PR 2 measured (an attach root's children keep
their own output; cluster workers are reported like any fork). The
JavaScript README's child-process section states the default and the
three opt-outs. The tool reference names JavaScript beside Python
wherever the tree is described (`parentSessionId` with js-debug's
`<script> [<pid>]` name shape, the launch and restart answers, the
attach-root detach), says a JavaScript child's close releases it, and
documents `outputOn` on `get_output`. The skill reference, CLAUDE.md and
the README's design-doc line no longer call the tree Python-only or
unshipped.

Changelog: the `changed` entry for the default; the unreleased `added`
entry of #1094 no longer says the key defaults to false.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BDrn6AsHQ4rmTXFHzQkn8

* docs: the docs index no longer calls the session tree unshipped; two sentence shapes (#1082)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BDrn6AsHQ4rmTXFHzQkn8

* test(javascript): the attach fixture forks with execArgv: [] — a child inheriting --inspect is scanned as a nameless target (#1082)

Windows CI failed two attach cases: the fork was adopted as `Child Process
[<pid>]`, its breakpoint in child.js echoed back unbound, and the parent
ran to its end. The fixture forked with Node's default `execArgv`, so under
`node --inspect=<port>` the child inherited the flag; js-debug's
process-tree scan attaches a watchdog to any child whose arguments carry
an inspector port under that label (`scriptName: "Child Process"`,
`dynamicAttach: true`), and on the slow runner that report won the race
against the bootloader's own. `fork_attach_target.js` has avoided this
with `execArgv: []` since #501; the fixture does the same, and the
JavaScript README names the hazard for anyone attaching to a forking
program started with --inspect.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BDrn6AsHQ4rmTXFHzQkn8

* test(javascript): the tree suites resume js-debug's first-statement pause of an adopted child and wait on (#1082)

The pre-push hook's full integration run failed the cluster case once:
with four workers starting at once, one worker's first wait answered a
stop at the module's first statement (line 9) instead of its breakpoint
(line 29). The proxy log has js-debug's event — `stopped`, reason
`pause`, "Paused on debugger statement", no hitBreakpointIds — its
generic description for a CDP pause with no breakpoint hit, surfaced
under load for a target attached with continueOnAttach; the js policy's
debugger-statement rule then reported it as a breakpoint. The other three
workers and every earlier run stopped on the breakpoint only.

That stop is js-debug's entry pause, not the tree's doing: the shared
child-stop helper of the attach, cascade and tree suites now resumes a
child stop that is not at the expected line and waits again (at most
twice), with the measurement in its comment. The mislabel is filed as a
dogfood ticket.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BDrn6AsHQ4rmTXFHzQkn8

* test(javascript): the attach fixture forks once js-debug's bootloader is in its environment — the injection lands after the attach answers (#1082)

Windows CI, second run: three attach cases ended without a child row and
with the child's breakpoint unbound. The proxy log has the attach
answered at 14.472 s, the program target adopted by 14.573 s, and no
`startDebugging` ever after: the fork ran unreported. js-debug injects
its bootloader into an attached process by evaluating `process.env.
NODE_OPTIONS` and `process.env.VSCODE_INSPECTOR_OPTIONS` in it over the
inspector, with up to 200 retries, after the attach request is answered;
the fixture forked on the harness's `go` written right after that
answer, inside the window on a slow runner.

The fixture's `go` now waits (at most 5 s) for `VSCODE_INSPECTOR_OPTIONS`
to appear before it forks and says which way it went; `now` forks at once
for the opt-out case, where nothing is injected. The designed-flow and
opt-out cases pin those two lines. The JavaScript README names the
window for anyone attaching to a program that forks on a trigger.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015BDrn6AsHQ4rmTXFHzQkn8

* fix(javascript): guard child auto-attach across forwarded targets

---------

Co-authored-by: JF <john.franklin@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

session tree M2: js-debug forks as debug sessions — N targets per tree, #501's release stays the fallback (#1005 part B)

2 participants