Skip to content

session tree M1: Python children as debug sessions — debugpy startDebugging adoption, parentSessionId, tree-aware wait_for_stop (#1005 part B) #1081

Closed

Description

@debugmcpdev

Milestone 1 of the session tree (design, merged in #1083; issue #1005 part B): a Python child process reported by debugpy becomes a debug session of its own, linked to the session that spawned it.

Body updated 2026-11-04 to the merged design (the #1083 review tightened the wait, closing, cleanup and idle contracts after this issue was filed); the PR sequence below is how it ships.

What ships

  • Adoption. The worker's initialize advertises supportsStartDebuggingRequest: true for Python, so debugpy reports each child as a startDebugging reverse request (the debugpyAttach event form is the same payload and is recognised too). On a report the server creates a child session (parentSessionId, the parent's language, the report's name — Subprocess <pid>), opens a second connection to the same adapter port, runs the child handshake in debugpy's order (initialize → attach → initialized → setBreakpoints → configurationDone) with the parent's current breakpoint set re-sent before configurationDone, and releases the park. Measured ~110 ms from report to first stop (spike notes).
  • Rows. list_debug_sessions rows gain parentSessionId on child rows; DebugSessionInfo grows the same field. No new tool, no changed parameter.
  • Tree-aware wait_for_stop. A wait on a session observes it and all its descendants and returns a pause or error from that subtree, or the session's own end, with sessionId naming the session selected (equal to the id asked for when it is the session itself) and the ordinary stop answer for it. Level-triggered as today (nothing is consumed) with a fixed priority when several are settled: the session itself if paused/error, else the first paused/error live descendant in adoption order, else the session itself if ended, else keep waiting — an ended descendant alone never completes an ancestor's wait. Every answer on a session with children carries children: [{ sessionId, name, state }]; the start_debugging answer carries the same list when a child was adopted during the launch hold. Sessions without children answer as today plus sessionId.
  • Output. The launch session's buffer is the tree's output (already true). The child's buffer carries its own lines (redirectOutput: true on the child's attach).
  • Exit. Children end with terminated and no exitCode (attach sessions). A paused child that outlives its parent stays debuggable and holds the parent's exited (the python (Windows): the debuggee runs under pythonw.exe, so a subprocess that inherits stderr fails with WinError 6 (pytest-xdist workers) #993 pipe effect); the parent row says running and its wait reports the child.
  • Closing. close_debug_session on any session closes its subtree, descendants first (deepest first), then the named session; closing a child leaves its ancestors and siblings open. Closing a launch root ends the process tree. Explicitly closing a child uses debugpy's measured semantics: the child is terminated (exit 0 reported to its parent) and the answer says processEnded: true. Closing a non-root subtree never shuts down the shared worker or adapter. Closing or idle-reaping an attach root preserves the external targets: every descendant is detached deepest-first with disconnect(terminateDebuggee: false), then the root; rows removed; released: true.
  • Connection-scoped cleanup. Today handleTerminated/handleExited/handleError call proxyManager.stop(), which tears the worker down. A child's proxy view stop() closes only that child's connection and listeners; a root's natural end never reaps a worker that still serves live descendants (the reap waits for the last one); explicit root close still cascades.
  • Default on. The Python launch transform stops defaulting subProcess to false (fix(python): debugpy sub-process debugging off by default — a program that starts a Python child no longer hangs (#1005) #1008's default existed only because of the park hang, which adoption removes); the warning naming the hang goes; an explicit subProcess: false opts out per launch. A changed changelog entry documents the behaviour change (child rows appear for xdist workers, autoreloaders, scripts that shell out).
  • Bounds. DEBUG_MCP_MAX_CHILD_SESSIONS (default 32, 0 disables adoption): beyond it the release lever runs (answer the request success: false; debugpy unblocks the child itself) and the parent's next answer says childrenReleased: n. Ended children stay listed until closed and leave with their parent.
  • Detach and restart. detach_from_process on an attach root with children detaches the children first (deepest first, disconnect(terminateDebuggee: false); a debugpy child resumes undebugged, rows go stopped with released: true), then the parent. restart_debugging closes and removes the previous run's children and answers childrenClosed: n. Neither marks children error.
  • Attach root. The Python attach transform's subProcess default flips only once the M1 attach fixture (a by-port attach to a parent that spawns a child) shows the same report on the same connection; otherwise attach keeps subProcess: false and its warning and a follow-up measures it.
  • Refusals. start_debugging, restart_debugging, attach_to_process, detach_from_process, expose_session and unexpose_session on a child id are refused by name (the mirror follows the root).
  • Idle. The design's per-debug-session idle policy (MCP_DEBUG_SESSION_IDLE_MS: activity refreshes a session and its ancestors; an in-flight tree wait protects the subtree it observes) is the 2026-07-28-era replacement for the HTTP stale reaper and ships with the PR that introduces that knob (Move to the v2 TypeScript SDK, then to protocol 2026-07-28 — two steps, v0.27 or later #1025 step 2), together with its tree tests. Until then the existing reaper's cascade (server stop → close all sessions, trees included) is unchanged.

How it ships (three PRs)

  1. PR 1 — the mechanism, opt-in. The adoption contract on AdapterPolicy with the Python pins; the worker advertises and keeps N connections; events and responses tagged by connection; ProxyManager demultiplexes into a child proxy view with connection-scoped stop(); parentSessionId on rows; adoption with the parent's breakpoints; the cap with the reject lever; the close cascade; the refusals; the child's own output buffer; a real-debugpy integration test. Observable only under an explicit subProcess: true (today: the child parks forever). The subProcess default stays false.
  2. PR 2 — the designed flow. Tree-aware wait_for_stop with sessionId and children; start_debugging carrying children; the detach and restart cascade answers (released, childrenClosed); processEnded / released / childrenReleased.
  3. PR 3 — on by default. The launch subProcess default flips (changed entry, the fix(python): debugpy sub-process debugging off by default — a program that starts a Python child no longer hangs (#1005) #1008 warning goes); the attach fixture measured and the attach default flipped if it reports the same way; xdist, grandchild and cascade-row suites; the reference docs; the KNOWN_ISSUES entry dropped; the skill content.

Where (seams named in the design)

  • packages/shared/src/interfaces/adapter-policy*.ts: the adoption contract (report recognition, advertise, attach arguments, release lever, output attribution, end semantics) with the Python pins; 'none' stays the default; tests/unit/shared/adapter-policy-contract.test.ts pins the new members.
  • src/proxy/dap-proxy-connection-manager.ts (initialize), src/proxy/minimal-dap.ts (reverse requests held instead of acked; events tagged with their connection's session id instead of re-emitted as the parent's), src/proxy/dap-proxy-worker.ts (a sessionId → MinimalDapClient map, request routing, closing every connection on shutdown, no worker shutdown while children live).
  • src/proxy/proxy-manager.ts: demultiplex IPC envelopes by sessionId (the field exists and is ignored today) into a child proxy view implementing IProxyManager over the parent's channel.
  • packages/shared/src/models/index.ts and src/session/session-store.ts (parentSessionId), src/session/session-manager-core.ts (adoption on child-reported, the deferred worker reap, the cascade on close), src/session/execution/execution-controller.ts (the tree-aware wait; keep the wait_for_stop: block until a session's next stop or end instead of polling list_debug_sessions #849 microtask re-check), src/server/handlers/session-tools.ts and execution-tools.ts (rows and answer fields), packages/adapter-python/src/python-debug-adapter.ts (the default).
  • Docs: docs/tool-reference.md (list_debug_sessions, wait_for_stop, close_debug_session, get_output), docs/python/README.md, docs/KNOWN_ISSUES.md (drop the "children run without the debugger" entry), the skill content.

Done when

  • tests/fixtures/python/subprocess-inherit and the tree fixtures (subprocess.Popen, multiprocessing spawn, a grandchild) run as a CI suite: a breakpoint set before launch in the child's file stops the child; wait_for_stop on the parent returns with the child's sessionId; list_debug_sessions shows the row with parentSessionId; the cascade rows of the design's lifecycle table each have a test, including closing a child with grandchildren without orphaning their sessions and a child's termination without stopping the shared worker or its siblings; the attach fixture shows that root close and root detach release the whole subtree without killing external targets (detach keeps the stopped rows, close removes them); python -m pytest -n 2 (pytest-xdist) yields two child sessions and completes.
  • The conformance lane and http-spec-contract unchanged; the full suite green; node scripts/check-docs.mjs clean.

Part of #1005. Design: docs/architecture/session-tree.md. Measured facts: docs/python/spike-notes-subprocess.md. Related: #993 (the pipe effect), #985 (pytest preset keeps -p no:xdist until this lands), #987 (a child's logpoint hits are the child's), #1025 (step 2 carries the idle policy).

Activity

  1. debugmcpdev commented on Nov 4, 2026

    @debugmcpdev
    CollaboratorAuthor

    Body updated to the design as merged in #1083. The review there tightened four contracts after this issue was filed, and they are now in the body: (1) wait_for_stop returns a pause or error from the subtree or the session's own end, so an ended descendant alone never completes an ancestor's wait; (2) close_debug_session on any session closes its subtree, and closing a non-root subtree never shuts down the shared worker, while closing or idle-reaping an attach root detaches every descendant with terminateDebuggee: false; (3) terminal cleanup is connection-scoped: a child view's stop() closes only its connection, and a root's natural end never reaps a worker with live descendants; (4) the idle policy's tree rules (activity refreshes ancestors, an in-flight tree wait protects its subtree) ship with the PR that introduces MCP_DEBUG_SESSION_IDLE_MS (#1025 step 2), not here.

    The body also records how M1 ships: PR 1 is the mechanism behind an explicit subProcess: true (in progress), PR 2 the tree-aware wait and the cascade answers, PR 3 the default flip and the suites.

  2. debugmcpdev commented on Nov 4, 2026

    @debugmcpdev
    CollaboratorAuthor

    PR 1 of 3 is open: #1087 — the adoption mechanism behind an explicit subProcess: true: a debugpy-reported child becomes a session with parentSessionId, stops on the parent's breakpoints re-sent before configurationDone, has its own output and close_debug_session; closing the parent closes its children first; the launch-shaped tools and the mirror refuse a child id; the per-tree cap DEBUG_MCP_MAX_CHILD_SESSIONS (default 32, 0 off). Proven against real debugpy in tests/adapters/python/integration/python-subprocess-tree.test.ts. The subProcess default stays false. Next: PR 2, the tree-aware wait_for_stop with sessionId and children plus the detach and restart answer fields; PR 3, the default flip, the attach fixture and the xdist/grandchild suites.

  3. debugmcpdev commented on Nov 4, 2026

    @debugmcpdev
    CollaboratorAuthor

    PR 2 of 3 is open: #1090 — the designed flow. wait_for_stop is tree-aware (a wait on a session observes it and every descendant; sessionId names the session the answer is about, children lists the tree, an ended child alone never completes the parent's wait), the launch and restart answers carry children/childrenReleased/childrenClosed, close_debug_session answers childrenClosed/processEnded/released, and detach_from_process on an attach root releases the children first (childrenReleased). Proven against real debugpy: a breakpoint in child.py only, the parent's wait answers the child's stop three times with the child's sessionId, then the parent's end. The subProcess default still flips in PR 3, with the attach fixture and the xdist/grandchild suites. Found on the way: #1089 (a Windows flake in bridge-tree-kill.test.ts, untouched code).

  4. added a commit that references this issue on Nov 5, 2026
    28b326a
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions