Repository navigation
Description
What happens
A Python debuggee that starts another Python process never gets past that spawn: the child is started, nothing more is printed, the session stays running with no stop and no exit, and wait_for_stop answers pending: true forever. Nothing in the answer points at the child.
Measured 2026-10-26 on main b080737b (Windows 11, Python 3.13.12, debugpy 1.8.20 and 1.8.22) with a fixture that runs subprocess.run([sys.executable, "-c", "print('child ok')"], stdin=PIPE, stdout=PIPE) (execnet's shape):
- Default launch: the debuggee prints its first lines, the child appears in the process table with pydevd's bootstrap prepended to its command line, and neither process makes progress. The session's proxy log shows debugpy's
debugpyAttachevent arriving ([MinimalDapClient] Received event: debugpyAttach) and nothing done with it; the child's pydevd log ends at "Connected to" the adapter. - Same launch with
dapLaunchArgs: { subProcess: false }: exit 0,child okand the child's stderr line captured inget_output, on the direct install and in a venv alike. - Same launch with the debuggee under
python.exe(launchpythonpinned): still parks — this is independent of python (Windows): the debuggee runs under pythonw.exe, so a subprocess that inherits stderr fails with WinError 6 (pytest-xdist workers) #993'spythonw.exe. python -m pytest -n 2(pytest-xdist 3.8.0):created: 2/2 workers, then nothing, no exit. WithsubProcess: falsethe workers run (undebugged).
Why
debugpy's sub-process debugging is on when the launch names no subProcess key, which is our launch today. pydevd hooks process creation in the debuggee and, for a Python child, prepends its own bootstrap so the child starts pydevd and connects back to the same adapter. The child then waits: debugpy's adapter comment says subprocesses "will not start running user code until the client tells them to".
For each such child the adapter asks the DAP client to adopt it — a startDebugging reverse request when the client advertised supportsStartDebuggingRequest, otherwise a debugpyAttach event — carrying a ready-made attach configuration (subProcessId, connect to the adapter's own port). The client is expected to open a second DAP session to the adapter, send attach with that subProcessId, set breakpoints and configurationDone. VS Code does this and shows each child as its own debug session. mcp-debugger ignores both forms. debugpy self-unblocks a child only when it could not report it to anyone (adapter/servers.py: "No clients to wait for - unblocking"); ours was reported, so it waits.
Who hits it
Anything that starts Python from Python under the debugger: subprocess.run([sys.executable, …]), multiprocessing with the spawn start method, pytest-xdist workers, Django's runserver autoreloader child, Celery/Airflow-style workers, build and tooling scripts that shell out to Python.
Part A — stop the hang (bug)
- The Python launch transform defaults
subProcess: false: children run undebugged instead of parking; a launch-scoped note says so. - An explicit
subProcess: trueis refused (or forwarded with a warning — decide) with a message that names this issue, since today it can only park the child. - Regression test: the fixture above runs to completion under the debugger (CI-run; add it to the
windows-python-integrationlist as well).
Part B — debug the children too (feature, design carefully)
The value is real: xdist workers, pools, autoreloader children, worker processes are exactly the places an IDE user clicks "attach" for and an agent cannot reach today. Design questions:
- Where a child lives. One MCP debug session is one program today: one thread list, one current stop, one breakpoint set;
continue/step/pauseaim at one process. The shape that matches DAP (and VS Code) is a new MCP session per child —parentSessionId, listed bylist_debug_sessions, breakpoints inherited from the parent at attach time, its own stop/continue, its own output and exit code — with no new tool. The alternative, multiplexing processes into one session, changes what every existing answer means. - The handshake. In the proxy worker: a second DAP client to the same adapter,
initialize→attach { subProcessId, connect }→ breakpoints →configurationDone, answering thestartDebuggingreverse request (advertisesupportsStartDebuggingRequest) and thedebugpyAttachevent.ChildSessionManageris the one-child precedent for js-debug, where the parent is orchestration and the single child is the program; here the parent is a program too, and Node forks have the same gap (js attach to a Node process that forks children strands those children (second startDebugging target ignored, child waits forever) #501 releases a second fork undebugged). One multi-process design should cover both. - Lifecycle. Children that outlive the parent, exit while the parent is paused, or spawn grandchildren;
close_debug_sessionandterminatecascading; the exit-code and output-flush contracts per child; the in-flight guard and stale-session reaper per child session. - Agent UX. How a stop in a child is reported to a caller waiting on the parent (
wait_for_stopon which session?), howlist_debug_sessionsshows the tree, and what the parent's launch answer says when a child is adopted. - Scope. Python (debugpy) and JavaScript (js-debug forks) first; the other adapters' child-process stories are unmeasured.
Refs #993 (the Windows pythonw.exe std-handle hazard, the other reason a debuggee child fails), #985 (the pytest preset keeps -p no:xdist either way), #501 (js-debug's one-child adoption).
Activity
debugmcpdev commentedon Oct 26, 2026
One more measured fact for the design half, so it need not be re-measured:
close_debug_sessionon a session whose child is parked ends the whole tree. Every parked session in today's runs (the fixture's child, and the two xdist workers) closed cleanly, and afterwards nopython.exe/pythonw.exewith debugpy, pydevd, the fixture or pytest on its command line was left in the process table — debugpy's launcher kills its job object on the way out, which takes the parked children with the debuggee. Part A (default off) is PR 2, stacked on #1007.fix(python): run the adapter under the session's interpreter, not pyt…
fix(python): debugpy sub-process debugging off by default — a program…
fix(python): debugpy sub-process debugging off by default — a program…
debugmcpdev commentedon Oct 26, 2026
Part A is merged: #1008 (
55f9a9bd). The Python launch and attach transforms defaultsubProcesstofalse, so a program that starts a Python child runs to completion with the child undebugged; an explicitsubProcess: trueis forwarded with a warning that names this issue.python-subprocess-inherit.test.tsproves the default on both CI platforms. What remains here is Part B, the design: adopting debugpy's children (and js-debug's forks, #501) as child debug sessions.debugmcpdev commentedon Oct 28, 2026
Deferral note for v0.26.0: the hang this issue opened with is fixed — #1008 defaults
subProcessto false, so a Python child runs undebugged instead of parking until a client attaches, and an explicitsubProcess: trueis forwarded with a warning that names the hang. What remains is part B, debugging the children themselves (multi-process design: how a child's debugpy connection becomes a session). That is a feature, not a defect, so it stays open past the release; relabeled accordingly.docs: scrub for v0.26.0 — Flutter ships, Dart in Docker, counts and s…
debugmcpdev commentedon Nov 2, 2026
Direction, decided 2026-11-02 in a backlog review: Part B is a pre-1.0 design task, not a nice-to-have.
The one-session-one-program model is what four open issues each defer on with "decide before the freeze": this one (a session tree for debuggee children), #989 (a browser attach is one target per page/iframe/worker), #985 M2 (jest/vitest must force single-worker because
ChildSessionManageradopts one child) and #987 (which surface a trace rides, and whether a child's hits join the parent's table). The tree —parentSessionId, child rows inlist_debug_sessions, per-childwait_for_stop/output/exit, the cascade onclose_debug_session— is additive to today's answers, so it can be shaped without breaking the shipped contract; but it has to be shaped before the tool-schema freeze (ROADMAP, path to 1.0) so that 1.0 does not pin the single-program model by accident.First deliverable, in the repo's usual shape (COBOL M0, Dart, pytest): spike-notes with the measured handshake — a second DAP client answering debugpy's
startDebuggingreverse request /debugpyAttachevent, and js-debug's second target — and the cascade behaviour (child exits while the parent is paused, parent exits with a child live,close_debug_sessionon either, what each reports; the "close ends the whole tree" fact above is the first row). The design doc underdocs/architecture/with the lifecycle answers listed in the body comes from those measurements, not before them. Until then the shipped policy stays the contract: children run undebugged and the note says so (#1008).debugmcpdev commentedon Nov 3, 2026
Spike done (2026-11-03): the measured handshake and cascade behaviour for both adapters, with a throwaway DAP driver against
python -m debugpy.adapter(1.8.20) and the vendored js-debug v1.140.0 — not the worker. Notes:docs/python/spike-notes-subprocess.mdanddocs/javascript/spike-notes-forks.md(PR #1080). Both are a go; the facts that shape the design:Shared. A child is a second client connection to the same adapter port, driven through today's handshake (
initialize → attach → initialized → setBreakpoints → configurationDone), ~110 ms (debugpy) / ~300 ms (js-debug, bounded by the fixture's first tick) from report to first stop. The report arrives on the connection of the process that spawned the child (parent → child → grandchild), soparentSessionIdfalls out of routing. Breakpoints set on the parent's connection never reach the child in either adapter — the tree must re-send per child. Children end withterminatedand noexited(attach sessions carry no exit code). An acked-but-never-attached report parks the child indefinitely in both — that is today's{}ack and the hang this issue opened with.debugpy. Reports as
startDebuggingonly when the client'sinitializeadvertisessupportsStartDebuggingRequest(the worker does not today); otherwise adebugpyAttachevent with the same body: the parent's launch args minusrequest/args, plusname: "Subprocess <pid>",subProcessId,connect: {host, port}= the adapter's own port.program/cwdare the parent's — the child's script is not in the report. Rejecting the request (success: false) makes the adapter unblock the child itself; it ran undebugged 100 ms later — no throwaway connection needed. Child stdout arrives twice (launcher pipe on the parent, pydevd redirect on the child);redirectOutput: falsein the child's attach leaves only the parent copy.stopOnEntryis a no-op on attach. Cascade:disconnect(terminateDebuggee: true)on the parent ends the tree; on a child, only that child (exit code 0). A paused child outlives a parent that exits — and because it inherited the parent's stdout pipe, the parent'sexited/terminatedare held until the child ends (the launcher waits for the pipe's EOF; the #993 mechanism).multiprocessing.Process(spawn) reports the same way. One hazard: the adapter exits when a session-less client disconnects, so a connect-and-close readiness probe kills the real client's handshake.js-debug. Every process — the program included — is one
startDebugging {request: "launch", configuration: {type, name: "<script> [<pid>]", __pendingTargetId}}; forks andspawn(process.execPath)behave the same. Rejecting a report leaves the child parked forever (and the parent waiting on it): the throwaway attach release (#501) stays the only lever. Output is tree-wide on the root connection only; child sessions carry none. Cascade:disconnect/terminateon the program or a child session never kills anything (and the program session'sterminate/disconnectgo unanswered); only the root'sdisconnect(terminateDebuggee: true)ends the tree (~600 ms, also unanswered — the worker's tree-kill stays). A child never outlives the launch: when the parent exits right after forking, the fork is never reported and its process is gone within 300 ms.Next: the design doc (
docs/architecture/session-tree.md) from these facts — where a child lives, the worker-side connection, lifecycle, agent UX, and the ownership & lifetime section the #1025 decision needs — then M1 (Python children as sessions) and M2 (js-debug multi-target) as issues.docs: session-tree spike notes — debugpy subprocesses and js-debug fo…
debugmcpdev commentedon Nov 3, 2026
Design done (2026-11-03):
docs/architecture/session-tree.mdin #1083, built from the spike notes merged in #1080 (478b6ad). Decisions taken with the project owner today, after walking the practical flow (set a breakpoint in the child's file, start the parent, onewait_for_stop, inspect with the id it returns):wait_for_stopis tree-aware: a wait on a session returns when it or any descendant settles, withsessionIdnaming which; level-triggered as today, with a fixed priority when several are settled (the session if paused, else the first paused live descendant, else the session if ended), andchildrenon every answer of a session that has children. Sessions without children answer as today.subProcessdefault flips in M1 (the fix(python): debugpy sub-process debugging off by default — a program that starts a Python child no longer hangs (#1005) #1008 default existed only because of the park hang, which adoption removes; attach flips once its own fixture is measured), js-debug'sautoAttachChildProcessesflips in M2 for launch and attach.outputOn).startDebuggingis the protocol's one child-report mechanism and only debugpy and js-debug implement it. rdbg's forked child opens its own debug server announced on stderr only (debug gem source; a milestone only after a Linux fork spike); LLDB follows one side of a fork; Delve folds exec'd children into one session (followExec); the JDI bridge, netcoredbg and the Dart adapters have nothing. Every "none" adapter keepsattach_to_processas its route.Mechanics: one worker per tree, N connections to the same adapter port, IPC envelopes demultiplexed by session id into a child proxy view, the adoption contract on
AdapterPolicy('none'stays the default). No new tool, no changed parameter;parentSessionIdand the answer fields are additive.Milestones filed: #1081 (M1, Python children), #1082 (M2, js-debug N targets); M3 (browser targets) waits for the #989 spike. This issue stays open as the umbrella until M1 ships.
docs: the session-tree design — a debuggee's children as debug sessio…
debugmcpdev commentedon Nov 4, 2026
Status 2026-11-04: the design merged in #1083 (
docs/architecture/session-tree.md), with the review's tightening of the wait, closing, cleanup and idle contracts. M1 (#1081) is in progress as three PRs; the first, the adoption mechanism behind an explicitsubProcess: true(a debugpy-reported child becomes a session withparentSessionId, breakpoints re-sent beforeconfigurationDone, closable on its own), is being built now. ThesubProcessdefault staysfalseuntil the tree-awarewait_for_stoplands in PR 2.session tree M1 (1/3): a Python child reported by debugpy becomes a c…
debugmcpdev commentedon Nov 5, 2026
Part B's milestone M1 is complete with #1091 (PR 1 #1087, PR 2 #1090, PR 3 #1091): a Python child reported by debugpy is a child debug session,
wait_for_stopis tree-aware, the cascades answer, and sub-process debugging is on by default — the hang this issue reports cannot occur for Python any more: a reported child is adopted or released, never parked. The by-port attach side was measured in PR 3: the target decides sub-process debugging at listen time (--configure-subProcess), the attach request'ssubProcessis inert, and the children report on the attach connection (docs/python/spike-notes-subprocess.md, the new section). Still open under this issue: M2, js-debug forks as sessions (#1082).debugmcpdev commentedon Nov 6, 2026
Part B's milestone M2 (#1082, js-debug) is complete with PR 1 #1094 (merged) and PR 2 #1096 (open, CI running): every Node process a JavaScript launch or attach starts — a
fork(), aspawn(process.execPath, …), aclusterworker, a grandchild — is a child debug session, on by default (autoAttachChildProcesses: true; an explicitfalseopts a launch or attach out,DEBUG_MCP_MAX_CHILD_SESSIONS=0the server). A report js-debug makes is adopted or released by a throwaway attach, never parked — the #501 wedge that this issue's hang reduced to cannot occur any more for JavaScript either. Measured on the way: an adopted child paused on a breakpoint ends with its parent's exit (the launch owns the tree), an attach root's children keep their own console output, cluster workers are reported like any fork.With M1 (#1081, Python) complete since #1091, part B is done for both adapters the DAP
startDebuggingmechanism exists in. What remains under this issue is documented indocs/architecture/session-tree.md: the other adapters have no report mechanism (rdbg re-binds its port on fork, #1085 is the spike for that; LLDB and Delve follow one side of a fork; the JDI bridge, netcoredbg and the Dart adapters report nothing), so the tree ends where the adapters' reports end.