diff --git a/CLAUDE.md b/CLAUDE.md index 321d5efd4..44efe65de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -248,7 +248,7 @@ The codebase follows a **layered architecture with dependency injection** and ** - Collaborators see the facade through `OperationsContext` (`operations-context.ts`), whose members are late-bound arrows and getters — never captured references — so reassigning a facade method or writing a tunable on a live instance is visible to them; each takes the narrowest `Pick<>` slice it uses. Collaborator-to-collaborator dependencies are constructor arguments (`DebugLauncher(ctx, proxyLauncher, breakpoints, inFlight)`, `AttachController(ctx, proxyLauncher, breakpoints, pauseCoordinator, inFlight)`) held as captured instance references: their methods resolve at call time, so `vi.spyOn(internals(ops).proxyLauncher, 'start')` intercepts, but reassigning a collaborator field after construction is not observed. The facade owns one shared `InFlightGuard` (`in-flight-guard.ts`, issue #711) that the launcher and the attach controller both take: `startDebugging`, `restartDebugging`, `attachToProcess` and `detachFromProcess` each claim the session before their first await and release it in `finally`, so one launch-shaped call per session at a time; `restartDebugging` replays through the launcher's private `launch()` under its own claim, never through the facade's `startDebugging`. Shared per-request DAP helpers live in `dap-request-helpers.ts` - Coordinates ProxyManager instances (one per session) - Handles breakpoint management and queuing - - The session tree (issue #1081, `docs/architecture/session-tree.md`): a child the adapter reports (debugpy under `subProcess: true`) is adopted by `SessionManagerCore.adoptReportedChild` as a session of its own — `parentSessionId`, the parent's language and breakpoints, a child proxy view over the parent's worker wired with the same handlers — under the per-tree cap of `child-session-cap.ts` (`DEBUG_MCP_MAX_CHILD_SESSIONS`, default 32, `0` off; beyond it the child is released); `closeSession` closes a subtree deepest first, a root that ends with live children defers its worker's reap until the last ends (`stopOrDeferWorker`), and the launch-shaped tools and the mirror refuse a child id by name (`handlers/shared.ts` `childSessionRefusal`) + - The session tree (issue #1081, `docs/architecture/session-tree.md`): a child the adapter reports (debugpy under `subProcess: true`) is adopted by `SessionManagerCore.adoptReportedChild` as a session of its own — `parentSessionId`, the parent's language and breakpoints, a child proxy view over the parent's worker wired with the same handlers — under the per-tree cap of `child-session-cap.ts` (`DEBUG_MCP_MAX_CHILD_SESSIONS`, default 32, `0` off; beyond it the child is released); `closeSession` closes a subtree deepest first, a root that ends with live children defers its worker's reap until the last ends (`stopOrDeferWorker`), and the launch-shaped tools and the mirror refuse a child id by name (`handlers/shared.ts` `childSessionRefusal`). The tree-aware wait: `ExecutionController.waitForStop` runs on `waitForSubtreeSettle` (`execution/session-state-wait.ts`, the `waitForSettle` primitive shared with `waitForSessionState`) over the core's subtree listeners (`onSubtreeStateChange`, fired for a session and each ancestor on every transition and removal) and `selectSettledInTree` (`execution/tree-settle.ts`, the four-rule read: the session itself when paused or errored, else the first paused or errored descendant depth first in adoption order, else the session itself when ended, else keep waiting; `describeTreeAnswer` builds `children`/`childrenReleased`). The answer fields: `sessionId` (the selected session; set by the wait handler in `handlers/execution-tools.ts`), `children`/`childrenReleased`/`childrenClosed` (hoisted beside `pending` by `handlers/shared.ts` `hoistTreeFields` on the wait, launch, restart and detach answers) and `processEnded`/`released` (spread by the `close_debug_session` handler from the `closeSessionReporting` outcome — the close outcome it answers from, `childrenClosed` included); `releaseDescendants` is the detach cascade (children detached deepest first with `terminateDebuggee: false`, their rows kept as `stopped` with `programEnd.data.released`, then the parent) 4. **ProxyManager** (`src/proxy/proxy-manager.ts`) - Manages communication with debug proxy process diff --git a/changelog.d/1081-tree-wait.added.md b/changelog.d/1081-tree-wait.added.md new file mode 100644 index 000000000..e52c1adb0 --- /dev/null +++ b/changelog.d/1081-tree-wait.added.md @@ -0,0 +1 @@ +**`wait_for_stop` is tree-aware — set a breakpoint in the child's code, start the parent, wait** — a wait on a session now observes it and every child session its program spawned (Python `subProcess: true`) and answers for the session a fixed priority names: the session itself when it is paused, else the first paused descendant, else the session itself when it has ended; `sessionId` names the session the answer is about (inspect, step and continue with it), `children` lists every descendant with its state, and an ended child alone never completes the parent's wait. The `start_debugging` and `restart_debugging` answers carry the same `children`, plus `childrenReleased` (children run undebugged beyond `DEBUG_MCP_MAX_CHILD_SESSIONS`) and `childrenClosed` (a relaunch closes the previous run's children; a Flutter hot restart keeps them); `detach_from_process` on an attach root detaches the children first and leaves them running (`childrenReleased`, or `childrenClosed` with `terminateProcess: true`); `close_debug_session` says `childrenClosed` when it took descendants with it, `processEnded: true` for a debugpy child it ended, or `released: true` for an attach root whose targets run on. The Python `subProcess` default still flips in the last step (#1005, #1081). diff --git a/changelog.d/1081.added.md b/changelog.d/1081.added.md index a983bf090..4bb579e17 100644 --- a/changelog.d/1081.added.md +++ b/changelog.d/1081.added.md @@ -1 +1 @@ -**Python child processes become child debug sessions under `subProcess: true`** — a Python launch with `dapLaunchArgs.subProcess: true` used to park every child the program spawned until a debugger attached, which the server never did; debugpy's report is now adopted: each child 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), with its own `get_output`, `wait_for_stop` and `close_debug_session`; closing the parent closes its children first; `DEBUG_MCP_MAX_CHILD_SESSIONS` caps a tree's live children (default 32, `0` turns adoption off; a child beyond it runs undebugged). The default stays off until the tree-aware `wait_for_stop` lands (#1005, #1081). +**Python child processes become child debug sessions under `subProcess: true`** — a Python launch with `dapLaunchArgs.subProcess: true` used to park every child the program spawned until a debugger attached, which the server never did; debugpy's report is now adopted: each child 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), with its own `get_output`, `wait_for_stop` and `close_debug_session`; closing the parent closes its children first; `DEBUG_MCP_MAX_CHILD_SESSIONS` caps a tree's live children (default 32, `0` turns adoption off; a child beyond it runs undebugged). The default stays off until the last step of #1081 flips it (#1005, #1081). diff --git a/changelog.d/1090-review.fixed.md b/changelog.d/1090-review.fixed.md new file mode 100644 index 000000000..3582c0e20 --- /dev/null +++ b/changelog.d/1090-review.fixed.md @@ -0,0 +1 @@ +Session-tree teardown now ends child processes before the parent when `detach_from_process` requests termination, prevents new child adoption throughout detach, and clears output subscriptions whenever a session is removed, including during restart. Tree-aware waits reselect when a chosen child resumes or is closed while its answer is being read, preserving the original timeout. Closing a previously released child no longer claims its process was terminated (#1081, #1090). diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index 124e2d6d8..4f5eb9945 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -93,11 +93,11 @@ undebugged: a breakpoint in code that only a child executes does not hit. With a `dapLaunchArgs: { subProcess: true }` on a launch, each Python child the program starts (`subprocess`, `multiprocessing`, pytest-xdist workers) becomes a child debug session: `list_debug_sessions` lists it with `parentSessionId`, it stops on the parent's breakpoints (re-sent -to it before it runs), and it has its own `get_output`, `wait_for_stop` and `close_debug_session`; -closing the parent closes its children first. Today a child is discovered through -`list_debug_sessions` (its row carries `parentSessionId`) and waited on with its own id. The default -stays `false` until the tree-aware `wait_for_stop` lands (#1081); the design is -[the session tree](architecture/session-tree.md). +to it before it runs), and it has its own `get_output` and `close_debug_session`; closing the parent +closes its children first. A `wait_for_stop` on the parent reports a child's stop with `sessionId` +naming the child and `children` listing the tree with each session's state; a child can still be +waited on by its own id. The default stays `false` until the last step of #1081 flips it; the design +is [the session tree](architecture/session-tree.md). ## Dart/Flutter: what the SDK debug adapter does differently diff --git a/docs/architecture/session-tree.md b/docs/architecture/session-tree.md index 64d803f6f..cac3bc1e6 100644 --- a/docs/architecture/session-tree.md +++ b/docs/architecture/session-tree.md @@ -10,10 +10,11 @@ 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 in progress: M1 PR 1 -(adoption behind an explicit `subProcess: true`) under [#1081](https://github.com/debugmcp/mcp-debugger/issues/1081), -then the tree-aware wait, then the default flip; M2 is [#1082](https://github.com/debugmcp/mcp-debugger/issues/1082). -Nothing here is shipped yet. +[#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 progress; then the default flip. M2 is +[#1082](https://github.com/debugmcp/mcp-debugger/issues/1082). Nothing is on by default yet. 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. @@ -112,7 +113,7 @@ exists). The agent then uses `sessionId` for inspection, stepping and continuing paused reports the child (rule 2 before rule 3), because nothing can be done on the parent. The microtask re-check that hides an auto-continued entry stop ([#849](https://github.com/debugmcp/mcp-debugger/issues/849)) is unchanged. -- Every answer on a session that has children carries `children: [{ sessionId, name, state }]`, +- Every answer on a session that has children carries `children: [{ sessionId, name, state, parentSessionId }]`, the paused and the `pending: true` answers included, so a caller always sees what else is live or paused and what to wait on next. The `start_debugging` answer carries the same list when a child was adopted during the launch hold. diff --git a/docs/python/README.md b/docs/python/README.md index 55ffaa00f..044aca421 100644 --- a/docs/python/README.md +++ b/docs/python/README.md @@ -270,4 +270,4 @@ Variable values, captured output, and evaluate results are scanned for credentia 3. Use source context to see code around your current position 4. The stack trace shows the call hierarchy that led to the current position 5. Expressions are evaluated against the current paused debug context (current frame when available), with the evaluation context defaulting to `'variables'` — reads and arithmetic are reliable, but verify a mutation such as `x = 5` with a follow-up evaluate before relying on it -6. Child processes run undebugged by default: debugpy's sub-process debugging is sent as `subProcess: false` until the tree-aware `wait_for_stop` lands ([#1081](https://github.com/debugmcp/mcp-debugger/issues/1081)). With `dapLaunchArgs: { subProcess: true }`, every Python child the program starts (`subprocess`, `multiprocessing`, pytest-xdist workers) becomes a child debug session of its own: `list_debug_sessions` lists it with `parentSessionId` naming this session, it stops on the breakpoints set on this session before launch (re-sent to it before it runs), and it has its own `get_output`, `wait_for_stop` and `close_debug_session`; closing the parent closes its children first. Today a child is discovered through `list_debug_sessions` (its row carries `parentSessionId`) and waited on with its own id. The design is [the session tree](../architecture/session-tree.md), built on the handshake measured in [spike-notes-subprocess.md](spike-notes-subprocess.md) +6. Child processes run undebugged by default: debugpy's sub-process debugging is sent as `subProcess: false` until the default flips in the last step of [#1081](https://github.com/debugmcp/mcp-debugger/issues/1081). With `dapLaunchArgs: { subProcess: true }`, every Python child the program starts (`subprocess`, `multiprocessing`, pytest-xdist workers) becomes a child debug session of its own: `list_debug_sessions` lists it with `parentSessionId` naming this session, it stops on the breakpoints set on this session before launch (re-sent to it before it runs), and it has its own `get_output` and `close_debug_session`; closing the parent closes its children first. A `wait_for_stop` on this session is a wait on the tree: it reports a child's stop with `sessionId` naming the child (inspect, step and continue with that id) and `children` listing every child session with its state; a child can still be waited on by its own id. The design is [the session tree](../architecture/session-tree.md), built on the handshake measured in [spike-notes-subprocess.md](spike-notes-subprocess.md) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index da606bfa8..fd77f19fb 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -131,7 +131,7 @@ Lists all active debugging sessions — in Streamable HTTP mode, the ones this M Errored sessions include optional `diagnostics` with the current launch attempt's server-host `proxyLogPath` and remote-safe `proxyLogResource`. The record is retained for proxy initialization failures and for proxy/adapter deaths after initialization, and is cleared when a new launch or attach attempt begins. -**`parentSessionId`:** present on child rows only. A child session is created by the server, never by `create_debug_session`, when the adapter reports a process the debuggee started: today a Python launch with `dapLaunchArgs.subProcess: true`, where debugpy reports each child (see [the session tree](./architecture/session-tree.md)). The child inherits the parent's `language`, its `name` is the adapter's own label for the process (debugpy: `Subprocess `), it has its own breakpoints, `wait_for_stop`, `get_output` and `close_debug_session`, and it carries no `exitCode` (it ends with the adapter's `terminated` event). `start_debugging`, `restart_debugging`, `attach_to_process`, `detach_from_process`, `expose_session` and `unexpose_session` refuse a child id by name. Closing the parent closes its children first. `DEBUG_MCP_MAX_CHILD_SESSIONS` caps the live children of one tree (default 32; `0` turns adoption off): a child reported beyond the cap is released and runs undebugged. +**`parentSessionId`:** present on child rows only. A child session is created by the server, never by `create_debug_session`, when the adapter reports a process the debuggee started: today a Python launch with `dapLaunchArgs.subProcess: true`, where debugpy reports each child (see [the session tree](./architecture/session-tree.md)). The child inherits the parent's `language`, its `name` is the adapter's own label for the process (debugpy: `Subprocess `), it has its own breakpoints, `get_output` and `close_debug_session`, and it carries no `exitCode` (it ends with the adapter's `terminated` event). The parent's [`wait_for_stop`](#wait_for_stop) reports the child's stop with `sessionId` naming the child and `children` listing the tree; a child can still be waited on by its own id (that wait answers for the child and its own descendants only). `start_debugging`, `restart_debugging`, `attach_to_process`, `detach_from_process`, `expose_session` and `unexpose_session` refuse a child id by name. Closing the parent closes its children first (`childrenClosed` in the answer). `DEBUG_MCP_MAX_CHILD_SESSIONS` caps the live children of one tree (default 32; `0` turns adoption off): a child reported beyond the cap is released and runs undebugged, and the parent's next `wait_for_stop`, `start_debugging` or `restart_debugging` answer counts the run's released children in `childrenReleased`. --- @@ -150,6 +150,21 @@ Closes an active debugging session. } ``` +A session with child sessions, a child session or an attach root says what the close took with it +(the session tree, issue #1081): +```json +{ "success": true, "message": "Closed debug session: c7e2a9b4-5d1f-4e8a-9b3c-2f6d8a1e0b47", "processEnded": true } +``` + +- `childrenClosed: n` when the close took descendants with it: descendants are closed first, + deepest first, then the named session. Closing a child leaves its ancestors, its siblings and the + debug adapter they share alone. +- `processEnded: true` when the named session is a child whose process the close ended (debugpy: a + child's close terminates it, exit 0 reported to its parent). +- `released: true` when the named session is an attach root: its descendants are detached with + their targets kept running, deepest first, their rows removed, then the root is detached. (Later + also a child of an adapter that can only release the child it adopted.) + **Notes:** - Sessions may close automatically on errors - Closing a non-existent session returns `success: false` (`message: "Failed to close debug session: "`); in Streamable HTTP mode a session another MCP client created answers `error: "Session belongs to another MCP client session: …"` instead, and stays open (issue #939) @@ -497,6 +512,8 @@ Two things are outside the hold. A `stopOnEntry` launch is answered at its entry A breakpoint set, removed or cleared while a launch is still starting is delivered to the adapter as soon as the program is launched (issue #851). +**Sessions with children** (the session tree, issue #1081): when a child session was adopted during the hold (a Python launch with `dapLaunchArgs.subProcess: true`, where debugpy reports each process the program starts), the answer carries `children: [{ sessionId, name, state, parentSessionId }]` — every descendant, depth first in adoption order, each with its state and the session that spawned it. `childrenReleased: n` says how many children reported this run were released to run undebugged (beyond `DEBUG_MCP_MAX_CHILD_SESSIONS`, or an adoption that failed), and `childrenClosed: n` that the start tore down a previous live run and closed its children with it. All three sit at the top level beside `pending` and in `data`, and are absent when there is nothing to say, so a session with no children answers as before. The launch answer is about the launched session itself; a child's stop is collected by [`wait_for_stop`](#wait_for_stop), which answers with `sessionId` naming the child. + **JavaScript launch configuration:** Both launch option objects accept js-debug settings; `adapterLaunchConfig` wins when a key appears in both. The effective `stopOnEntry` value controls the entry pause and is replayed by `restart_debugging`. An honoured `noDebug` disables that pause. @@ -574,6 +591,7 @@ Restarts the debuggee in one call. When the adapter offers a native restart (DAP - The launch configuration is replayed verbatim (script, args, `dapLaunchArgs`, `adapterLaunchConfig` — the `testRunner` preset rides in it — `breakOnExceptions`); there are no per-restart overrides — call `start_debugging` for a different configuration. - **The output buffer starts fresh on a relaunch**: `outputReset: true` signals that `get_output` cursors from the previous launch are stale — read from `since: 0`. A hot restart keeps the buffer (same process): cursors stay valid. - The hold and its `pending: true` answer are the same as `start_debugging`'s ("When the launch answers" above): a relaunch that has not stopped when the hold elapses answers `running`, and `wait_for_stop` collects its stop. A relaunch that stopped carries the same [stop snapshot](#the-stop-snapshot) (issue #982). +- **Children belong to a run** (the session tree, issue #1081): a relaunch closes and removes the previous run's child sessions before it starts and says so with `childrenClosed: n`; a child adopted during the new run's hold is listed in `children`, and children released to run undebugged this run are counted in `childrenReleased`, as for `start_debugging` (top level beside `pending`, and in `data`). A hot restart keeps the process and its children: no `childrenClosed`, the children listed. - Not available for **attach sessions** unless the adapter offers a native restart (a Flutter attach session hot-restarts like a launched one; any other attach session has no launch configuration to replay — detach and re-attach instead), nor for sessions that were never launched, including dry-run-only sessions — call `start_debugging` on the same session instead. --- @@ -736,6 +754,7 @@ Blocks until the session next pauses or ends, then says why and where. This is t { "success": true, "state": "paused", + "sessionId": "a4d1acc8-84a8-44fe-a13e-28628c5b33c7", "message": "Paused (breakpoint) at /abs/path/app.py:42", "lastStop": { "reason": "breakpoint", "threadId": 1, "timestamp": 1791213582583 }, "location": { "file": "/abs/path/app.py", "line": 42, "column": 1 }, @@ -759,6 +778,7 @@ Blocks until the session next pauses or ends, then says why and where. This is t { "success": true, "state": "stopped", + "sessionId": "a4d1acc8-84a8-44fe-a13e-28628c5b33c7", "message": "The program exited with code 3.", "exitCode": 3 } @@ -776,6 +796,7 @@ message adds "without hitting any breakpoint. Verified but never hit: app.py:3." "success": true, "state": "running", "pending": true, + "sessionId": "a4d1acc8-84a8-44fe-a13e-28628c5b33c7", "message": "The program is still running after 30s without reaching 1 breakpoint(s). Nothing was cancelled: what is armed stays armed, and the session becomes 'paused' when the program gets there. Call wait_for_stop again to keep waiting, or pause_execution to interrupt it." } ``` @@ -789,6 +810,68 @@ message adds "without hitting any breakpoint. Verified but never hit: app.py:3." - A session that was never started answers `success: false` (`Nothing to wait for: this session has not been started…`). So does a session closed while the call was waiting, and a `timeout` that is not a positive number. - Several calls may wait on one session at once. A call whose request is cancelled, or whose client disconnects, releases its wait. +**Sessions with children** (the session tree, issue #1081): a wait on a session observes it and +every descendant — present, or adopted while the wait runs — and answers for the one session a +fixed priority names: + +1. the session itself, if it is paused or in error; +2. else the first paused or errored descendant, depth first in adoption order (a child, then its + own descendants, then the next child); +3. else the session itself, if it has ended; +4. else it keeps waiting — an ended descendant alone never completes an ancestor's wait. + +The wait stays level-triggered: nothing is consumed, asking twice answers the same stop twice, two +concurrent waiters agree, and the entry stop the server auto-continues is still never reported. One +consequence: an errored descendant keeps being selected on every later wait until it is closed — +close it to wait past it. + +- `sessionId` is on every answer: the selected session's id, equal to the id asked for when the + session itself is selected and on the pending answer. Everything else in the answer (`lastStop`, + `location`, `context`, `snapshot`, `threadId`, `note`, `exitCode`, the `programEnd` data) is the + ordinary stop answer **for that session**; use `sessionId` for inspection, stepping and + continuing. An errored descendant answers `state: "error"` with its id and the reason in + `message` (`Child session ended in an error state…`). +- `children: [{ sessionId, name, state, parentSessionId }]` whenever the asked session has + descendants: every descendant, depth first in adoption order, each with its state and the session + that spawned it — the pending answer included. +- `childrenReleased: n` when children reported on that session's connection during this run were + released to run undebugged (beyond `DEBUG_MCP_MAX_CHILD_SESSIONS`, or an adoption that failed): a + running total for the run, reset by a relaunch, never consumed. +- A session with no children answers byte-for-byte as before plus `sessionId`. A wait on a child + answers for the child and its own descendants only. + +A wait on the parent answering a child's stop (`sessionId` is the child's): +```json +{ + "success": true, + "state": "paused", + "sessionId": "c7e2a9b4-5d1f-4e8a-9b3c-2f6d8a1e0b47", + "message": "Paused (breakpoint) at /abs/path/child.py:12", + "lastStop": { "reason": "breakpoint", "threadId": 1, "timestamp": 1791213590211 }, + "location": { "file": "/abs/path/child.py", "line": 12, "column": 1 }, + "context": { + "lineContent": " result = work(item)", + "surrounding": [ + { "line": 11, "content": "def run(item):" }, + { "line": 12, "content": " result = work(item)" }, + { "line": 13, "content": " return result" } + ] + }, + "snapshot": { + "frames": [ { "name": "run", "file": "/abs/path/child.py", "line": 12 } ], + "locals": { "item": "3" } + }, + "children": [ + { "sessionId": "c7e2a9b4-5d1f-4e8a-9b3c-2f6d8a1e0b47", "name": "Subprocess 48213", "state": "paused", "parentSessionId": "a4d1acc8-84a8-44fe-a13e-28628c5b33c7" } + ] +} +``` + +The pending answer lists the tree too, with `sessionId` naming the session asked for: +```json +{ "success": true, "state": "running", "pending": true, "sessionId": "a4d1acc8-84a8-44fe-a13e-28628c5b33c7", "message": "The program is still running after 30s …", "children": [ { "sessionId": "c7e2a9b4-5d1f-4e8a-9b3c-2f6d8a1e0b47", "name": "Subprocess 48213", "state": "running", "parentSessionId": "a4d1acc8-84a8-44fe-a13e-28628c5b33c7" } ] } +``` + --- ### pause_execution @@ -1510,6 +1593,7 @@ Detaches the debugger from an attached process, leaving the process running. - Detaching sends DAP `disconnect` with `terminateDebuggee: false`, then stops the proxy; the session ends in `"stopped"` either way, so a later re-attach needs a new session. - CodeLLDB sessions on Windows (C/C++ and COBOL attach sessions, and any C/C++, Rust or COBOL launch detached this way): while the target is stopped, a DAP `continue` goes out ahead of the detaching `disconnect` — on this path, on `close_debug_session` and on shutdown alike — and `data.resumedBeforeDetach` is `true`; if the debugger refuses the continue, the detach still proceeds with `resumedBeforeDetach: false` and `data.warning` names the risk. Why: CodeLLDB's Windows detach otherwise kills a target whose last resume was a step (issue #763; the mechanism is in the C/C++ guide's attach section). - Detaching a session with no active debug process reports `success: false` with `No active debug session to detach from` in `message`. +- **An attach root with child sessions** (the session tree, issue #1081): the children are detached first, deepest first, each with `disconnect` and `terminateDebuggee: false` so it runs on undebugged, then the parent. The child rows stay listed as `stopped` with `programEnd: { note: "Released: detached with its parent; the process runs on undebugged.", data: { released: true } }`, and the answer carries `childrenReleased: n` — n children now run undebugged (top level and in `data`). With `terminateProcess: true` the whole subtree is closed and the answer carries `childrenClosed: n` instead. --- diff --git a/packages/adapter-python/src/python-debug-adapter.ts b/packages/adapter-python/src/python-debug-adapter.ts index 6886a95ee..d866a5a65 100644 --- a/packages/adapter-python/src/python-debug-adapter.ts +++ b/packages/adapter-python/src/python-debug-adapter.ts @@ -447,15 +447,16 @@ export class PythonDebugAdapter extends EventEmitter implements IDebugAdapter { // debugpy reports every Python child the program starts when this is on // (its default when the key is absent), and the server adopts each // report as a child debug session that stops on this session's - // breakpoints (the session tree, issue #1081). Off by default until the - // tree-aware wait_for_stop lands, so children run undebugged; a - // caller's explicit value is forwarded. + // breakpoints, reported by this session's wait_for_stop (the session + // tree, issue #1081). Off by default until the last step of #1081 + // flips it, so children run undebugged; a caller's explicit value is + // forwarded. subProcess: requestedSubProcess ?? false }; if (requestedSubProcess === true) { this.launchDiagnostics.push({ key: 'subProcess', - message: "forwarded — each Python child this program starts becomes a child debug session, listed by list_debug_sessions with parentSessionId and stopping on this session's breakpoints; the default stays off until the tree-aware wait_for_stop lands (issue #1081)" + message: "forwarded — each Python child this program starts becomes a child debug session, listed by list_debug_sessions with parentSessionId and stopping on this session's breakpoints; wait_for_stop on this session reports the child's stop with sessionId naming it; the default stays off until the last step of issue #1081 flips it" }); } diff --git a/skills/debugging/SKILL.md b/skills/debugging/SKILL.md index 13c8ff0f0..8d6766af4 100644 --- a/skills/debugging/SKILL.md +++ b/skills/debugging/SKILL.md @@ -45,7 +45,7 @@ Rules that prevent 90% of failed sessions: - **Absolute paths only** for `file` and `scriptPath` (relative paths are rejected in host mode). - **Use real frame IDs.** Take `id` from `get_stack_trace` frames; it is adapter-assigned and is not 0-indexed. - **Expand variable containers.** If a variable entry carries a `variablesReference`, call `get_variables` again with that reference to see children (Python's "special variables", object fields, array elements). -- **Respect session state.** Stepping, evaluation, and variable reads require `PAUSED`. After `continue_execution` the session is `RUNNING`; after a step or breakpoint hit it returns to `PAUSED` with a persisted stop reason telling you why it stopped (`breakpoint`, `step`, `entry`, `exception`, ...). `continue_execution` returns at once — call `wait_for_stop {sessionId}` to block until the program stops again or ends; it answers with the stop (`lastStop`, `location`) or the exit code. +- **Respect session state.** Stepping, evaluation, and variable reads require `PAUSED`. After `continue_execution` the session is `RUNNING`; after a step or breakpoint hit it returns to `PAUSED` with a persisted stop reason telling you why it stopped (`breakpoint`, `step`, `entry`, `exception`, ...). `continue_execution` returns at once — call `wait_for_stop {sessionId}` to block until the program stops again or ends; it answers with the stop (`lastStop`, `location`) or the exit code. A session whose program spawned child sessions (Python `subProcess: true`) is waited on as a tree: the answer's `sessionId` names the session that stopped — use it to inspect — and `children` lists the rest with their states. - **A `start_debugging` that answers `running` with `pending: true` is not stuck.** The launch holds its answer only briefly — about a second — for the program's first stop, so a breakpoint reached as the program starts is answered in the one call, and anything later is collected with `wait_for_stop {sessionId}`, which blocks until the program stops or ends. Nothing was cancelled: breakpoints stay armed for as long as the program runs, and the message says what is armed. For a server, this is the workflow: set the breakpoint in the handler, `start_debugging` (answers `pending`), send the request, `wait_for_stop`. The same goes for any step, pause or attach that answers `pending: true`. `wait_for_stop` takes a `timeout` in ms (default 30000); when it runs out it answers `pending: true` again — just call it again. Keep each call under your MCP client's own request cap (45 s is safe under the common 60 s cap): a longer wait is several calls, not one long `timeout`, or the client times out first. - **Breakpoints may verify late.** Some adapters (debugpy, JDI) report breakpoints unverified until the module/class loads; that is normal, not an error. - **`` placeholders are masking, not program state.** Credential-shaped values and values of sensitive variable names (`password`, `api_key`, ...) are masked by default in variable/evaluate/output results; a `redaction` field reports what was hidden. The real value is intact in the debuggee — don't "fix" it, and don't retry the read. The user can disable masking by restarting the server with `DEBUG_MCP_NO_REDACT=1`. diff --git a/skills/debugging/references/python.md b/skills/debugging/references/python.md index a35461703..3e6b24910 100644 --- a/skills/debugging/references/python.md +++ b/skills/debugging/references/python.md @@ -66,7 +66,7 @@ Shorthand: `create_debug_session { "language": "python", "host": "127.0.0.1", "p - **evaluate_expression** runs in debugpy's `variables` (watch-style) context by default. Reads and arithmetic are reliable; whether mutations like `x = 5` take effect depends on debugpy — verify with a follow-up evaluate before relying on one. Collections are truncated at 300 items. - **Output capture works for Python** (`redirectOutput`): `print()` and stderr land in `get_output` entries, readable during and after the run until the session closes. - Logpoints work: `set_breakpoint` with `logMessage: "x={x}"` logs interpolated values to `get_output` without pausing. -- **Child processes run undebugged by default.** debugpy's sub-process debugging is sent off until the tree-aware `wait_for_stop` lands (#1081). `dapLaunchArgs: { subProcess: true }` makes each Python child the program starts a child debug session: `list_debug_sessions` lists it with `parentSessionId`, it stops on this session's breakpoints, and it is waited on with its own id. +- **Child processes run undebugged by default.** debugpy's sub-process debugging is sent off until the default flips in the last step of #1081. `dapLaunchArgs: { subProcess: true }` makes each Python child the program starts a child debug session: `list_debug_sessions` lists it with `parentSessionId`, it stops on this session's breakpoints, and `wait_for_stop` on this session reports the child's stop with `sessionId` naming the child (inspect and step with that id) and `children` listing the tree; a child can still be waited on by its own id. ## Troubleshooting diff --git a/src/proxy/child-proxy-view.ts b/src/proxy/child-proxy-view.ts index 0668f65dd..0e031a09a 100644 --- a/src/proxy/child-proxy-view.ts +++ b/src/proxy/child-proxy-view.ts @@ -26,8 +26,16 @@ export interface ChildAdoptResult { capabilities?: DebugProtocol.Capabilities; } +/** How a child's connection is closed: ending the child (the default) or letting it run on. */ +export interface ChildStopOptions { + /** `false` releases the child to run undebugged (a detach cascade, issue #1081); default `true`. */ + terminateDebuggee?: boolean; +} + export interface IChildProxyView extends IProxyManager { readonly childSessionId: string; + /** Connection-scoped: close this child's connection, ending the child unless told to release it. */ + stop(options?: ChildStopOptions): Promise; /** * Adopt the held report as this view's connection: the worker opens a * connection to the adapter, runs the child handshake with the given @@ -169,13 +177,15 @@ export class ChildProxyView extends EventEmitter implements IChildProxyView { /** * Connection-scoped: close this child's connection (debugpy ends the child, - * the measured semantics of a child's close) and forget the view. The - * worker, the adapter and the root's own state are untouched. + * the measured semantics of a child's close — or releases it to run on when + * `terminateDebuggee` is false, the detach cascade) and forget the view. + * The worker, the adapter and the root's own state are untouched. */ - async stop(): Promise { + async stop(options: ChildStopOptions = {}): Promise { if (this.stopped) { return; } + const terminateDebuggee = options.terminateDebuggee ?? true; this.stopped = true; const needsClose = this.connectionOpen || this.adoptionStarted; this.connectionOpen = false; @@ -183,7 +193,7 @@ export class ChildProxyView extends EventEmitter implements IChildProxyView { // also cancels a handshake still in flight on the same IPC channel. if (needsClose && !this.exitEmitted && this.root.isRunning()) { try { - await this.root.sendDapRequestAs(this.childSessionId, CHILD_CLOSE_COMMAND, { terminateDebuggee: true }, { timeoutMs: 5000 }); + await this.root.sendDapRequestAs(this.childSessionId, CHILD_CLOSE_COMMAND, { terminateDebuggee }, { timeoutMs: 5000 }); } catch (error) { this.logger.warn( `[ChildProxyView ${this.childSessionId}] close failed (best effort): ${error instanceof Error ? error.message : String(error)}` @@ -325,6 +335,11 @@ export class ChildProxyView extends EventEmitter implements IChildProxyView { // own methods, structurally, exactly as on the root manager. } +/** Whether a session's proxy is a child's view over its root's worker (issue #1081). */ +export function isChildProxyView(proxyManager: IProxyManager): proxyManager is IChildProxyView { + return typeof (proxyManager as Partial).childSessionId === 'string'; +} + /** Fails to compile unless T is assignable to U. */ type AssertAssignable = T; /** A view is a HandshakeProxy too, like the root manager (see proxy-manager.ts). */ diff --git a/src/server.ts b/src/server.ts index 5ab973479..21697d7ca 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,7 +15,7 @@ import { ProxyNotRunningError } from './errors/debug-errors.js'; import { SessionManager, SessionManagerConfig } from './session/session-manager.js'; -import type { DebugResult, LaunchResultData, StepResultData, WaitForStopResultData } from './session/session-manager-core.js'; +import type { CloseSessionOutcome, DebugResult, LaunchResultData, StepResultData, WaitForStopResultData } from './session/session-manager-core.js'; import { FrameSummary, StackTraceResult } from './session/session-manager-data.js'; import { VariableTruncationSummary } from './session/variable-caps.js'; import { createProductionDependencies } from './container/dependencies.js'; @@ -315,8 +315,8 @@ export class DebugMcpServer implements ToolContext { return this.sessionManager.restartDebugging(sessionId); } - public async closeDebugSession(sessionId: string): Promise { - return this.sessionManager.closeSession(sessionId); + public async closeDebugSession(sessionId: string): Promise { + return this.sessionManager.closeSessionReporting(sessionId); } /** @@ -782,6 +782,9 @@ export class DebugMcpServer implements ToolContext { // is a new output resource: subscribed clients learn of it as they do of // a session created or closed through the tools. this.sessionManager.on('child-session-adopted', this.handleChildSessionAdopted); + // Removals also happen during relaunch and terminating detach, outside + // close_debug_session. Clean up each resource at the removal itself. + this.sessionManager.on('session-removed', this.handleSessionRemoved); this.server.onerror = (error) => { this.logger.error('Server error', { error }); }; @@ -792,6 +795,11 @@ export class DebugMcpServer implements ToolContext { this.outputResources.notifyListChanged(); }; + private readonly handleSessionRemoved = (sessionId: string): void => { + this.outputResources.forgetSession(sessionId); + this.outputResources.notifyListChanged(); + }; + /** * Get session name for logging * @internal ToolContext service. @@ -831,6 +839,7 @@ export class DebugMcpServer implements ToolContext { this.outputResources.dispose(); this.sessionManager.removeListener('output-captured', this.outputResources.handleOutputCaptured); this.sessionManager.removeListener('child-session-adopted', this.handleChildSessionAdopted); + this.sessionManager.removeListener('session-removed', this.handleSessionRemoved); this.logger.info('Debug MCP Server stopped'); // Last: detach this server's logger from the shared file transport so // per-session servers in HTTP mode don't accumulate on it (issue #404). diff --git a/src/server/handlers/debuggee-tools.ts b/src/server/handlers/debuggee-tools.ts index ed658e3c1..6d6f3d7ec 100644 --- a/src/server/handlers/debuggee-tools.ts +++ b/src/server/handlers/debuggee-tools.ts @@ -4,7 +4,7 @@ */ import type { ToolHandler } from '../tool-context.js'; import type { HotReloadArgs } from '@debugmcp/shared'; -import { attachRequestFromArgs, childSessionRefusal, hoistStopLanding, successWarning } from './shared.js'; +import { attachRequestFromArgs, childSessionRefusal, hoistStopLanding, hoistTreeFields, successWarning } from './shared.js'; import { assertPlainObjectArg, foldTestRunnerPreset, @@ -67,6 +67,9 @@ export const startDebuggingTool: ToolHandler = async (ctx, args) => { // than the source (issue #950). Nothing for a launch that answered // running or that ended: the session layer gave those no location. await hoistStopLanding(ctx, responsePayload, debugResult.data, sessionId, 'launch result'); + // The session tree (issue #1081): children adopted during the hold, what + // a relaunch closed, what ran undebugged. + hoistTreeFields(responsePayload, debugResult.data); return jsonResult(responsePayload); } catch (error) { return sessionErrorResultOrThrow(error, { state: 'stopped' }); @@ -108,6 +111,7 @@ export const restartDebuggingTool: ToolHandler = async (ctx, args) => { } // And the same landing when it stopped (issue #951). await hoistStopLanding(ctx, responsePayload, debugResult.data, args.sessionId, 'restart result'); + hoistTreeFields(responsePayload, debugResult.data); } return jsonResult(responsePayload); } catch (error) { @@ -202,6 +206,8 @@ export const detachFromProcessTool: ToolHandler = async (ctx, args) => { if (detachResult.data) { responsePayload.data = detachResult.data; + // The children a detach released, or a terminating detach closed (issue #1081). + hoistTreeFields(responsePayload, detachResult.data); } return jsonResult(responsePayload); diff --git a/src/server/handlers/execution-tools.ts b/src/server/handlers/execution-tools.ts index 27583b37e..6e3f94910 100644 --- a/src/server/handlers/execution-tools.ts +++ b/src/server/handlers/execution-tools.ts @@ -7,7 +7,7 @@ import { isTerminalSessionState } from '@debugmcp/shared'; import type { ToolContext, ToolHandler } from '../tool-context.js'; import type { DebugResult, StepResultData } from '../../session/session-manager-core.js'; import { requireSessionId } from '../tool-validation.js'; -import { hoistStopLanding } from './shared.js'; +import { hoistStopLanding, hoistTreeFields } from './shared.js'; import { failureResult, jsonResult, @@ -146,6 +146,11 @@ export const waitForStopTool: ToolHandler = async (ctx, args, _toolName, extra) if (data?.message) { response.message = data.message; } + // The session this answer is about (issue #1081): the one asked for, or + // the descendant the tree-aware wait selected — inspect with this id. + if (data?.sessionId) { + response.sessionId = data.sessionId; + } if (data?.lastStop) { response.lastStop = data.lastStop; } @@ -157,8 +162,10 @@ export const waitForStopTool: ToolHandler = async (ctx, args, _toolName, extra) if (Array.isArray(data?.unhitBreakpoints) && data.unhitBreakpoints.length > 0) { response.unhitBreakpoints = data.unhitBreakpoints; } - // The landing (issues #904, #938), as for pause_execution and the step tools. - await hoistStopLanding(ctx, response, data, args.sessionId, 'wait_for_stop result'); + // The landing (issues #904, #938), as for pause_execution and the step + // tools — read for the selected session, whose stop this is. + await hoistStopLanding(ctx, response, data, data?.sessionId ?? args.sessionId, 'wait_for_stop result'); + hoistTreeFields(response, data); // What the adapter read into the program's end (issue #877: e.g. availableDevices) // lands beside the message under the policy's own keys, after everything the answer // says for itself: a key the answer already has — the landing's included — wins, the diff --git a/src/server/handlers/session-tools.ts b/src/server/handlers/session-tools.ts index 88c7658b5..c39dcd277 100644 --- a/src/server/handlers/session-tools.ts +++ b/src/server/handlers/session-tools.ts @@ -221,20 +221,25 @@ export const closeDebugSessionTool: ToolHandler = async (ctx, args) => { requireSessionId(args); const sessionName = ctx.getSessionName(args.sessionId); - const closed = await ctx.closeDebugSession(args.sessionId); + const outcome = await ctx.closeDebugSession(args.sessionId); - if (closed) { + if (outcome.closed) { // Log session closure ctx.logger.info('session:closed', { sessionId: args.sessionId, sessionName: sessionName, timestamp: Date.now() }); - - // The session's output resource is gone (issue #218) - ctx.outputResources.forgetSession(args.sessionId); - ctx.outputResources.notifyListChanged(); } - return jsonResult({ success: closed, message: closed ? `Closed debug session: ${args.sessionId}` : `Failed to close debug session: ${args.sessionId}` }); + // The session tree (issue #1081): what the close took with it, and what + // it did to the processes — a child's ended (debugpy) or released; an + // attach root's descendants released with their targets, like its own. + return jsonResult({ + success: outcome.closed, + message: outcome.closed ? `Closed debug session: ${args.sessionId}` : `Failed to close debug session: ${args.sessionId}`, + ...(outcome.childrenClosed ? { childrenClosed: outcome.childrenClosed } : {}), + ...(outcome.processEnded ? { processEnded: true } : {}), + ...(outcome.released ? { released: true } : {}) + }); }; diff --git a/src/server/handlers/shared.ts b/src/server/handlers/shared.ts index eb7456f4e..1b2187d58 100644 --- a/src/server/handlers/shared.ts +++ b/src/server/handlers/shared.ts @@ -21,7 +21,7 @@ import type { ToolContext } from '../tool-context.js'; import type { ToolArguments } from '../tool-arguments.js'; import { validateBreakOnExceptions } from '../tool-validation.js'; import { debuggerOffWhy } from '../../session/debugger-off.js'; -import type { DebugResult, StopAnswerFields } from '../../session/session-manager-core.js'; +import type { DebugResult, StopAnswerFields, TreeAnswerFields } from '../../session/session-manager-core.js'; import type { AttachRequest } from '../../session/attach/attach-controller.js'; import type { ManagedSession } from '../../session/session-store.js'; import { ErrorMessages } from '../../utils/error-messages.js'; @@ -186,6 +186,28 @@ export function hasOpenableSourcePath(file: string): boolean { return !file.startsWith('<') && path.isAbsolute(file); } +/** + * Hoist the session tree's answer fields (issue #1081) beside `pending`: + * `children` (every descendant with its state), `childrenClosed` (what a + * relaunch or a close took with it) and `childrenReleased` (children that run + * undebugged this run) — copied whenever the session layer set them, so a + * session with no tree keeps exactly the fields it had. + */ +export function hoistTreeFields(response: Record, data: TreeAnswerFields | undefined): void { + if (!data) { + return; + } + if (data.children) { + response.children = data.children; + } + if (typeof data.childrenClosed === 'number') { + response.childrenClosed = data.childrenClosed; + } + if (typeof data.childrenReleased === 'number') { + response.childrenReleased = data.childrenReleased; + } +} + /** * Hoist where a stop landed to the top level of a tool answer and annotate * it — the one shape every stop answer has: the step tools, pause_execution, diff --git a/src/server/tool-context.ts b/src/server/tool-context.ts index 757bb17c4..4e0084af0 100644 --- a/src/server/tool-context.ts +++ b/src/server/tool-context.ts @@ -24,7 +24,7 @@ import type { Variable } from '@debugmcp/shared'; import type { SessionManager } from '../session/session-manager.js'; -import type { DebugResult, LaunchResultData, StepResultData, WaitForStopResultData } from '../session/session-manager-core.js'; +import type { CloseSessionOutcome, DebugResult, LaunchResultData, StepResultData, WaitForStopResultData } from '../session/session-manager-core.js'; import type { FrameSummary, StackTraceResult } from '../session/session-manager-data.js'; import type { VariableTruncationSummary } from '../session/variable-caps.js'; import type { SimpleFileChecker, FileExistenceResult } from '../utils/simple-file-checker.js'; @@ -97,7 +97,8 @@ export interface ToolContext { breakOnExceptions?: ExceptionBreakMode ): Promise>; restartDebugging(sessionId: string): Promise>; - closeDebugSession(sessionId: string): Promise; + /** Close the session and its subtree; the outcome says what went with it (issue #1081). */ + closeDebugSession(sessionId: string): Promise; setFunctionBreakpoint( sessionId: string, functionName: string, diff --git a/src/server/tool-schemas.ts b/src/server/tool-schemas.ts index cb4797eef..0246aab90 100644 --- a/src/server/tool-schemas.ts +++ b/src/server/tool-schemas.ts @@ -221,7 +221,7 @@ export function buildToolDefinitions(options: BuildToolDefinitionsOptions): Tool { name: 'step_into', description: 'Step into the current call. Waits briefly for the program to stop; if the step is still executing after ~5s, returns success with state "running" and pending:true — the session becomes "paused" when the step completes (call wait_for_stop to block until then)', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] } }, { name: 'step_out', description: 'Step out of the current function. Waits briefly for the program to stop; if the step is still executing after ~5s (e.g. the rest of the function is long-running), returns success with state "running" and pending:true — the session becomes "paused" when the step completes (call wait_for_stop to block until then)', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] } }, { name: 'continue_execution', description: 'Continue execution. Returns immediately after the adapter acknowledges; does not wait for the next stop. Call wait_for_stop to block until the program stops again or ends — it reports why (lastStop, e.g. "breakpoint" vs "exception") and where; list_debug_sessions and get_stack_trace show the same without waiting. Use get_output to read the program\'s stdout/stderr', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] } }, - { name: 'wait_for_stop', description: 'Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state "paused" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state "running" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, timeout: { type: 'number', description: 'Max time (ms) to wait for the stop (default: 30000, max: 600000). Not a limit on the program — it keeps running under the debugger either way. Note: your MCP client may enforce its own overall request timeout; keep this below it and call again on pending:true' } }, required: ['sessionId'] } }, + { name: 'wait_for_stop', description: 'Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state "paused" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state "running" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent\'s wait', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, timeout: { type: 'number', description: 'Max time (ms) to wait for the stop (default: 30000, max: 600000). Not a limit on the program — it keeps running under the debugger either way. Note: your MCP client may enforce its own overall request timeout; keep this below it and call again on pending:true' } }, required: ['sessionId'] } }, { name: 'pause_execution', description: 'Pause a running program. Waits briefly for the stop; if the program cannot stop within ~5s (e.g. blocked in native code, or an idle server waiting for input), returns success with pending:true and the session reports "paused" the next time the program runs code (call wait_for_stop to block until then). An observed stop answers with stopReason, the first visible frame as location (with its source context), and note when the stop was reported on a thread with no program frame — a sibling thread in program code is shown instead, or, when none has one, the note says no program frame is active. Fails with an actionable error if the session has no debuggable target to pause (e.g. a js attach whose target session was never adopted or has ended)', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, threadId: { type: 'number', description: 'Thread ID to pause. If omitted or 0, pauses all threads.' } }, required: ['sessionId'] } }, { name: 'list_threads', description: 'List all threads in the debugged process', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' } }, required: ['sessionId'] } }, { name: 'get_variables', description: 'Get variables (scope is variablesReference: number). Responses are size-guarded: oversized values are cut (truncated:true) and very large scopes return a capped list with a truncation notice — pass names:[...] to fetch specific variables in full', inputSchema: { type: 'object', properties: { sessionId: { type: 'string' }, scope: { type: 'number', description: "The variablesReference number from a StackFrame or Variable" }, names: namesProp }, required: getVariablesRequired } }, diff --git a/src/session/attach/attach-controller.ts b/src/session/attach/attach-controller.ts index a0554d897..46a9e48fb 100644 --- a/src/session/attach/attach-controller.ts +++ b/src/session/attach/attach-controller.ts @@ -24,7 +24,7 @@ import type { AttachLocality } from '@debugmcp/shared'; import { attachLocalityOf, classifyAttachAddressing } from './attach-addressing.js'; import { ErrorMessages } from '../../utils/error-messages.js'; import { DapResponseError } from '../../proxy/dap-response-error.js'; -import type { AttachResultData, CustomLaunchRequestArguments, DebugResult } from '../session-manager-core.js'; +import type { AttachResultData, CustomLaunchRequestArguments, DebugResult, DetachResultData } from '../session-manager-core.js'; import type { AttachContext } from '../operations-context.js'; import type { BreakpointController } from '../breakpoints/breakpoint-controller.js'; import { failProxySetup, sessionRemovedDuringTeardown } from '../launch/proxy-failure-diagnostics.js'; @@ -589,7 +589,7 @@ export class AttachController { async detachFromProcess( sessionId: string, terminateProcess: boolean = false - ): Promise { + ): Promise> { return this.inFlight.run(sessionId, 'detach', 'detach_from_process', this.ctx, () => this.detach(sessionId, terminateProcess) ); @@ -599,7 +599,7 @@ export class AttachController { private async detach( sessionId: string, terminateProcess: boolean - ): Promise { + ): Promise> { const session = this.ctx.getSession(sessionId); this.ctx.logger.info( `[SessionManager] Detaching from process for session ${sessionId}, terminateProcess: ${terminateProcess}` @@ -615,23 +615,21 @@ export class AttachController { let resumedBeforeDetach: boolean | undefined; let warning: string | undefined; + // The session tree (issue #1081): a terminating detach closes the + // subtree; a plain one releases the children first, deepest first, so + // each runs on undebugged, then detaches the parent. + let childrenClosed = 0; + let childrenReleased = 0; + const finishClose = this.ctx.beginSubtreeClose(sessionId); try { if (terminateProcess) { - // Terminate the process: say so to the adapter first. closeSession's - // own teardown detaches an attach target with terminateDebuggee=false - // (and, since issue #763, resumes it first), which would leave running - // the process this result reports as terminated. - try { - await session.proxyManager.sendDapRequest('disconnect', { - terminateDebuggee: true - }); - } catch (disconnectError) { - this.ctx.logger.warn(`[SessionManager] Terminating disconnect failed, closing the session anyway:`, disconnectError); - } - await this.ctx.closeSession(sessionId); + // End children before disconnecting the root, and override an + // attach root's usual keep-the-target-running close semantics. + childrenClosed = (await this.ctx.closeSessionReporting(sessionId, { terminateDebuggee: true })).childrenClosed ?? 0; } else { const proxyManager = session.proxyManager; + childrenReleased = await this.ctx.releaseDescendants(session); // Disconnect without terminating - send DAP disconnect request. In // attach mode the worker resumes a stopped target ahead of it where @@ -689,7 +687,9 @@ export class AttachController { ? 'Detached and terminated process' : 'Detached from process (process still running)', ...(resumedBeforeDetach !== undefined ? { resumedBeforeDetach } : {}), - ...(warning !== undefined ? { warning } : {}) + ...(warning !== undefined ? { warning } : {}), + ...(childrenClosed > 0 ? { childrenClosed } : {}), + ...(childrenReleased > 0 ? { childrenReleased } : {}) } }; } catch (error) { @@ -701,6 +701,8 @@ export class AttachController { state: session.state, error: `Failed to detach: ${message}` }; + } finally { + finishClose(); } } } diff --git a/src/session/execution/execution-controller.ts b/src/session/execution/execution-controller.ts index 9ebd090dc..25b612bcb 100644 --- a/src/session/execution/execution-controller.ts +++ b/src/session/execution/execution-controller.ts @@ -13,7 +13,7 @@ * * `waitForStop` (issue #849) is the explicit continuation of that `pending` * answer, and the one operation here that sends nothing: it waits on the - * session's state through `waitForSessionState` for as long as its caller + * state of the session and its descendants through `waitForSubtreeSettle` for as long as its caller * says. It registers no event listeners, so rule 1 below has nothing to * arbitrate for it; it does obey rule 2. * @@ -42,7 +42,6 @@ import { getErrorMessage } from '../../errors/debug-errors.js'; import { isSessionTerminated, - isSettledSessionState, isTerminalSessionState, NO_DEBUG_TARGET_MARKER, redactStopInfo, @@ -68,7 +67,8 @@ import type { ExecutionContext } from '../operations-context.js'; import { describeStopLocation, landingFields, type StopLanding } from '../inspection/stop-location.js'; import { composeStopSnapshot } from '../inspection/stop-snapshot.js'; import type { PauseCoordinator } from './pause-coordinator.js'; -import { waitForSessionState } from './session-state-wait.js'; +import { waitForSubtreeSettle } from './session-state-wait.js'; +import { describeTreeAnswer } from './tree-settle.js'; import { resolveDapTimeoutOverride } from '../dap-request-helpers.js'; import { buildRunToCompletionSummary, describeProgramEnd } from '../breakpoints/launch-warnings.js'; import { describeLaunchArming } from '../launch/launch-arming.js'; @@ -771,6 +771,14 @@ export class ExecutionController { * pause, once the stack has been read): a session that is already paused or * already over answers at once; a wait that ran out, or whose caller went * away, answers `pending: true` with the session still live. + * + * Tree-aware (the session tree, issue #1081): the wait observes the session + * and all its descendants, present or adopted meanwhile, and answers for + * the one `selectSettledInTree` names — the session itself when paused or + * in error, else the first paused or errored descendant, else the session + * itself when ended; only descendants ending never completes it. The + * answer's `sessionId` names that session, and everything else in it is + * that session's ordinary stop answer; `children` lists the descendants. */ async waitForStop( sessionId: string, @@ -788,102 +796,144 @@ export class ExecutionController { } const waitMs = timeoutCheck.timeoutMs ?? this.ctx.tunables.waitForStopDefaultMs; - // The stop on record before this wait began. A different record afterwards - // is a stop that landed during it — the discipline step and pause use to - // tell their own stop from an earlier one. - const lastStopBefore = session.lastStop; + // The stop on record before this wait began, per session of the tree. A + // different record afterwards is a stop that landed during it — the + // discipline step and pause use to tell their own stop from an earlier + // one. A descendant adopted during the wait has no entry: whatever it + // recorded is this wait's. + const lastStopBefore = new Map([ + [session.id, session.lastStop], + ...this.ctx.descendantsOf(sessionId).map((d): [string, SessionStopInfo | undefined] => [d.id, d.lastStop]) + ]); + + const deadline = Date.now() + waitMs; + const wait = () => waitForSubtreeSettle(this.ctx, sessionId, { + timeoutMs: Math.max(0, deadline - Date.now()), signal + }); + let settled = await wait(); + let target = session; + let landing: StopLanding; + let stopAtLanding: SessionStopInfo | undefined; + let snapshot: Awaited>; + for (;;) { + if (settled.outcome === 'gone') { + return { + success: false, + error: ErrorMessages.sessionClosedWhileWaiting(sessionId), + state: SessionState.STOPPED + }; + } + // The session this answer is about: the selection's, or — pending — + // the one asked for. + target = settled.outcome === 'matched' ? settled.value : session; + + // Where it stopped, best effort: the same short settle and stack read + // a step or a pause does. Re-read state after the DAP round trip. + landing = target.state === SessionState.PAUSED + ? await describeStopLocation(this.ctx, target.id, 'waitForStop') + : {}; + // Pin this answer's stop before the snapshot's locals read: a new stop + // under that read belongs to the next answer, never a mixed snapshot. + stopAtLanding = target.lastStop; + // lastStop already carries the exception, so do not repeat it in the + // snapshot. Its own identity gate rejects a changed stop's locals. + snapshot = await composeStopSnapshot( + this.ctx, target.id, landing.stackResult, 'waitForStop', { omitException: true } + ); - const outcome = await waitForSessionState( - this.ctx, - sessionId, - isSettledSessionState, - { timeoutMs: waitMs, signal } - ); - if (outcome === 'gone') { - return { - success: false, - error: ErrorMessages.sessionClosedWhileWaiting(sessionId), - state: SessionState.STOPPED - }; + try { + this.ctx.getSession(sessionId); + } catch { + settled = { outcome: 'gone' }; + continue; + } + if (target !== session && ( + !this.ctx.descendantsOf(sessionId).includes(target) || + (target.state !== SessionState.PAUSED && !isTerminalSessionState(target.state)) + )) { + // A selected child may be closed or resumed by another caller while + // the answer is assembled. Re-select the tree under the ORIGINAL + // deadline: never return a removed id, or a pending root answer with + // the child's state. A still-present child that naturally ended keeps + // the ordinary ended-at-settle contract, including its recorded stop. + // waitForSubtreeSettle intentionally checks current state even with + // a zero timeout. That is useful for an initial read, but retries + // must not keep selecting alternating children past this deadline. + settled = Date.now() >= deadline + ? { outcome: signal?.aborted ? 'aborted' : 'timeout' } + : await wait(); + continue; + } + break; } - - // Where it stopped, best effort: the same short settle and stack read a - // step or a pause does. It is a DAP round trip, so the state is read - // again afterwards rather than trusted across it. - const landing: StopLanding = session.state === SessionState.PAUSED - ? await describeStopLocation(this.ctx, sessionId, 'waitForStop') - : {}; const location = landing.location; - // The stop this wait settled on, pinned at the landing read: the compose - // below is a real wait, and a NEW stop landing under it belongs to the - // next wait_for_stop, not to this answer — one answer, one stop. - const stopAtLanding = session.lastStop; - // The snapshot behind the landing (issue #982). The exception section is - // omitted on purpose: this answer carries the full (redacted) lastStop, - // and the same message must never ship twice. The composer's own - // stop-identity gate answers no snapshot if the stop changed under it. - const snapshot = await composeStopSnapshot( - this.ctx, sessionId, landing.stackResult, 'waitForStop', { omitException: true } - ); + const tree = () => describeTreeAnswer(session, this.ctx.descendantsOf(sessionId)); // Emit a redacted copy of the stop record (issue #982); the stored record // keeps its identity — the exceptionInfo merge and `lastStop !== before` // guards depend on it. const emitStop = (stop: SessionStopInfo): SessionStopInfo => this.ctx.redactionEnabled() ? redactStopInfo(stop) : stop; - const state = session.state; + const state = target.state; // Rule 2 of the settle contract: a session that is over by now is // reported as ended, with the stop that landed during this wait if there // was one — an exception stop and the exit can arrive in one tick. A stop // from before the wait is not this wait's to report: the program was // resumed past it. const stopDuringWait = - session.lastStop && session.lastStop !== lastStopBefore ? { lastStop: emitStop(session.lastStop) } : {}; + target.lastStop && target.lastStop !== lastStopBefore.get(target.id) ? { lastStop: emitStop(target.lastStop) } : {}; if (state === SessionState.PAUSED && stopAtLanding) { const where = location ? ` at ${location.file}:${location.line}` : ''; return { success: true, state, data: { + sessionId: target.id, message: `Paused (${stopAtLanding.reason})${where}`, lastStop: emitStop(stopAtLanding), ...landingFields(landing), - ...(snapshot ? { snapshot } : {}) + ...(snapshot ? { snapshot } : {}), + ...tree() } }; } if (state === SessionState.ERROR) { - const detail = session.lastProxyError ? `: ${session.lastProxyError}` : ''; + const detail = target.lastProxyError ? `: ${target.lastProxyError}` : ''; + const which = target === session ? 'The debug session' : `Child session ${target.id}`; return { success: true, state, - data: { message: `The debug session ended in an error state${detail}`, ...stopDuringWait } + data: { sessionId: target.id, message: `${which} ended in an error state${detail}`, ...stopDuringWait, ...tree() } }; } if (state === SessionState.STOPPED) { - // No stop was recorded in this launch: the program ran past every - // breakpoint, and the answer says which — the same run-to-completion - // summary the launch gives when the program ends inside its hold - // (issues #701, #1033); it already joins the adapter's reading of the - // end (issue #877). A program resumed past a stop keeps the plain - // message: its breakpoints were not all missed. - const ranPast = session.lastStop ? undefined : buildRunToCompletionSummary(session); + // Only the session itself can be selected already ended (rule 3); a + // selected descendant can also end while its answer is assembled. + // No stop was recorded in this launch: + // the program ran past every breakpoint, and the answer says which — + // the same run-to-completion summary the launch gives when the program + // ends inside its hold (issues #701, #1033); it already joins the + // adapter's reading of the end (issue #877). A program resumed past a + // stop keeps the plain message: its breakpoints were not all missed. + const ranPast = target.lastStop ? undefined : buildRunToCompletionSummary(target); return { success: true, state, data: { + sessionId: target.id, message: ranPast ? ranPast.summary - : `${describeProgramEnd(session.exitCode)}.${session.programEndExplanation?.note ? ` ${session.programEndExplanation.note}` : ''}`, - ...(typeof session.exitCode === 'number' ? { exitCode: session.exitCode } : {}), + : `${describeProgramEnd(target.exitCode)}.${target.programEndExplanation?.note ? ` ${target.programEndExplanation.note}` : ''}`, + ...(typeof target.exitCode === 'number' ? { exitCode: target.exitCode } : {}), ...(ranPast?.data ?? {}), - ...(session.programEndExplanation ? { programEnd: session.programEndExplanation } : {}), - ...stopDuringWait + ...(target.programEndExplanation ? { programEnd: target.programEndExplanation } : {}), + ...stopDuringWait, + ...tree() } }; } this.ctx.logger.info( - `[SessionManager waitForStop] Session ${sessionId} still ${state} after ${waitMs}ms (${outcome}); answering with pending` + `[SessionManager waitForStop] Session ${sessionId} still ${state} after ${waitMs}ms (${settled.outcome}); answering with pending` ); // What is armed now words the answer: with something armed, that it stays // armed; with nothing, no pause is promised. An entry stop is not part of @@ -893,6 +943,7 @@ export class ExecutionController { success: true, state, data: { + sessionId: session.id, message: ErrorMessages.waitForStopPending( waitMs / 1000, state === SessionState.INITIALIZING ? 'initializing' : 'running', @@ -903,7 +954,8 @@ export class ExecutionController { noUncaughtStop: arming.noUncaughtStop } ), - pending: true + pending: true, + ...tree() } }; } diff --git a/src/session/execution/session-state-wait.ts b/src/session/execution/session-state-wait.ts index cea0a65d0..c177dbaf6 100644 --- a/src/session/execution/session-state-wait.ts +++ b/src/session/execution/session-state-wait.ts @@ -13,12 +13,22 @@ * `stopped` event itself would report a stop the caller never sees. * * Never rejects. A session that is removed while waited on settles as 'gone'. + * + * The same primitive (`waitForSettle`) serves the tree-aware wait (the + * session tree, issue #1081): `waitForSubtreeSettle` subscribes to the + * session and all its descendants and re-reads the tree's selection + * (`selectSettledInTree`) after each change, under the same deferral. */ import type { SessionState } from '@debugmcp/shared'; +import type { ManagedSession } from '../session-store.js'; import type { OperationsContext } from '../operations-context.js'; +import { selectSettledInTree } from './tree-settle.js'; export type SessionStateWaitContext = Pick; +/** What the tree-aware wait reads: the session, its descendants, and the tree's change feed. */ +export type SubtreeWaitContext = Pick; + /** * How the wait settled: the predicate held, the timeout passed, the caller's * signal aborted, or the session no longer exists. @@ -31,21 +41,30 @@ export interface SessionStateWaitOptions { signal?: AbortSignal; } -export function waitForSessionState( - ctx: SessionStateWaitContext, - sessionId: string, - until: (state: SessionState) => boolean, +/** A settle wait's answer: what the read selected, or why it stopped reading. */ +export type SettleWaitResult = + | { outcome: 'matched'; value: T } + | { outcome: Exclude }; + +/** + * Wait until `read` returns a value: now, or after a change `subscribe` + * reports, re-read in a microtask. A `read` that throws means what it reads + * no longer exists ('gone'). + */ +export function waitForSettle( + subscribe: (listener: () => void) => () => void, + read: () => T | undefined, options: SessionStateWaitOptions -): Promise { +): Promise> { const { timeoutMs, signal } = options; - return new Promise((resolve) => { + return new Promise>((resolve) => { let settled = false; let recheckQueued = false; // What to undo when the wait settles: the subscription, the abort // listener and the timer, each added as it is set up. const disposers: Array<() => void> = []; - const settle = (outcome: SessionStateWaitOutcome) => { + const settle = (result: SettleWaitResult) => { if (settled) { return; } @@ -53,34 +72,34 @@ export function waitForSessionState( for (const dispose of disposers) { dispose(); } - resolve(outcome); + resolve(result); }; - /** Read the state as it is now; true when that settled the wait. */ + /** Read as things are now; true when that settled the wait. */ const check = (): boolean => { - let state: SessionState; + let value: T | undefined; try { - state = ctx.getSession(sessionId).state; + value = read(); } catch { - settle('gone'); + settle({ outcome: 'gone' }); return true; } - if (until(state)) { - settle('matched'); + if (value !== undefined) { + settle({ outcome: 'matched', value }); return true; } return false; }; if (signal?.aborted) { - settle('aborted'); + settle({ outcome: 'aborted' }); return; } if (check()) { return; } - disposers.push(ctx.onStateChange(sessionId, () => { + disposers.push(subscribe(() => { if (settled || recheckQueued) { return; } @@ -93,11 +112,46 @@ export function waitForSessionState( }); })); if (signal) { - const onAbort = () => settle('aborted'); + const onAbort = () => settle({ outcome: 'aborted' }); signal.addEventListener('abort', onAbort, { once: true }); disposers.push(() => signal.removeEventListener('abort', onAbort)); } - const timer = setTimeout(() => settle('timeout'), timeoutMs); + const timer = setTimeout(() => settle({ outcome: 'timeout' }), timeoutMs); disposers.push(() => clearTimeout(timer)); }); } + +export async function waitForSessionState( + ctx: SessionStateWaitContext, + sessionId: string, + until: (state: SessionState) => boolean, + options: SessionStateWaitOptions +): Promise { + const result = await waitForSettle( + (listener) => ctx.onStateChange(sessionId, listener), + () => { + const state = ctx.getSession(sessionId).state; + return until(state) ? state : undefined; + }, + options + ); + return result.outcome; +} + +/** + * The tree-aware wait (issue #1081): settles on the session the four-rule + * selection names — the session itself, or a descendant, including one + * adopted while waiting — and is 'gone' only when the asked session itself is + * removed; a descendant's removal is an ordinary change. + */ +export function waitForSubtreeSettle( + ctx: SubtreeWaitContext, + sessionId: string, + options: SessionStateWaitOptions +): Promise> { + return waitForSettle( + (listener) => ctx.onSubtreeChange(sessionId, listener), + () => selectSettledInTree(ctx.getSession(sessionId), ctx.descendantsOf(sessionId)), + options + ); +} diff --git a/src/session/execution/tree-settle.ts b/src/session/execution/tree-settle.ts new file mode 100644 index 000000000..17a3a2157 --- /dev/null +++ b/src/session/execution/tree-settle.ts @@ -0,0 +1,68 @@ +/** + * Which session a tree-aware wait_for_stop answers for (the session tree, + * issue #1081; docs/architecture/session-tree.md, "wait_for_stop is + * tree-aware"). A pure read over the session and its descendants, given in + * the rule-2 order (depth first, adoption order within a level): + * + * 1. the session itself when it is paused or in error; + * 2. else the first descendant that is paused or in error; + * 3. else the session itself when it has ended; + * 4. else nothing: only descendants have ended and the session still runs, + * so the wait keeps waiting — an ended descendant alone never completes + * an ancestor's wait. + * + * Level-triggered like the single-session wait: nothing is consumed, so two + * waiters read the same selection, and an errored descendant keeps being + * selected until it is closed. + */ +import { SessionState, isTerminalSessionState } from '@debugmcp/shared'; +import type { ManagedSession } from '../session-store.js'; +import type { TreeAnswerFields } from '../session-manager-core.js'; + +/** The slice of a session the selection reads. */ +export interface SettleCandidate { + id: string; + state: SessionState; +} + +/** + * The tree fields of an answer on `session`: its descendants (given depth + * first in adoption order) with their states, and the run's count of + * children released to run undebugged. Both absent when there is nothing to + * say, so a session with no tree answers byte-for-byte as before. + */ +export function describeTreeAnswer( + session: Pick, + descendants: readonly Pick[] +): TreeAnswerFields { + return { + ...(descendants.length > 0 + ? { + children: descendants.map((d) => ({ + sessionId: d.id, + name: d.name, + state: d.state, + parentSessionId: d.parentSessionId ?? '' + })) + } + : {}), + ...(session.releasedChildReports ? { childrenReleased: session.releasedChildReports } : {}) + }; +} + +const isPausedOrErrored = (state: SessionState): boolean => + state === SessionState.PAUSED || state === SessionState.ERROR; + +export function selectSettledInTree(session: T, descendants: readonly T[]): T | undefined { + if (isPausedOrErrored(session.state)) { + return session; + } + const settledDescendant = descendants.find((d) => isPausedOrErrored(d.state)); + if (settledDescendant) { + return settledDescendant; + } + if (isTerminalSessionState(session.state)) { + return session; + } + return undefined; +} diff --git a/src/session/launch/debug-launcher.ts b/src/session/launch/debug-launcher.ts index ef5d59aa0..3aacb22aa 100644 --- a/src/session/launch/debug-launcher.ts +++ b/src/session/launch/debug-launcher.ts @@ -34,6 +34,7 @@ import type { CustomLaunchRequestArguments, DebugResult, LaunchResultData } from import type { ManagedSession, ToolchainValidationState } from '../session-store.js'; import type { LaunchContext } from '../operations-context.js'; import { describeStopLocation, landingFields, type StopLanding } from '../inspection/stop-location.js'; +import { describeTreeAnswer } from '../execution/tree-settle.js'; import { composeStopSnapshot } from '../inspection/stop-snapshot.js'; import type { BreakpointController, ResyncOutcome } from '../breakpoints/breakpoint-controller.js'; import { reresolveAnchors, type AnchorResolution } from '../breakpoints/anchor-resolution.js'; @@ -481,6 +482,9 @@ export class DebugLauncher { return this.dryRun(session, { scriptPath, scriptArgs, dapLaunchArgs, adapterLaunchConfig, breakOnExceptions }); } + // Children belong to a run (issue #1081): a relaunch closes the previous + // run's, and the answer says how many. + let childrenClosed = 0; if (session.proxyManager || session.pendingProxyStop || session.deferredWorkerStop) { if (session.proxyManager) { this.ctx.logger.warn( @@ -494,7 +498,7 @@ export class DebugLauncher { // proxyManager and leaves its stop() in flight as pendingProxyStop, // which this awaits too (#502) — otherwise the relaunch races the old // worker's exit (a debuggee port still bound, lastProxyPid overwritten). - await this.ctx.stopProxyPreservingSession(session); + childrenClosed = (await this.ctx.stopProxyPreservingSession(session)) ?? 0; } // Update to INITIALIZING state and set lifecycle to ACTIVE @@ -874,6 +878,10 @@ export class DebugLauncher { stopOnEntrySuccessful: !!effectiveLaunchArgs?.stopOnEntry && finalState === SessionState.PAUSED, ...landingFields(landing), ...(snapshot ? { snapshot } : {}), + // The session tree (issue #1081): the children adopted during the + // hold, the run's released count, and what the relaunch closed. + ...describeTreeAnswer(finalSession, this.ctx.descendantsOf(sessionId)), + ...(childrenClosed > 0 ? { childrenClosed } : {}), }, }; } catch (error) { @@ -1161,6 +1169,8 @@ export class DebugLauncher { reason: finalState === SessionState.PAUSED ? finalSession.lastStop?.reason ?? 'unknown' : undefined, ...landingFields(landing), ...(snapshot ? { snapshot } : {}), + // A hot restart keeps the process and its children (issue #1081). + ...describeTreeAnswer(finalSession, this.ctx.descendantsOf(sessionId)), }, }; } diff --git a/src/session/launch/proxy-failure-diagnostics.ts b/src/session/launch/proxy-failure-diagnostics.ts index 822b0c067..4d2694624 100644 --- a/src/session/launch/proxy-failure-diagnostics.ts +++ b/src/session/launch/proxy-failure-diagnostics.ts @@ -93,7 +93,7 @@ export type ProxyFailureOperation = 'startDebugging' | 'attachToProcess' | 'prox * session-preserving proxy teardown. */ export interface ProxySetupFailureDeps extends ProxyFailureLogDeps { - stopProxyPreservingSession(session: ManagedSession): Promise; + stopProxyPreservingSession(session: ManagedSession): Promise; } /** diff --git a/src/session/operations-context.ts b/src/session/operations-context.ts index b679fa83a..76bf48362 100644 --- a/src/session/operations-context.ts +++ b/src/session/operations-context.ts @@ -26,7 +26,7 @@ import type { import type { ManagedSession } from './session-store.js'; import type { StackTraceResult } from './inspection/frame-anchor-resolver.js'; import type { LocalVariablesResult } from './session-manager-data.js'; -import type { CustomLaunchRequestArguments } from './session-manager-core.js'; +import type { CloseSessionOutcome, CustomLaunchRequestArguments } from './session-manager-core.js'; import type { VariableAccessMode } from '../utils/variable-access.js'; import type { IProxyManager } from '../proxy/proxy-manager.js'; import type { IProxyManagerFactory } from '../factories/proxy-manager-factory.js'; @@ -81,6 +81,19 @@ export interface OperationsContext { * (see `waitForSessionState`), not from within it. Returns the unsubscribe. */ onStateChange(sessionId: string, listener: () => void): () => void; + /** + * The session tree (issue #1081): be told whenever the session or any of + * its descendants changes state, is adopted (its first transition) or is + * removed. Same contract as `onStateChange`: no arguments, runs inside the + * transition, read when the stack has unwound (see `waitForSubtreeSettle`). + */ + onSubtreeChange(sessionId: string, listener: () => void): () => void; + /** + * Every descendant of a session, depth first in adoption order (a child, + * then its own descendants, then the next child): the order the tree-aware + * wait selects in and the `children` answers list in. + */ + descendantsOf(sessionId: string): ManagedSession[]; /** * The data layer's policy lookup: total, and overridable by tests. @@ -111,8 +124,15 @@ export interface OperationsContext { * (issue #711). */ cleanupProxyEventHandlers(session: ManagedSession, proxyManager: IProxyManager): void; - stopProxyPreservingSession(session: ManagedSession): Promise; + /** Resolves with how many descendants the teardown closed (issue #1081). */ + stopProxyPreservingSession(session: ManagedSession): Promise; closeSession(sessionId: string): Promise; + /** `closeSession` with its outcome (issue #1081): the rows removed, and what the close did to the processes. */ + closeSessionReporting(sessionId: string, options?: { terminateDebuggee?: boolean }): Promise; + /** Block new child adoptions during a complete teardown; returns the release function. */ + beginSubtreeClose(sessionId: string): () => void; + /** The detach cascade (issue #1081): release every descendant, deepest first; resolves with how many. */ + releaseDescendants(session: ManagedSession): Promise; getStackTrace( sessionId: string, threadId?: number, @@ -182,6 +202,7 @@ export type LaunchContext = Pick< | 'selectPolicy' | 'getStackTraceDetailed' | 'stopProxyPreservingSession' + | 'descendantsOf' | 'extractLocalsFromResolved' | 'redactionEnabled' | 'snapshotEnabled' @@ -208,6 +229,9 @@ export type AttachContext = Pick< | 'stopProxyPreservingSession' | 'cleanupProxyEventHandlers' | 'closeSession' + | 'closeSessionReporting' + | 'releaseDescendants' + | 'beginSubtreeClose' | 'tunables' | 'extractLocalsFromResolved' | 'redactionEnabled' @@ -225,7 +249,8 @@ export type BreakpointContext = Pick< * Stepping / continue / pause / threads / waiting for a stop. The policy and * the detailed stack serve the pending-stop explanation (issue #678): the raw * origin frame of a step, and the adapter's account of why it may never land. - * The state subscription serves wait_for_stop (issue #849). + * The state subscription serves wait_for_stop (issue #849); the subtree + * subscription and the descendant read serve its tree-aware form (issue #1081). */ export type ExecutionContext = Pick< OperationsContext, @@ -233,6 +258,8 @@ export type ExecutionContext = Pick< | 'getSession' | 'updateState' | 'onStateChange' + | 'onSubtreeChange' + | 'descendantsOf' | 'getStackTraceDetailed' | 'selectPolicy' | 'tunables' diff --git a/src/session/session-manager-core.ts b/src/session/session-manager-core.ts index 11a2e6a72..64d9d6859 100644 --- a/src/session/session-manager-core.ts +++ b/src/session/session-manager-core.ts @@ -35,6 +35,7 @@ import { } from '@debugmcp/shared'; import { ISessionStoreFactory } from '../factories/session-store-factory.js'; import { IProxyManager, type ChildReportedEvent } from '../proxy/proxy-manager.js'; +import { isChildProxyView } from '../proxy/child-proxy-view.js'; import { maxChildSessions, MAX_CHILD_SESSIONS_ENV } from './child-session-cap.js'; import { IProxyManagerFactory } from '../factories/proxy-manager-factory.js'; import type { BreakpointSyncResult, FunctionBreakpointSyncResult } from '../proxy/dap-proxy-interfaces.js'; @@ -218,7 +219,43 @@ export type PauseResultData = DebugResultData & StopAnswerFields & { /** How much of the output's tail a policy reads the program's end from (issue #877). */ const PROGRAM_END_OUTPUT_TAIL_CHARS = 8000; -export type WaitForStopResultData = DebugResultData & StopAnswerFields & { +/** + * One descendant in a `children` answer (the session tree, issue #1081): + * the row's id, the adapter's name for the process, its state, and the + * session that spawned it — listed depth first in adoption order, the order + * the tree-aware wait selects in. + */ +export interface ChildSessionSummary { + sessionId: string; + name: string; + state: SessionState; + parentSessionId: string; +} + +/** + * What an answer on a session with a tree carries + * (docs/architecture/session-tree.md, "wait_for_stop is tree-aware"). + */ +export interface TreeAnswerFields { + /** Every descendant with its state; absent when the session has none. */ + children?: ChildSessionSummary[]; + /** + * Reports released to run undebugged on this session's connection during + * this run (beyond the cap, or an adoption that failed): a running total, + * never consumed, so concurrent waiters read the same number. + */ + childrenReleased?: number; + /** Descendants this operation closed (a relaunch, a close) — when any. */ + childrenClosed?: number; +} + +export type WaitForStopResultData = DebugResultData & StopAnswerFields & TreeAnswerFields & { + /** + * The session this answer is about (issue #1081): the one asked for, or the + * descendant the tree-aware selection named — the id to inspect, step and + * continue with. + */ + sessionId: string; message: string; lastStop?: SessionStopInfo; /** @@ -239,7 +276,32 @@ export type WaitForStopResultData = DebugResultData & StopAnswerFields & { * so the answer names the frame the program is paused in and the handler can * read the line and the build behind it. */ -export type LaunchResultData = DebugResultData & StopAnswerFields; +export type LaunchResultData = DebugResultData & StopAnswerFields & TreeAnswerFields; + +/** + * What a detach returns on top of the common fields (the session tree, issue + * #1081): the children a plain detach released to run undebugged, or a + * terminating detach closed. + */ +export type DetachResultData = DebugResultData & Pick; + +/** + * What a close did (the session tree, issue #1081): the rows it removed, + * descendants first; how many of those were descendants; and, for a child, + * whether its process was ended or released — for an attach root, that its + * target and its descendants' were released to run on. + */ +export interface CloseSessionOutcome { + closed: boolean; + /** Every id removed, deepest first then the named session. */ + closedSessionIds: string[]; + /** Descendants closed with the named session, when any. */ + childrenClosed?: number; + /** The named session is a child whose process this close ended (the policy's `closeChild: 'terminate'`). */ + processEnded?: true; + /** The named session's target runs on: an attach root (its descendants' too), or a child under `closeChild: 'release'`. */ + released?: true; +} /** * What a successful attach returns on top of the common fields: the same @@ -312,8 +374,16 @@ export abstract class SessionManagerCore extends EventEmitter { */ private readonly sessionStateListeners = new Map void>>(); + /** + * Who wants to know when a session's subtree changes (the session tree, + * issue #1081): told after every transition of the session or any + * descendant — a child's first transition is its adoption — and after any + * of them is removed. The tree-aware wait_for_stop subscribes here. + */ + private readonly subtreeListeners = new Map void>>(); + /** Do not admit new descendants while an explicit close or relaunch tears down a subtree. */ - private readonly closingSubtrees = new Set(); + private readonly closingSubtrees = new Map(); /** * TTL cache for launch-gate toolchain probes (issue #360) — shares the @@ -421,6 +491,49 @@ export abstract class SessionManagerCore extends EventEmitter { executionState: execution }); this.notifySessionStateListeners(session.id); + this.notifySubtreeListeners(session); + } + + /** + * Subscribe to a session's subtree (issue #1081): the session's own + * transitions and removal, and those of every descendant, present or + * adopted later. Returns the unsubscribe. + */ + protected onSubtreeStateChange(sessionId: string, listener: () => void): () => void { + let listeners = this.subtreeListeners.get(sessionId); + if (!listeners) { + listeners = new Set(); + this.subtreeListeners.set(sessionId, listeners); + } + listeners.add(listener); + return () => { + const current = this.subtreeListeners.get(sessionId); + if (current?.delete(listener) && current.size === 0) { + this.subtreeListeners.delete(sessionId); + } + }; + } + + /** Tell the subtree listeners of a session and of each of its ancestors. */ + private notifySubtreeListeners(session: ManagedSession): void { + const seen = new Set(); + let current: ManagedSession | undefined = session; + while (current && !seen.has(current.id)) { + seen.add(current.id); + const listeners = this.subtreeListeners.get(current.id); + if (listeners) { + for (const listener of [...listeners]) { + try { + listener(); + } catch (error) { + this.logger.warn( + `[SessionManager] Subtree listener threw for session ${current.id}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + } + current = current.parentSessionId ? this.sessionStore.get(current.parentSessionId) : undefined; + } } /** @@ -480,22 +593,120 @@ export abstract class SessionManagerCore extends EventEmitter { } async closeSession(sessionId: string): Promise { - // The session tree (issue #1081): closing any session closes its subtree - // first, deepest first, so a child never outlives its parent's row and a - // root's worker goes after its last child's connection. - this.closingSubtrees.add(sessionId); + return (await this.closeSessionReporting(sessionId)).closed; + } + + /** + * Close a session and its subtree, and say what the close did (issue + * #1081): the descendants it took with it, deepest first; for a child, + * whether its process was ended (the policy's `closeChild` pin: debugpy + * terminates it) or released; for an attach root, that every descendant was + * detached with its target kept running, as the root's own target is. + */ + async closeSessionReporting(sessionId: string, options: { terminateDebuggee?: boolean } = {}): Promise { + const session = this.sessionStore.get(sessionId); + if (!session) { + this.logger.warn(`[SESSION_CLOSE_FAIL] Session not found: ${sessionId}`); + return { closed: false, closedSessionIds: [] }; + } + // An attach root's close preserves its external targets, descendants + // included: their connections are closed without ending them. + const releaseDescendants = !options.terminateDebuggee && !session.parentSessionId && session.attachMode === true; + // Closing a retained row after its connection ended cannot terminate + // its process. In particular, released children keep running after a + // plain detach, even if their rows are explicitly closed later. + const childEnd = session.parentSessionId && session.proxyManager ? this.childCloseSemantics(session) : undefined; + const alreadyReleased = session.parentSessionId && session.programEndExplanation?.data?.released === true; + const closedSessionIds: string[] = []; + const finishClose = this.beginSubtreeClose(sessionId); try { for (const descendant of this.descendantsDeepestFirst(sessionId)) { - await this.closeSingleSession(descendant.id); + if (await this.closeSingleSession(descendant.id, { releaseChild: releaseDescendants })) { + closedSessionIds.push(descendant.id); + } + } + const childrenClosed = closedSessionIds.length; + // An explicit terminating detach ends every child before the root's + // disconnect can tear down their shared adapter/worker connection. + if (options.terminateDebuggee && session.proxyManager) { + try { + await session.proxyManager.sendDapRequest('disconnect', { terminateDebuggee: true }); + } catch (disconnectError) { + this.logger.warn('[SessionManager] Terminating disconnect failed, closing the session anyway:', disconnectError); + } + } + const closed = await this.closeSingleSession(sessionId); + if (closed) { + closedSessionIds.push(sessionId); } - return await this.closeSingleSession(sessionId); + return { + closed, + closedSessionIds, + ...(childrenClosed > 0 ? { childrenClosed } : {}), + ...(closed && childEnd === 'terminate' ? { processEnded: true as const } : {}), + ...(closed && (childEnd === 'release' || releaseDescendants || alreadyReleased) ? { released: true as const } : {}) + }; } finally { - this.closingSubtrees.delete(sessionId); + finishClose(); } } - /** Close one session: its own proxy (a root's worker, a child's connection), its row, its waiters. */ - protected async closeSingleSession(sessionId: string): Promise { + /** + * The detach cascade (issue #1081): release every descendant of an attach + * root, deepest first, ahead of the root's own detach — each child's + * connection is closed with `terminateDebuggee: false`, so the process + * runs on undebugged, and its row stays, `stopped`, with a program end + * that says it was released. Returns how many were released. + */ + protected async releaseDescendants(session: ManagedSession): Promise { + let released = 0; + for (const descendant of this.descendantsDeepestFirst(session.id)) { + if (isTerminalSessionState(descendant.state) && !descendant.proxyManager) { + continue; + } + clearPauseIntent(descendant); + const proxy = descendant.proxyManager; + if (proxy) { + try { + this.cleanupProxyEventHandlers(descendant, proxy); + } catch (cleanupError) { + this.logger.error(`[SessionManager] Error during listener cleanup for session ${descendant.id}:`, cleanupError); + } + try { + await (isChildProxyView(proxy) ? proxy.stop({ terminateDebuggee: false }) : proxy.stop()); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`[SessionManager] Releasing child session ${descendant.id} failed (best effort): ${message}`); + } finally { + descendant.proxyManager = undefined; + } + } + descendant.programEndExplanation = { + note: 'Released: detached with its parent; the process runs on undebugged.', + data: { released: true } + }; + this._updateSessionState(descendant, SessionState.STOPPED); + this.sessionStore.update(descendant.id, { sessionLifecycle: SessionLifecycleState.TERMINATED }); + released += 1; + } + return released; + } + + /** What closing a child does to its process: the adapter's measured semantics, pinned on the policy. */ + private childCloseSemantics(session: ManagedSession): 'terminate' | 'release' { + try { + return this.sessionStore.selectPolicy(session.language).childAdoption?.closeChild ?? 'terminate'; + } catch { + return 'terminate'; + } + } + + /** + * Close one session: its own proxy (a root's worker, a child's connection + * — ended, or released to run on when `releaseChild` is set), its row, its + * waiters. + */ + protected async closeSingleSession(sessionId: string, options: { releaseChild?: boolean } = {}): Promise { const session = this.sessionStore.get(sessionId); if (!session) { this.logger.warn(`[SESSION_CLOSE_FAIL] Session not found: ${sessionId}`); @@ -516,7 +727,8 @@ export abstract class SessionManagerCore extends EventEmitter { // Then stop the proxy try { - await session.proxyManager.stop(); + const proxy = session.proxyManager; + await (options.releaseChild && isChildProxyView(proxy) ? proxy.stop({ terminateDebuggee: false }) : proxy.stop()); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); this.logger.error(`[SessionManager] Error stopping proxy for session ${sessionId}:`, message); @@ -555,9 +767,13 @@ export abstract class SessionManagerCore extends EventEmitter { this.logger.info(`Session ${sessionId} marked as STOPPED/TERMINATED.`); this.sessionStore.remove(sessionId); - // Anything still waiting on this session learns it is gone (issue #849). + this.emit('session-removed', sessionId); + // Anything still waiting on this session learns it is gone (issue #849), + // and a wait on an ancestor's tree sees one descendant fewer (issue #1081). this.notifySessionStateListeners(sessionId); this.sessionStateListeners.delete(sessionId); + this.notifySubtreeListeners(session); + this.subtreeListeners.delete(sessionId); if (session.parentSessionId) { // A child's view reports the shared worker's pid, which legitimately // lives on (issue #1081); the leak probe is the root's. Its end may @@ -604,21 +820,25 @@ export abstract class SessionManagerCore extends EventEmitter { * even without a live proxy process, so the adapter registry slot is * always released (issue #238). */ - protected async stopProxyPreservingSession(session: ManagedSession): Promise { - this.closingSubtrees.add(session.id); + protected async stopProxyPreservingSession(session: ManagedSession): Promise { + const finishClose = this.beginSubtreeClose(session.id); try { - await this.stopProxyAndDescendants(session); + return await this.stopProxyAndDescendants(session); } finally { - this.closingSubtrees.delete(session.id); + finishClose(); } } - private async stopProxyAndDescendants(session: ManagedSession): Promise { + /** Returns how many descendants it closed (the relaunch answer's `childrenClosed`). */ + private async stopProxyAndDescendants(session: ManagedSession): Promise { clearPauseIntent(session); // Children belong to a run (issue #1081): a relaunch, or the teardown of // a launch that failed, closes the previous run's children first. + let childrenClosed = 0; for (const descendant of this.descendantsDeepestFirst(session.id)) { - await this.closeSingleSession(descendant.id); + if (await this.closeSingleSession(descendant.id)) { + childrenClosed += 1; + } } const proxyManager = session.proxyManager; if (proxyManager) { @@ -648,6 +868,7 @@ export abstract class SessionManagerCore extends EventEmitter { await session.pendingProxyStop; session.pendingProxyStop = undefined; } + return childrenClosed; } async closeAllSessions(): Promise { @@ -680,6 +901,22 @@ export abstract class SessionManagerCore extends EventEmitter { return current; } + /** Block new adoptions until this teardown and any overlapping teardown finish. */ + protected beginSubtreeClose(sessionId: string): () => void { + this.closingSubtrees.set(sessionId, (this.closingSubtrees.get(sessionId) ?? 0) + 1); + let finished = false; + return () => { + if (finished) return; + finished = true; + const remaining = (this.closingSubtrees.get(sessionId) ?? 1) - 1; + if (remaining > 0) { + this.closingSubtrees.set(sessionId, remaining); + } else { + this.closingSubtrees.delete(sessionId); + } + }; + } + private isSubtreeClosing(session: ManagedSession): boolean { let current: ManagedSession | undefined = session; const seen = new Set(); @@ -698,6 +935,20 @@ export abstract class SessionManagerCore extends EventEmitter { return all.filter((s) => s.parentSessionId === sessionId); } + /** + * Every descendant of a session, depth first in adoption order (pre-order: a + * child, its own descendants, then the next child) — the order the + * tree-aware wait selects in and the `children` answers list in. + */ + protected descendantsDepthFirst(sessionId: string): ManagedSession[] { + const result: ManagedSession[] = []; + for (const child of this.childrenOf(sessionId)) { + result.push(child); + result.push(...this.descendantsDepthFirst(child.id)); + } + return result; + } + /** Every descendant of a session, deepest first (post-order), adoption order within a level. */ protected descendantsDeepestFirst(sessionId: string): ManagedSession[] { const result: ManagedSession[] = []; @@ -774,7 +1025,12 @@ export abstract class SessionManagerCore extends EventEmitter { parentProxy: IProxyManager, reported: ChildReportedEvent ): Promise { - const release = (reason: string): void => { + const release = (reason: string, options: { counted: boolean } = { counted: true }): void => { + if (options.counted) { + // The run's count of children that run undebugged (issue #1081): the + // parent's next answers carry it as `childrenReleased`. + parent.releasedChildReports = (parent.releasedChildReports ?? 0) + 1; + } parentProxy .sendDapRequest(CHILD_RELEASE_COMMAND, { reportId: reported.reportId, reason }, { timeoutMs: 5000 }) .catch((err) => { @@ -785,7 +1041,7 @@ export abstract class SessionManagerCore extends EventEmitter { }; if (this.sessionStore.get(parent.id) !== parent || parent.proxyManager !== parentProxy || this.isSubtreeClosing(parent)) { - release('the parent session is closing or its connection has ended'); + release('the parent session is closing or its connection has ended', { counted: false }); return; } @@ -865,7 +1121,7 @@ export abstract class SessionManagerCore extends EventEmitter { this.logger.warn( `[SessionManager] Adopting child ${reported.name} of session ${parent.id} failed: ${message}` ); - release(`adoption failed: ${message}`); + release(`adoption failed: ${message}`, { counted: !this.isSubtreeClosing(parent) }); if (child && this.sessionStore.get(child.id) === child && !this.isSubtreeClosing(child)) { // Use the normal subtree teardown: a grandchild may already have // been reported, and removing the last child must reap a root whose @@ -924,6 +1180,7 @@ export abstract class SessionManagerCore extends EventEmitter { session.adapterCapabilities = undefined; session.liveUpdateState = undefined; session.programEndExplanation = undefined; + session.releasedChildReports = undefined; session.failureDiagnostics = undefined; // Adapter degradation notes are per-launch (issue #441). session.adapterNotices = []; diff --git a/src/session/session-manager-operations.ts b/src/session/session-manager-operations.ts index 4c9642aa4..02ac8cd9b 100644 --- a/src/session/session-manager-operations.ts +++ b/src/session/session-manager-operations.ts @@ -40,6 +40,7 @@ import { AttachController, type AttachRequest } from './attach/attach-controller import { InFlightGuard } from './in-flight-guard.js'; import { AttachResultData, + DetachResultData, CustomLaunchRequestArguments, DebugResult, LaunchResultData, @@ -177,6 +178,8 @@ export abstract class SessionManagerOperations extends SessionManagerData { updateSession: (sessionId, updates) => this.sessionStore.update(sessionId, updates), updateState: (session, newState) => this._updateSessionState(session, newState), onStateChange: (sessionId, listener) => this.onSessionStateChange(sessionId, listener), + onSubtreeChange: (sessionId, listener) => this.onSubtreeStateChange(sessionId, listener), + descendantsOf: (sessionId) => this.descendantsDepthFirst(sessionId), selectPolicy: (language) => this.selectPolicy(language), selectStorePolicy: (language) => this.sessionStore.selectPolicy(language), findFreePort: () => this.findFreePort(), @@ -186,6 +189,9 @@ export abstract class SessionManagerOperations extends SessionManagerData { this.cleanupProxyEventHandlers(session, proxyManager), stopProxyPreservingSession: (session) => this.stopProxyPreservingSession(session), closeSession: (sessionId) => this.closeSession(sessionId), + closeSessionReporting: (sessionId, options) => this.closeSessionReporting(sessionId, options), + releaseDescendants: (session) => this.releaseDescendants(session), + beginSubtreeClose: (sessionId) => this.beginSubtreeClose(sessionId), getStackTrace: (sessionId, threadId, includeInternals) => this.getStackTrace(sessionId, threadId, includeInternals), getStackTraceDetailed: (sessionId, threadId, includeInternals, opts) => @@ -433,7 +439,7 @@ export abstract class SessionManagerOperations extends SessionManagerData { async detachFromProcess( sessionId: string, terminateProcess: boolean = false - ): Promise { + ): Promise> { return this.attach.detachFromProcess(sessionId, terminateProcess); } diff --git a/src/session/session-store.ts b/src/session/session-store.ts index c50819e7a..9fd14d321 100644 --- a/src/session/session-store.ts +++ b/src/session/session-store.ts @@ -179,6 +179,13 @@ export interface ManagedSession extends DebugSessionInfo { liveUpdateState?: unknown; /** The policy's reading of the program's end (issue #877), recorded at the end event; reset per launch. */ programEndExplanation?: ProgramEndExplanation; + /** + * Children reported on this session's connection and released to run + * undebugged during this run (the session tree, issue #1081): beyond the + * cap, or an adoption that failed. A running total the answers carry as + * `childrenReleased`; reset per launch. + */ + releasedChildReports?: number; // The breakOnExceptions mode actually in effect for the current launch or // attach — the user's value, or the policy's launch default when unset // (issue #244). Previously write-only pass-through; recorded for read-back. diff --git a/src/skill-content.ts b/src/skill-content.ts index 5ef8fd46a..57c9e4d39 100644 --- a/src/skill-content.ts +++ b/src/skill-content.ts @@ -46,7 +46,7 @@ Failing test (Python): start_debugging {sessionId, scriptPath: ABSOLUTE test fil Key rules: - Every paused answer (start/restart/wait_for_stop/pause/attach and the steps) already carries snapshot {exception?, frames, locals | changedVariables} — the exception message, top frames and the top frame's locals, redacted and capped. A repeated stop in the same frame (stepping, a continue+wait loop over one breakpoint) carries changedVariables {name: {before, after}} instead of locals — an empty {} means the line changed nothing visible. Read it before calling get_stack_trace/get_local_variables; call those (and get_scopes -> get_variables with a variablesReference) only to expand structures, see more frames, or fetch the full list (snapshot.truncation says when there is more). Entry stops carry no snapshot. - Stepping/evaluation/variable reads require the session to be PAUSED; the stop reason on each pause tells you why it stopped. -- continue_execution returns at once, and a launch, step, pause or attach may answer pending:true when its stop has not arrived yet. Nothing is cancelled — breakpoints stay armed. Call wait_for_stop {sessionId} to block until the session pauses or ends (it reports the stop, or the exit code); a wait that runs out answers pending:true again, so call it again. +- continue_execution returns at once, and a launch, step, pause or attach may answer pending:true when its stop has not arrived yet. Nothing is cancelled — breakpoints stay armed. Call wait_for_stop {sessionId} to block until the session pauses or ends (it reports the stop, or the exit code); a wait that runs out answers pending:true again, so call it again. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: the answer's sessionId names the session that stopped — use it to inspect — and children lists the rest with their states. - If a variable entry has a variablesReference, call get_variables with it to expand children. - Breakpoints may report unverified until the module/class loads — that is normal. - list_breakpoints shows every breakpoint with its verified state; remove_breakpoint (by id or file+line) and clear_breakpoints take effect immediately, even mid-run — use them to move a bisection window without restarting.${statementRule}${expectedContentRule} diff --git a/tests/adapters/python/integration/python-subprocess-tree.test.ts b/tests/adapters/python/integration/python-subprocess-tree.test.ts index d22d40e7d..d8d1d98c0 100644 --- a/tests/adapters/python/integration/python-subprocess-tree.test.ts +++ b/tests/adapters/python/integration/python-subprocess-tree.test.ts @@ -142,9 +142,14 @@ describe('Python child processes under subProcess: true (the session tree, issue } expect(paused.state, JSON.stringify(paused)).toBe('paused'); expect(JSON.stringify(paused.location ?? paused.data?.location ?? paused)).toMatch(/parent\.py/); + // A paused parent is reported before its children (rule 1); the launch + // answer itself names no session — it is about the launched one. + if (paused !== started) { + expect(paused.sessionId, JSON.stringify(paused)).toBe(rootId); + } - // The child is discovered through the listing in this milestone (the - // tree-aware wait_for_stop lands next). + // The parent paused the moment it spawned, before the child was reported: + // the listing is where the child's row turns up. const listing = await pollUntil( () => call('list_debug_sessions', {}), (l) => Array.isArray(l.sessions) && l.sessions.some((s: { parentSessionId?: string }) => s.parentSessionId === rootId), @@ -195,6 +200,8 @@ describe('Python child processes under subProcess: true (the session tree, issue // Closing the child leaves the parent paused and its worker alive. const closedChild = await call('close_debug_session', { sessionId: childId }); expect(closedChild.success, JSON.stringify(closedChild)).toBe(true); + expect(closedChild.processEnded, JSON.stringify(closedChild)).toBe(true); + expect(closedChild.childrenClosed).toBeUndefined(); const listing = await call('list_debug_sessions', {}); expect((listing.sessions as Array<{ id: string }>).some((s) => s.id === childId)).toBe(false); const rootRow = (listing.sessions as Array<{ id: string; state: string }>).find((s) => s.id === rootId); @@ -239,6 +246,93 @@ describe('Python child processes under subProcess: true (the session tree, issue } }, 120000); + /** + * The designed flow (docs/architecture/session-tree.md): a breakpoint in + * the child's code only, start the parent, wait on the parent. The wait + * answers the child's stop with the child's id (rule 2), again after each + * continue, and the parent's own end once the child has ended (rule 3). + */ + it("the parent's wait_for_stop reports the child's stops with its sessionId, then the parent's end", async () => { + const created = await call('create_debug_session', { language: 'python', name: 'subprocess-tree-designed-flow' }); + expect(created.success).toBe(true); + const rootId = created.sessionId as string; + try { + const childBp = await call('set_breakpoint', { sessionId: rootId, file: childFixture, line: CHILD_BREAKPOINT_LINE }); + expect(childBp.success, JSON.stringify(childBp)).toBe(true); + const started = await call('start_debugging', { + sessionId: rootId, + scriptPath: parentFixture, + breakOnExceptions: 'none', + dapLaunchArgs: { stopOnEntry: false, subProcess: true } + }); + expect(started.success, JSON.stringify(started)).toBe(true); + expect(started.state, JSON.stringify(started)).toBe('running'); + + let childId: string | undefined; + for (let iteration = 0; iteration < 3; iteration += 1) { + const stop = await call('wait_for_stop', { sessionId: rootId, timeout: 30000 }); + expect(stop.state, JSON.stringify(stop)).toBe('paused'); + expect(stop.sessionId, JSON.stringify(stop)).toBeDefined(); + expect(stop.sessionId).not.toBe(rootId); + childId ??= stop.sessionId as string; + expect(stop.sessionId).toBe(childId); + expect(JSON.stringify(stop.location ?? stop)).toMatch(/child\.py/); + expect(stop.location?.line ?? stop.lastStop?.line).toBe(CHILD_BREAKPOINT_LINE); + expect(stop.children, JSON.stringify(stop)).toEqual([ + { sessionId: childId, name: expect.stringMatching(/^Subprocess \d+$/), state: 'paused', parentSessionId: rootId } + ]); + // The answer's own stop is the child's: inspect it with that id. + const locals = await call('get_local_variables', { sessionId: childId }); + expect(locals.success, JSON.stringify(locals)).toBe(true); + expect(JSON.stringify(locals)).toMatch(/"i"/); + const resumed = await call('continue_execution', { sessionId: childId }); + expect(resumed.success, JSON.stringify(resumed)).toBe(true); + } + + // The child runs to its end; the parent then sees the exit code and ends. + const ended = await call('wait_for_stop', { sessionId: rootId, timeout: 30000 }); + expect(ended.state, JSON.stringify(ended)).toBe('stopped'); + expect(ended.sessionId, JSON.stringify(ended)).toBe(rootId); + expect(ended.exitCode, JSON.stringify(ended)).toBe(0); + expect(ended.children, JSON.stringify(ended)).toEqual([ + expect.objectContaining({ sessionId: childId, state: 'stopped', parentSessionId: rootId }) + ]); + const output = await call('get_output', { sessionId: rootId }); + expect(JSON.stringify(output)).toContain('child done 3'); + expect(JSON.stringify(output)).toContain('parent child exit 0'); + } finally { + await call('close_debug_session', { sessionId: rootId }); + } + }, 120000); + + it("restarting the parent closes the previous run's child and says so", async () => { + const { rootId, childId } = await startTree('subprocess-tree-restart'); + try { + const childStop = await call('wait_for_stop', { sessionId: childId, timeout: 30000 }); + expect(childStop.state, JSON.stringify(childStop)).toBe('paused'); + + const restarted = await call('restart_debugging', { sessionId: rootId }); + expect(restarted.success, JSON.stringify(restarted)).toBe(true); + expect(restarted.childrenClosed, JSON.stringify(restarted)).toBe(1); + + let paused = restarted; + if (restarted.state !== 'paused') { + paused = await call('wait_for_stop', { sessionId: rootId, timeout: 30000 }); + } + expect(paused.state, JSON.stringify(paused)).toBe('paused'); + if (paused !== restarted) { + expect(paused.sessionId, JSON.stringify(paused)).toBe(rootId); + } + expect(JSON.stringify(paused.location ?? paused)).toMatch(/parent\.py/); + + const listing = await call('list_debug_sessions', {}); + const ids = (listing.sessions as Array<{ id: string }>).map((s) => s.id); + expect(ids).not.toContain(childId); + } finally { + await call('close_debug_session', { sessionId: rootId }); + } + }, 120000); + it('closing the parent closes the child too and reaps the worker', async () => { const { rootId, childId } = await startTree('subprocess-tree-close-root'); const childStop = await call('wait_for_stop', { sessionId: childId, timeout: 30000 }); @@ -246,6 +340,8 @@ describe('Python child processes under subProcess: true (the session tree, issue const closed = await call('close_debug_session', { sessionId: rootId }); expect(closed.success, JSON.stringify(closed)).toBe(true); + expect(closed.childrenClosed, JSON.stringify(closed)).toBe(1); + expect(closed.processEnded).toBeUndefined(); const listing = await call('list_debug_sessions', {}); const ids = (listing.sessions as Array<{ id: string }>).map((s) => s.id); diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-true.json index 444104df6..def419fab 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-true.json @@ -492,7 +492,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-unset.json index 873c4fa9e..81628b7b3 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-explicit.container-unset.json @@ -492,7 +492,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-true.json index 527a0e163..8dafbef88 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-true.json @@ -492,7 +492,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-unset.json index c3f3fb4d0..d5de90166 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-open.container-unset.json @@ -492,7 +492,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-true.json index 527a0e163..8dafbef88 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-true.json @@ -492,7 +492,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-unset.json index c3f3fb4d0..d5de90166 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-assert.va-unset.container-unset.json @@ -492,7 +492,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-true.json index b6751d0a2..efe1da4f3 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-true.json @@ -502,7 +502,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-unset.json index ab713a702..5b2bdd0b6 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-explicit.container-unset.json @@ -502,7 +502,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-true.json index 720148b33..b0fe92017 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-true.json @@ -502,7 +502,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-unset.json index 73c459764..c7f5afd38 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-open.container-unset.json @@ -502,7 +502,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-true.json index 720148b33..b0fe92017 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-true.json @@ -502,7 +502,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-unset.json index 73c459764..c7f5afd38 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-content.va-unset.container-unset.json @@ -502,7 +502,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-true.json index d49e40702..23570f12e 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-true.json @@ -488,7 +488,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-unset.json index 238ed1cea..31d12b914 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-explicit.container-unset.json @@ -488,7 +488,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-true.json index e5067e0d2..63047df92 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-true.json @@ -488,7 +488,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-unset.json index c82486be5..a751c1b29 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-open.container-unset.json @@ -488,7 +488,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-true.json index e5067e0d2..63047df92 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-true.json @@ -488,7 +488,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-unset.json index c82486be5..a751c1b29 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-line.va-unset.container-unset.json @@ -488,7 +488,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-true.json index b6751d0a2..efe1da4f3 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-true.json @@ -502,7 +502,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-unset.json index ab713a702..5b2bdd0b6 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-explicit.container-unset.json @@ -502,7 +502,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-true.json index 720148b33..b0fe92017 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-true.json @@ -502,7 +502,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-unset.json index 73c459764..c7f5afd38 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-open.container-unset.json @@ -502,7 +502,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-true.json b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-true.json index 720148b33..b0fe92017 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-true.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-true.json @@ -502,7 +502,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-unset.json b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-unset.json index 73c459764..c7f5afd38 100644 --- a/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-unset.json +++ b/tests/core/unit/server/__snapshots__/tool-list/bp-unset.va-unset.container-unset.json @@ -502,7 +502,7 @@ }, { "name": "wait_for_stop", - "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt", + "description": "Block until the session next pauses or ends, then say why and where. Call it after continue_execution (which does not wait) and whenever a tool answers pending:true, instead of polling list_debug_sessions. Answers at once when the session is already paused (state \"paused\" with lastStop — the same record list_debug_sessions shows — and the top frame as location) or already over (the terminal state, with exitCode when the debuggee reported one); note says when the answer is anchored on a thread other than the one the stop was reported on, or when no frame of the shown stack is program code. If neither happens within the timeout it answers state \"running\" with pending:true: nothing was cancelled, breakpoints and exception filters stay armed — call it again to keep waiting, or pause_execution to interrupt. A session whose program spawned child sessions (Python subProcess: true) is waited on as a tree: sessionId names the session the answer is about — the session itself when it is paused, else the first paused descendant, else the session itself when it has ended — and children lists every descendant with its state; an ended child alone never completes the parent's wait", "inputSchema": { "type": "object", "properties": { diff --git a/tests/core/unit/server/dynamic-tool-documentation.test.ts b/tests/core/unit/server/dynamic-tool-documentation.test.ts index 4c253bd8c..938a6354e 100644 --- a/tests/core/unit/server/dynamic-tool-documentation.test.ts +++ b/tests/core/unit/server/dynamic-tool-documentation.test.ts @@ -36,6 +36,7 @@ vi.mock('../../../../src/session/session-manager.js', () => ({ SessionManager: vi.fn(function() { return ({ createSession: vi.fn(), closeSession: vi.fn(), + closeSessionReporting: vi.fn(), closeAllSessions: vi.fn(), getAllSessions: vi.fn().mockReturnValue([]), getSession: vi.fn(), diff --git a/tests/core/unit/server/handlers/debuggee-tools.test.ts b/tests/core/unit/server/handlers/debuggee-tools.test.ts index 39e8abb25..014cbcd0d 100644 --- a/tests/core/unit/server/handlers/debuggee-tools.test.ts +++ b/tests/core/unit/server/handlers/debuggee-tools.test.ts @@ -8,7 +8,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { DotnetAdapterPolicy, SessionLifecycleState } from '@debugmcp/shared'; -import { startDebuggingTool, restartDebuggingTool, attachToProcessTool } from '../../../../../src/server/handlers/debuggee-tools.js'; +import { startDebuggingTool, restartDebuggingTool, attachToProcessTool, detachFromProcessTool } from '../../../../../src/server/handlers/debuggee-tools.js'; import { createMockToolContext } from '../server-test-helpers.js'; // DebugMcpServer builds its dependencies in the constructor; mock the container @@ -113,6 +113,46 @@ describe('debuggee lifecycle tool handlers', () => { } }); + describe('the tree fields (issue #1081)', () => { + const children = [{ sessionId: 'child-1', name: 'Subprocess 4242', state: 'running', parentSessionId: 'test-session' }]; + + for (const [tool, answer] of answers) { + it(`${tool} hoists children, childrenClosed and childrenReleased beside pending`, async () => { + const payload = JSON.parse( + (await answer({ message: 'still running', pending: true, children, childrenClosed: 2, childrenReleased: 1 }, 'running')).content[0].text + ); + + expect(payload).toMatchObject({ success: true, state: 'running', pending: true, children, childrenClosed: 2, childrenReleased: 1 }); + }); + + it(`${tool} hoists none of them for a session without a tree`, async () => { + const payload = JSON.parse((await answer({ message: 'still running', pending: true }, 'running')).content[0].text); + + expect(payload).not.toHaveProperty('children'); + expect(payload).not.toHaveProperty('childrenClosed'); + expect(payload).not.toHaveProperty('childrenReleased'); + }); + } + }); + + describe('the tree fields on the detach_from_process answer (issue #1081)', () => { + it('hoists childrenReleased (a plain detach) and childrenClosed (a terminating one) beside the message', async () => { + ctx.sessionManager.detachFromProcess.mockResolvedValueOnce({ + success: true, state: 'stopped', data: { message: 'Detached from process (process still running)', childrenReleased: 2 } + }); + let payload = JSON.parse((await detachFromProcessTool(ctx, { sessionId: 'test-session' }, 'detach_from_process')).content[0].text); + expect(payload).toMatchObject({ success: true, state: 'stopped', childrenReleased: 2 }); + expect(payload).not.toHaveProperty('childrenClosed'); + + ctx.sessionManager.detachFromProcess.mockResolvedValueOnce({ + success: true, state: 'stopped', data: { message: 'Detached and terminated process', childrenClosed: 1 } + }); + payload = JSON.parse((await detachFromProcessTool(ctx, { sessionId: 'test-session', terminateProcess: true }, 'detach_from_process')).content[0].text); + expect(payload).toMatchObject({ success: true, state: 'stopped', childrenClosed: 1 }); + expect(payload).not.toHaveProperty('childrenReleased'); + }); + }); + // Issue #985: the test-runner preset rides start_debugging as two optional // arguments, folded into adapterLaunchConfig at intake so the seven-argument // facade signature and restart_debugging's replay stay as they are. diff --git a/tests/core/unit/server/handlers/execution-tools.test.ts b/tests/core/unit/server/handlers/execution-tools.test.ts index 91b687083..2a4ac522f 100644 --- a/tests/core/unit/server/handlers/execution-tools.test.ts +++ b/tests/core/unit/server/handlers/execution-tools.test.ts @@ -381,6 +381,43 @@ describe('execution tool handlers', () => { }); }); + describe('the tree fields on the wait_for_stop answer (issue #1081)', () => { + const liveSession = { id: 'test-session', sessionLifecycle: SessionLifecycleState.ACTIVE }; + const children = [{ sessionId: 'child-1', name: 'Subprocess 4242', state: 'paused', parentSessionId: 'test-session' }]; + + it('names the selected session, lists the children, and reads the landing on the selected session', async () => { + ctx.sessionManager.getSession.mockReturnValue(liveSession); + ctx.buildStaleness = { check: vi.fn().mockResolvedValue(undefined) }; + const lastStop = { reason: 'breakpoint', threadId: 1, timestamp: 1_700_000_000_000 }; + const location = { file: '/app/child.py', line: 7, column: 1 }; + ctx.sessionManager.waitForStop.mockResolvedValue({ + success: true, + state: 'paused', + data: { sessionId: 'child-1', message: 'Paused (breakpoint) at /app/child.py:7', lastStop, location, children, childrenReleased: 1 } + }); + + const payload = JSON.parse((await waitForStopTool(ctx, { sessionId: 'test-session' }, 'wait_for_stop')).content[0].text); + + expect(payload).toMatchObject({ success: true, state: 'paused', sessionId: 'child-1', lastStop, location, children, childrenReleased: 1 }); + // The stale-build read (and the line context) are keyed by the selected session, not the one asked for. + expect(ctx.sessionManager.getSessionPolicy).toHaveBeenCalledWith('child-1'); + }); + + it('carries sessionId and the children on the pending answer too', async () => { + ctx.sessionManager.getSession.mockReturnValue(liveSession); + const running = [{ ...children[0], state: 'running' }]; + ctx.sessionManager.waitForStop.mockResolvedValue({ + success: true, + state: 'running', + data: { sessionId: 'test-session', message: 'still running', pending: true, children: running } + }); + + const payload = JSON.parse((await waitForStopTool(ctx, { sessionId: 'test-session' }, 'wait_for_stop')).content[0].text); + + expect(payload).toEqual({ success: true, state: 'running', pending: true, message: 'still running', sessionId: 'test-session', children: running }); + }); + }); + describe('stepTool failure envelopes (issue #638)', () => { it.each([ ['step_over', 'stepOver'], diff --git a/tests/core/unit/server/server-resources.test.ts b/tests/core/unit/server/server-resources.test.ts index eeb1df154..574f56bbd 100644 --- a/tests/core/unit/server/server-resources.test.ts +++ b/tests/core/unit/server/server-resources.test.ts @@ -43,6 +43,7 @@ describe('Server Output Resources Tests', () => { let mockDependencies: Dependencies; let outputCapturedListener: ((sessionId: string, entry: unknown) => void) | undefined; let childAdoptedListener: ((childId: string, parentId: string) => void) | undefined; + let sessionRemovedListener: ((sessionId: string) => void) | undefined; beforeEach(() => { vi.useFakeTimers(); @@ -65,6 +66,9 @@ describe('Server Output Resources Tests', () => { if (event === 'child-session-adopted') { childAdoptedListener = listener; } + if (event === 'session-removed') { + sessionRemovedListener = listener; + } }); vi.mocked(SessionManager).mockImplementation(function() { return mockSessionManager as any; }); @@ -77,6 +81,7 @@ describe('Server Output Resources Tests', () => { vi.clearAllMocks(); outputCapturedListener = undefined; childAdoptedListener = undefined; + sessionRemovedListener = undefined; }); describe('the session tree (issue #1081)', () => { @@ -94,6 +99,33 @@ describe('Server Output Resources Tests', () => { expect(mockSessionManager.removeListener).toHaveBeenCalledWith('child-session-adopted', childAdoptedListener); }); + + it('forgets a removed child resource outside close_debug_session and cancels its pending update', async () => { + mockSessionManager.getSession.mockReturnValue(mockSession({ id: 'child-1' })); + await subscribe('debug://sessions/child-1/output'); + expect(sessionRemovedListener).toBeDefined(); + outputCapturedListener!('child-1', { output: 'before removal' }); + vi.mocked(mockServer.sendResourceListChanged).mockClear(); + + // Relaunch and terminating detach remove rows directly in the core; + // neither runs the close_debug_session handler. + sessionRemovedListener!('child-1'); + await vi.advanceTimersByTimeAsync(200); + + expect(mockServer.sendResourceListChanged).toHaveBeenCalledTimes(1); + expect(mockServer.sendResourceUpdated).not.toHaveBeenCalled(); + // The subscription itself is gone, as well as the pending timer. + outputCapturedListener!('child-1', { output: 'late output' }); + await vi.advanceTimersByTimeAsync(200); + expect(mockServer.sendResourceUpdated).not.toHaveBeenCalled(); + }); + + it('drops the removal subscription when the server stops', async () => { + expect(sessionRemovedListener).toBeDefined(); + await debugServer.stop(); + + expect(mockSessionManager.removeListener).toHaveBeenCalledWith('session-removed', sessionRemovedListener); + }); }); function mockSession(overrides: Record = {}) { diff --git a/tests/core/unit/server/server-session-tools.test.ts b/tests/core/unit/server/server-session-tools.test.ts index 4b9fbeb08..0b57bb26d 100644 --- a/tests/core/unit/server/server-session-tools.test.ts +++ b/tests/core/unit/server/server-session-tools.test.ts @@ -31,6 +31,8 @@ describe('Server Session Tools Tests', () => { let mockSessionManager: MockSessionManager; let mockDependencies: Dependencies; let callToolHandler: CallToolHandler; + let server: DebugMcpServer; + let sessionRemovedListener: (sessionId: string) => void; beforeEach(() => { mockDependencies = createMockDependencies(); @@ -43,9 +45,14 @@ describe('Server Session Tools Tests', () => { vi.mocked(StdioServerTransport).mockImplementation(function() { return mockStdioTransport as any; }); mockSessionManager = createMockSessionManager(mockDependencies.adapterRegistry); + mockSessionManager.on.mockImplementation((event: string, listener: (...args: any[]) => void) => { + if (event === 'session-removed') { + sessionRemovedListener = listener; + } + }); vi.mocked(SessionManager).mockImplementation(function() { return mockSessionManager as any; }); - new DebugMcpServer(); + server = new DebugMcpServer(); callToolHandler = getToolHandlers(mockServer).callToolHandler; }); @@ -241,8 +248,11 @@ describe('Server Session Tools Tests', () => { }); describe('close_debug_session', () => { + const outputResources = () => + (server as unknown as { outputResources: { forgetSession: (id: string) => void; notifyListChanged: () => void } }).outputResources; + it('should close session successfully', async () => { - mockSessionManager.closeSession.mockResolvedValue(true); + mockSessionManager.closeSessionReporting.mockResolvedValue({ closed: true, closedSessionIds: ['test-session'] }); const result = await callToolHandler({ method: 'tools/call', @@ -253,12 +263,43 @@ describe('Server Session Tools Tests', () => { }); const content = JSON.parse(result.content[0].text); - expect(content.success).toBe(true); - expect(content.message).toContain('Closed debug session'); + expect(content).toEqual({ success: true, message: 'Closed debug session: test-session' }); + }); + + it('answers the tree fields of a close and forgets every closed output resource (issue #1081)', async () => { + mockSessionManager.closeSessionReporting.mockImplementation(async () => { + const closedSessionIds = ['grandchild', 'child', 'test-session']; + closedSessionIds.forEach(sessionRemovedListener); + return { closed: true, closedSessionIds, childrenClosed: 2, released: true }; + }); + const forget = vi.spyOn(outputResources(), 'forgetSession'); + const announce = vi.spyOn(outputResources(), 'notifyListChanged'); + + const result = await callToolHandler({ + method: 'tools/call', + params: { name: 'close_debug_session', arguments: { sessionId: 'test-session' } } + }); + + const content = JSON.parse(result.content[0].text); + expect(content).toEqual({ success: true, message: 'Closed debug session: test-session', childrenClosed: 2, released: true }); + expect(forget.mock.calls.map(([id]) => id)).toEqual(['grandchild', 'child', 'test-session']); + // Once per removal, with no duplicate notification from the handler. + expect(announce).toHaveBeenCalledTimes(3); + }); + + it('says processEnded when the close ended a child process', async () => { + mockSessionManager.closeSessionReporting.mockResolvedValue({ closed: true, closedSessionIds: ['child'], processEnded: true }); + + const result = await callToolHandler({ + method: 'tools/call', + params: { name: 'close_debug_session', arguments: { sessionId: 'child' } } + }); + + expect(JSON.parse(result.content[0].text)).toEqual({ success: true, message: 'Closed debug session: child', processEnded: true }); }); it('should handle session not found', async () => { - mockSessionManager.closeSession.mockResolvedValue(false); + mockSessionManager.closeSessionReporting.mockResolvedValue({ closed: false, closedSessionIds: [] }); const result = await callToolHandler({ method: 'tools/call', @@ -274,7 +315,7 @@ describe('Server Session Tools Tests', () => { }); it('should handle SessionManager errors', async () => { - mockSessionManager.closeSession.mockRejectedValue(new Error('Close failed')); + mockSessionManager.closeSessionReporting.mockRejectedValue(new Error('Close failed')); await expect(callToolHandler({ method: 'tools/call', diff --git a/tests/core/unit/server/server-test-helpers.ts b/tests/core/unit/server/server-test-helpers.ts index c1fdd0723..8f434eae0 100644 --- a/tests/core/unit/server/server-test-helpers.ts +++ b/tests/core/unit/server/server-test-helpers.ts @@ -105,6 +105,7 @@ export function createMockSessionManager(mockAdapterRegistry: IAdapterRegistry) getAllSessions: vi.fn(), getSession: vi.fn(), closeSession: vi.fn(), + closeSessionReporting: vi.fn(), closeAllSessions: vi.fn(), setBreakpoint: vi.fn(), setFunctionBreakpoint: vi.fn(), diff --git a/tests/core/unit/server/tool-dispatch-foreign-session.test.ts b/tests/core/unit/server/tool-dispatch-foreign-session.test.ts index e265bfc09..66bc6407d 100644 --- a/tests/core/unit/server/tool-dispatch-foreign-session.test.ts +++ b/tests/core/unit/server/tool-dispatch-foreign-session.test.ts @@ -37,7 +37,7 @@ function fakeContext(overrides: Partial = {}): ToolContext { validateSession: (sessionId: string) => { throw new SessionNotFoundError(sessionId); }, - closeDebugSession: vi.fn(async () => false), + closeDebugSession: vi.fn(async () => ({ closed: false, closedSessionIds: [] })), outputResources: { forgetSession: vi.fn(), notifyListChanged: vi.fn() }, ...overrides } as unknown as ToolContext; @@ -106,7 +106,7 @@ describe('tools/call on a session id another server owns (issue #939)', () => { it('a session this server holds is dispatched without consulting the other servers', async () => { const ctx = fakeContext({ sessionManager: { getSession: vi.fn(() => ({ id: FOREIGN_ID })) } as unknown as ToolContext['sessionManager'], - closeDebugSession: vi.fn(async () => true) + closeDebugSession: vi.fn(async () => ({ closed: true, closedSessionIds: [FOREIGN_ID] })) }); registerToolHandlers(server, ctx); client = await connect(server); diff --git a/tests/core/unit/session/debug-result-data.test.ts b/tests/core/unit/session/debug-result-data.test.ts index 3b31982ba..c9101c106 100644 --- a/tests/core/unit/session/debug-result-data.test.ts +++ b/tests/core/unit/session/debug-result-data.test.ts @@ -26,9 +26,9 @@ describe('DebugResultData type contract (issue #590)', () => { >(); }); - it('the launch answer adds only the stop-answer fields: the landing plus the snapshot (issues #951/#982)', () => { + it('the launch answer adds only the stop-answer fields — the landing plus the snapshot (issues #951/#982) — and the tree fields (issue #1081)', () => { expectTypeOf().toEqualTypeOf< - keyof DebugResultData | 'location' | 'threadId' | 'note' | 'snapshot' + keyof DebugResultData | 'location' | 'threadId' | 'note' | 'snapshot' | 'children' | 'childrenReleased' | 'childrenClosed' >(); }); }); diff --git a/tests/core/unit/session/session-manager-child-sessions.test.ts b/tests/core/unit/session/session-manager-child-sessions.test.ts index 5fda260bc..e580a0a4b 100644 --- a/tests/core/unit/session/session-manager-child-sessions.test.ts +++ b/tests/core/unit/session/session-manager-child-sessions.test.ts @@ -596,6 +596,301 @@ describe('SessionManager child sessions (issue #1081)', () => { expect(childOf(rootId)).toBeUndefined(); }); + describe('the close outcome (issue #1081, the cascade answers)', () => { + async function tree(): Promise<{ rootId: string; childId: string; grandchildId: string; childView: MockChildProxyView; grandchildView: MockChildProxyView }> { + const rootId = await startRoot(); + dependencies.mockProxyManager.simulateEvent('child-reported', report('r1')); + await vi.runAllTimersAsync(); + const childId = childOf(rootId)!.id; + const childView = dependencies.mockProxyManager.childViews.get(childId)!; + childView.simulateEvent('child-reported', report('r2', 4343)); + await vi.runAllTimersAsync(); + const grandchildId = childOf(childId)!.id; + const grandchildView = dependencies.mockProxyManager.childViews.get(grandchildId)!; + return { rootId, childId, grandchildId, childView, grandchildView }; + } + + it("closing a child ends its process (debugpy's close) and says so", async () => { + const { rootId, childId, childView } = await tree(); + + const outcome = await sessionManager.closeSessionReporting(childId); + + expect(outcome).toEqual({ closed: true, closedSessionIds: expect.any(Array), childrenClosed: 1, processEnded: true }); + expect(outcome.closedSessionIds).toHaveLength(2); + expect(outcome.closedSessionIds[1]).toBe(childId); + expect(outcome.released).toBeUndefined(); + expect(childView.stopOptions[0]?.terminateDebuggee ?? true).toBe(true); + expect(sessionManager.getSession(rootId)).toBeDefined(); + }); + + it('closing a launch root counts the descendants it took with it, deepest first', async () => { + const { rootId, childId, grandchildId } = await tree(); + + const outcome = await sessionManager.closeSessionReporting(rootId); + + expect(outcome.closed).toBe(true); + expect(outcome.childrenClosed).toBe(2); + expect(outcome.closedSessionIds).toEqual([grandchildId, childId, rootId]); + expect(outcome.processEnded).toBeUndefined(); + expect(outcome.released).toBeUndefined(); + expect(sessionManager.getAllSessions()).toHaveLength(0); + }); + + it('closing an attach root releases every descendant (targets kept) and says released', async () => { + const { rootId, childView, grandchildView } = await tree(); + sessionManager.getSession(rootId)!.attachMode = true; + + const outcome = await sessionManager.closeSessionReporting(rootId); + + expect(outcome.closed).toBe(true); + expect(outcome.released).toBe(true); + expect(outcome.childrenClosed).toBe(2); + expect(grandchildView.stopOptions[0]).toEqual({ terminateDebuggee: false }); + expect(childView.stopOptions[0]).toEqual({ terminateDebuggee: false }); + expect(sessionManager.getAllSessions()).toHaveLength(0); + }); + + it('a session with no tree answers the bare outcome, and a missing one closed: false', async () => { + const rootId = await startRoot(); + expect(await sessionManager.closeSessionReporting(rootId)).toEqual({ closed: true, closedSessionIds: [rootId] }); + expect(await sessionManager.closeSessionReporting('no-such-session')).toEqual({ closed: false, closedSessionIds: [] }); + }); + + it('closing an already ended child only removes its row, without claiming the close ended its process', async () => { + const { childId, grandchildId, childView, grandchildView } = await tree(); + grandchildView.simulateEvent('terminated'); + childView.simulateEvent('terminated'); + await vi.runAllTimersAsync(); + expect(sessionManager.getSession(childId)!.proxyManager).toBeUndefined(); + + const outcome = await sessionManager.closeSessionReporting(childId); + + expect(outcome).toEqual({ closed: true, closedSessionIds: [grandchildId, childId], childrenClosed: 1 }); + }); + }); + + describe('the detach cascade (issue #1081): an attach root with children', () => { + async function attachTree() { + const rootId = await startRoot(); + sessionManager.getSession(rootId)!.attachMode = true; + dependencies.mockProxyManager.simulateEvent('child-reported', report('r1')); + await vi.runAllTimersAsync(); + const childId = childOf(rootId)!.id; + const childView = dependencies.mockProxyManager.childViews.get(childId)!; + childView.simulateEvent('child-reported', report('r2', 4343)); + await vi.runAllTimersAsync(); + const grandchildId = childOf(childId)!.id; + const grandchildView = dependencies.mockProxyManager.childViews.get(grandchildId)!; + return { rootId, childId, grandchildId, childView, grandchildView }; + } + + it('releases every descendant deepest first before the root detaches, keeps their rows as stopped, and counts them', async () => { + const { rootId, childId, grandchildId, childView, grandchildView } = await attachTree(); + const order: string[] = []; + vi.spyOn(childView, 'stop').mockImplementation(async (options) => { order.push(`child:${options?.terminateDebuggee}`); }); + vi.spyOn(grandchildView, 'stop').mockImplementation(async (options) => { order.push(`grandchild:${options?.terminateDebuggee}`); }); + const rootSend = dependencies.mockProxyManager.sendDapRequest.bind(dependencies.mockProxyManager); + vi.spyOn(dependencies.mockProxyManager, 'sendDapRequest').mockImplementation(async (command, args, options) => { + if (command === 'disconnect') order.push('root:disconnect'); + return rootSend(command, args, options); + }); + + const detached = await sessionManager.detachFromProcess(rootId); + await vi.runAllTimersAsync(); + + expect(detached.success).toBe(true); + expect(detached.state).toBe(SessionState.STOPPED); + expect(detached.data?.childrenReleased).toBe(2); + expect(order).toEqual(['grandchild:false', 'child:false', 'root:disconnect']); + for (const id of [childId, grandchildId]) { + const row = sessionManager.getSession(id)!; + expect(row).toBeDefined(); + expect(row.state).toBe(SessionState.STOPPED); + expect(row.proxyManager).toBeUndefined(); + expect(row.programEndExplanation).toEqual({ + note: 'Released: detached with its parent; the process runs on undebugged.', + data: { released: true } + }); + } + expect(sessionManager.getAllSessions().find((s) => s.id === childId)?.programEnd?.data).toEqual({ released: true }); + expect(sessionManager.getSession(rootId)!.state).toBe(SessionState.STOPPED); + }); + + it('a terminating detach closes the subtree and counts what it closed', async () => { + const { rootId, childId, grandchildId, childView, grandchildView } = await attachTree(); + const order: string[] = []; + vi.spyOn(childView, 'stop').mockImplementation(async (options) => { order.push(`child:${options?.terminateDebuggee ?? true}`); }); + vi.spyOn(grandchildView, 'stop').mockImplementation(async (options) => { order.push(`grandchild:${options?.terminateDebuggee ?? true}`); }); + const rootSend = dependencies.mockProxyManager.sendDapRequest.bind(dependencies.mockProxyManager); + vi.spyOn(dependencies.mockProxyManager, 'sendDapRequest').mockImplementation(async (command, args, options) => { + if (command === 'disconnect') { + order.push(`root:${(args as { terminateDebuggee: boolean }).terminateDebuggee}`); + } + return rootSend(command, args, options); + }); + const removed = vi.fn(); + sessionManager.on('session-removed', removed); + + const detached = await sessionManager.detachFromProcess(rootId, true); + await vi.runAllTimersAsync(); + + expect(detached.success).toBe(true); + expect(detached.data?.childrenClosed).toBe(2); + expect(detached.data?.childrenReleased).toBeUndefined(); + expect(order).toEqual(['grandchild:true', 'child:true', 'root:true']); + expect(removed.mock.calls).toEqual([[grandchildId], [childId], [rootId]]); + expect(sessionManager.getSession(childId)).toBeUndefined(); + expect(sessionManager.getSession(grandchildId)).toBeUndefined(); + expect(sessionManager.getSession(rootId)).toBeUndefined(); + }); + + it('releases reports arriving during child release or the root disconnect without adopting new rows', async () => { + const { rootId, grandchildView } = await attachTree(); + let finishChildStop!: () => void; + vi.spyOn(grandchildView, 'stop').mockImplementation(() => new Promise((resolve) => { finishChildStop = resolve; })); + let finishRootDisconnect!: () => void; + const rootSend = dependencies.mockProxyManager.sendDapRequest.bind(dependencies.mockProxyManager); + vi.spyOn(dependencies.mockProxyManager, 'sendDapRequest').mockImplementation(async (command, args, options) => { + if (command === 'disconnect') { + await new Promise((resolve) => { finishRootDisconnect = resolve; }); + } + return rootSend(command, args, options); + }); + + const detaching = sessionManager.detachFromProcess(rootId); + await vi.waitFor(() => expect(finishChildStop).toBeTypeOf('function')); + dependencies.mockProxyManager.simulateEvent('child-reported', report('during-child-release', 4444)); + finishChildStop(); + await vi.waitFor(() => expect(finishRootDisconnect).toBeTypeOf('function')); + dependencies.mockProxyManager.simulateEvent('child-reported', report('during-root-disconnect', 4545)); + finishRootDisconnect(); + const detached = await detaching; + await vi.runAllTimersAsync(); + + expect(detached.success).toBe(true); + expect(detached.data?.childrenReleased).toBe(2); + expect(sessionManager.getAllSessions()).toHaveLength(3); + expect(sessionManager.getAllSessions().every((session) => session.state === SessionState.STOPPED)).toBe(true); + expect(releases().map((call) => (call.args as { reportId: string }).reportId)).toEqual([ + 'during-child-release', 'during-root-disconnect' + ]); + expect(sessionManager.getSession(rootId)!.releasedChildReports).toBeUndefined(); + }); + + it('a detach with no children answers as before', async () => { + const rootId = await startRoot(); + sessionManager.getSession(rootId)!.attachMode = true; + + const detached = await sessionManager.detachFromProcess(rootId); + + expect(detached.success).toBe(true); + expect(detached.data).toEqual({ message: 'Detached from process (process still running)' }); + }); + + it('closing a released child preserves the release outcome and does not claim process termination', async () => { + const { rootId, childId, grandchildId } = await attachTree(); + await sessionManager.detachFromProcess(rootId); + + const outcome = await sessionManager.closeSessionReporting(childId); + + expect(outcome).toEqual({ closed: true, closedSessionIds: [grandchildId, childId], childrenClosed: 1, released: true }); + expect(outcome.processEnded).toBeUndefined(); + }); + }); + + describe('the tree answers (issue #1081, the designed flow)', () => { + it("a child released beyond the cap is counted on the parent's next wait answer as childrenReleased", async () => { + build({ DEBUG_MCP_MAX_CHILD_SESSIONS: '0' }); + const rootId = await startRoot(); + dependencies.mockProxyManager.simulateEvent('child-reported', report('r1')); + await vi.runAllTimersAsync(); + + const answer = await sessionManager.waitForStop(rootId, 100); + + expect(answer.data?.pending).toBe(true); + expect(answer.data?.childrenReleased).toBe(1); + expect(answer.data?.children).toBeUndefined(); + }); + + it('a relaunch starts the released count afresh', async () => { + build({ DEBUG_MCP_MAX_CHILD_SESSIONS: '0' }); + const rootId = await startRoot(); + dependencies.mockProxyManager.simulateEvent('child-reported', report('r1')); + await vi.runAllTimersAsync(); + expect((await sessionManager.waitForStop(rootId, 100)).data?.childrenReleased).toBe(1); + + vi.spyOn(dependencies.mockProxyManager, 'stop').mockResolvedValue(); + const restarted = await sessionManager.restartDebugging(rootId); + await vi.runAllTimersAsync(); + + expect(restarted.success).toBe(true); + expect((await sessionManager.waitForStop(rootId, 100)).data?.childrenReleased).toBeUndefined(); + }); + + it('a report released while its subtree is closing is not counted', async () => { + const rootId = await startRoot(); + dependencies.mockProxyManager.simulateEvent('child-reported', report('r1')); + await vi.runAllTimersAsync(); + const view = dependencies.mockProxyManager.childViews.get(childOf(rootId)!.id)!; + vi.spyOn(dependencies.mockProxyManager, 'stop').mockResolvedValue(); + let finishStop!: () => void; + vi.spyOn(view, 'stop').mockImplementation(() => new Promise((resolve) => { finishStop = resolve; })); + + const restarting = sessionManager.restartDebugging(rootId); + await vi.waitFor(() => expect(finishStop).toBeTypeOf('function')); + dependencies.mockProxyManager.simulateEvent('child-reported', report('during-restart')); + finishStop(); + await restarting; + await vi.runAllTimersAsync(); + + expect(sessionManager.getSession(rootId)!.releasedChildReports).toBeUndefined(); + }); + + it('a restart answers childrenClosed with the number of descendants the relaunch closed', async () => { + const rootId = await startRoot(); + dependencies.mockProxyManager.simulateEvent('child-reported', report('r1')); + await vi.runAllTimersAsync(); + const childView = dependencies.mockProxyManager.childViews.get(childOf(rootId)!.id)!; + childView.simulateEvent('child-reported', report('r2', 4343)); + await vi.runAllTimersAsync(); + vi.spyOn(dependencies.mockProxyManager, 'stop').mockResolvedValue(); + const removed = vi.fn(); + sessionManager.on('session-removed', removed); + + const restarted = await sessionManager.restartDebugging(rootId); + await vi.runAllTimersAsync(); + + expect(restarted.success).toBe(true); + expect(restarted.data?.childrenClosed).toBe(2); + expect(restarted.data?.children).toBeUndefined(); + expect(removed).toHaveBeenCalledTimes(2); + expect(removed).not.toHaveBeenCalledWith(rootId); + }); + + it('a launch with nothing to tear down answers no childrenClosed', async () => { + const session = await sessionManager.createSession({ language: DebugLanguage.MOCK }); + const started = await sessionManager.startDebugging(session.id, 'test.py'); + await vi.runAllTimersAsync(); + expect(started.data?.childrenClosed).toBeUndefined(); + }); + + it('a child adopted during the launch hold is listed on the launch answer', async () => { + const session = await sessionManager.createSession({ language: DebugLanguage.MOCK }); + const starting = sessionManager.startDebugging(session.id, 'test.py'); + await vi.advanceTimersByTimeAsync(100); + dependencies.mockProxyManager.simulateEvent('child-reported', report('r1')); + await vi.runAllTimersAsync(); + const started = await starting; + + expect(started.success).toBe(true); + expect(started.data?.pending).toBe(true); + const child = childOf(session.id)!; + expect(started.data?.children).toEqual([ + { sessionId: child.id, name: 'Subprocess 4242', state: SessionState.RUNNING, parentSessionId: session.id } + ]); + }); + }); + it('releases the report and removes the row if creating its proxy view fails', async () => { const rootId = await startRoot(); vi.spyOn(dependencies.mockProxyManager, 'createChildView').mockImplementation(() => { diff --git a/tests/core/unit/session/session-manager-wait-for-stop-tree.test.ts b/tests/core/unit/session/session-manager-wait-for-stop-tree.test.ts new file mode 100644 index 000000000..f4137b0f8 --- /dev/null +++ b/tests/core/unit/session/session-manager-wait-for-stop-tree.test.ts @@ -0,0 +1,403 @@ +/** + * The tree-aware wait_for_stop (the session tree, issue #1081): a wait on a + * session observes it and all its descendants, answers for the session the + * four-rule selection names (`sessionId`), and lists every descendant with + * its state (`children`). Built on the child-session harness: a mock worker + * reports children, each adopted as a session with a MockChildProxyView. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { SessionManager, SessionManagerConfig } from '../../../../src/session/session-manager.js'; +import type { DebugResult, WaitForStopResultData } from '../../../../src/session/session-manager-core.js'; +import { DebugLanguage, SessionState } from '@debugmcp/shared'; +import { createMockDependencies } from './session-manager-test-utils.js'; +import type { MockChildProxyView } from '../../../test-utils/mocks/mock-proxy-manager.js'; + +const report = (reportId: string, pid = 4242) => ({ + reportId, + targetId: String(pid), + name: `Subprocess ${pid}`, + configuration: { type: 'python', subProcessId: pid } +}); + +describe('SessionManager - the tree-aware waitForStop (issue #1081)', () => { + let sessionManager: SessionManager; + let dependencies: ReturnType; + const config: SessionManagerConfig = { + logDirBase: '/tmp/test-sessions', + defaultDapLaunchArgs: { stopOnEntry: false, justMyCode: true } + }; + + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + dependencies = createMockDependencies(); + sessionManager = new SessionManager(config, dependencies); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + dependencies.mockProxyManager.reset(); + }); + + async function startRoot(): Promise { + const session = await sessionManager.createSession({ language: DebugLanguage.MOCK }); + await sessionManager.startDebugging(session.id, 'test.py'); + await vi.runAllTimersAsync(); + expect(sessionManager.getSession(session.id)!.state).toBe(SessionState.RUNNING); + return session.id; + } + + /** Report a child on `on` (the root worker, or a child's view for a grandchild) and return its session and view. */ + async function adopt(on: { simulateEvent: (event: 'child-reported', reported: ReturnType) => void }, parentId: string, pid: number) { + on.simulateEvent('child-reported', report(`r-${pid}`, pid)); + await vi.runAllTimersAsync(); + const child = sessionManager.getAllSessions().find((s) => s.parentSessionId === parentId && s.name === `Subprocess ${pid}`)!; + expect(child).toBeDefined(); + const view = dependencies.mockProxyManager.childViews.get(child.id)!; + return { id: child.id, view }; + } + + const afterStop = () => vi.advanceTimersByTimeAsync(20); + function track(pending: Promise>) { + let result: DebugResult | undefined; + void pending.then((settled) => { result = settled; }); + return { result: () => result }; + } + + it('a session with no children answers as before, plus sessionId and no children list', async () => { + const rootId = await startRoot(); + dependencies.mockProxyManager.simulateStopped(1, 'breakpoint'); + + const result = await sessionManager.waitForStop(rootId); + + expect(result).toMatchObject({ success: true, state: SessionState.PAUSED }); + expect(result.data?.sessionId).toBe(rootId); + expect(result.data?.children).toBeUndefined(); + expect(result.data?.lastStop).toMatchObject({ reason: 'breakpoint' }); + expect(result.data?.location).toMatchObject({ file: 'test.py', line: 10 }); + }); + + it("answers a paused child for a running parent: the child's stop, its landing read on the child, sessionId naming it (rule 2)", async () => { + const rootId = await startRoot(); + const child = await adopt(dependencies.mockProxyManager, rootId, 4242); + const wait = track(sessionManager.waitForStop(rootId, 10_000)); + await vi.advanceTimersByTimeAsync(50); + expect(wait.result()).toBeUndefined(); + + child.view.simulateStopped(1, 'breakpoint', { reason: 'breakpoint', threadId: 1, allThreadsStopped: true }); + await afterStop(); + + const result = wait.result(); + expect(result).toMatchObject({ success: true, state: SessionState.PAUSED }); + expect(result?.data?.sessionId).toBe(child.id); + expect(result?.data?.lastStop).toMatchObject({ reason: 'breakpoint', threadId: 1 }); + expect(result?.data?.message).toMatch(/Paused \(breakpoint\)/); + // The landing was read on the child's connection, never on the root's. + expect(child.view.dapRequestCalls.some((c) => c.command === 'stackTrace')).toBe(true); + expect(dependencies.mockProxyManager.dapRequestCalls.some((c) => c.command === 'stackTrace')).toBe(false); + expect(result?.data?.children).toEqual([ + { sessionId: child.id, name: 'Subprocess 4242', state: SessionState.PAUSED, parentSessionId: rootId } + ]); + // The parent is untouched. + expect(sessionManager.getSession(rootId)!.state).toBe(SessionState.RUNNING); + }); + + it('answers the paused parent before its paused child (rule 1), listing the child as paused', async () => { + const rootId = await startRoot(); + const child = await adopt(dependencies.mockProxyManager, rootId, 4242); + child.view.simulateStopped(1, 'breakpoint', { reason: 'breakpoint', threadId: 1, allThreadsStopped: true }); + dependencies.mockProxyManager.simulateStopped(1, 'breakpoint'); + await afterStop(); + + const result = await sessionManager.waitForStop(rootId); + + expect(result.data?.sessionId).toBe(rootId); + expect(result.state).toBe(SessionState.PAUSED); + expect(result.data?.children).toEqual([ + expect.objectContaining({ sessionId: child.id, state: SessionState.PAUSED }) + ]); + }); + + it('selects depth first in adoption order: a paused grandchild of the first child before a paused second child', async () => { + const rootId = await startRoot(); + const first = await adopt(dependencies.mockProxyManager, rootId, 1001); + const second = await adopt(dependencies.mockProxyManager, rootId, 1002); + const grandchild = await adopt(first.view, first.id, 1003); + second.view.simulateStopped(1, 'breakpoint', { reason: 'breakpoint', threadId: 1, allThreadsStopped: true }); + grandchild.view.simulateStopped(1, 'breakpoint', { reason: 'breakpoint', threadId: 1, allThreadsStopped: true }); + await afterStop(); + + const result = await sessionManager.waitForStop(rootId); + + expect(result.data?.sessionId).toBe(grandchild.id); + expect(result.data?.children?.map((c) => c.sessionId)).toEqual([first.id, grandchild.id, second.id]); + expect(result.data?.children?.map((c) => c.parentSessionId)).toEqual([rootId, first.id, rootId]); + }); + + it("answers the parent's own end once every child has ended too (rule 3), the children listed as stopped", async () => { + const rootId = await startRoot(); + const child = await adopt(dependencies.mockProxyManager, rootId, 4242); + const wait = track(sessionManager.waitForStop(rootId, 10_000)); + + child.view.simulateEvent('terminated'); + await afterStop(); + dependencies.mockProxyManager.simulateExited(0); + await afterStop(); + + const result = wait.result(); + expect(result).toMatchObject({ success: true, state: SessionState.STOPPED }); + expect(result?.data?.sessionId).toBe(rootId); + expect(result?.data?.exitCode).toBe(0); + expect(result?.data?.children).toEqual([expect.objectContaining({ sessionId: child.id, state: SessionState.STOPPED })]); + }); + + it("a child's end alone never completes the parent's wait (rule 4); the parent's end then does", async () => { + const rootId = await startRoot(); + const child = await adopt(dependencies.mockProxyManager, rootId, 4242); + const wait = track(sessionManager.waitForStop(rootId, 10_000)); + + child.view.simulateEvent('terminated'); + await vi.advanceTimersByTimeAsync(500); + expect(sessionManager.getSession(child.id)!.state).toBe(SessionState.STOPPED); + expect(wait.result()).toBeUndefined(); + + dependencies.mockProxyManager.simulateExited(0); + await afterStop(); + expect(wait.result()).toMatchObject({ success: true, state: SessionState.STOPPED }); + expect(wait.result()?.data?.sessionId).toBe(rootId); + }); + + it('answers a paused child of a parent that has already ended (rule 2 before rule 3)', async () => { + const rootId = await startRoot(); + const child = await adopt(dependencies.mockProxyManager, rootId, 4242); + dependencies.mockProxyManager.simulateEvent('terminated'); + await afterStop(); + expect(sessionManager.getSession(rootId)!.state).toBe(SessionState.STOPPED); + child.view.simulateStopped(1, 'breakpoint', { reason: 'breakpoint', threadId: 1, allThreadsStopped: true }); + await afterStop(); + + const result = await sessionManager.waitForStop(rootId); + + expect(result.state).toBe(SessionState.PAUSED); + expect(result.data?.sessionId).toBe(child.id); + }); + + it('observes a child adopted while it waits', async () => { + const rootId = await startRoot(); + const wait = track(sessionManager.waitForStop(rootId, 10_000)); + await vi.advanceTimersByTimeAsync(50); + + // Reported and adopted under the wait (no runAllTimers: that would fire the wait's own timeout). + dependencies.mockProxyManager.simulateEvent('child-reported', report('r-4242', 4242)); + await vi.advanceTimersByTimeAsync(50); + const child = sessionManager.getAllSessions().find((s) => s.parentSessionId === rootId)!; + expect(child).toBeDefined(); + expect(wait.result()).toBeUndefined(); + const view = dependencies.mockProxyManager.childViews.get(child.id)!; + view.simulateStopped(1, 'breakpoint', { reason: 'breakpoint', threadId: 1, allThreadsStopped: true }); + await afterStop(); + + expect(wait.result()?.data?.sessionId).toBe(child.id); + expect(wait.result()?.data?.lastStop).toMatchObject({ reason: 'breakpoint' }); + expect(wait.result()?.data?.children).toEqual([expect.objectContaining({ sessionId: child.id, state: SessionState.PAUSED })]); + }); + + it('a descendant closed while it waits is one descendant fewer, not the end of the wait', async () => { + const rootId = await startRoot(); + const child = await adopt(dependencies.mockProxyManager, rootId, 4242); + const wait = track(sessionManager.waitForStop(rootId, 10_000)); + await vi.advanceTimersByTimeAsync(50); + + await sessionManager.closeSession(child.id); + await afterStop(); + expect(wait.result()).toBeUndefined(); + + dependencies.mockProxyManager.simulateStopped(1, 'breakpoint'); + await afterStop(); + expect(wait.result()).toMatchObject({ success: true, state: SessionState.PAUSED }); + expect(wait.result()?.data?.sessionId).toBe(rootId); + expect(wait.result()?.data?.children).toBeUndefined(); + }); + + it('a wait on a child answers for that child and its own descendants only', async () => { + const rootId = await startRoot(); + const child = await adopt(dependencies.mockProxyManager, rootId, 4242); + const grandchild = await adopt(child.view, child.id, 4343); + dependencies.mockProxyManager.simulateStopped(1, 'breakpoint'); + grandchild.view.simulateStopped(1, 'breakpoint', { reason: 'breakpoint', threadId: 1, allThreadsStopped: true }); + await afterStop(); + + const result = await sessionManager.waitForStop(child.id); + + expect(result.data?.sessionId).toBe(grandchild.id); + expect(result.data?.children).toEqual([ + { sessionId: grandchild.id, name: 'Subprocess 4343', state: SessionState.PAUSED, parentSessionId: child.id } + ]); + }); + + it('reselects the ended parent if the selected child resumes during its landing read', async () => { + const rootId = await startRoot(); + const child = await adopt(dependencies.mockProxyManager, rootId, 4242); + dependencies.mockProxyManager.simulateEvent('terminated'); + child.view.simulateStopped(1, 'breakpoint', { reason: 'breakpoint', threadId: 1, allThreadsStopped: true }); + const wait = track(sessionManager.waitForStop(rootId, 10_000)); + await vi.advanceTimersByTimeAsync(0); + + await sessionManager.continue(child.id); + await afterStop(); + + expect(wait.result()).toMatchObject({ + success: true, + state: SessionState.STOPPED, + data: { sessionId: rootId } + }); + expect(wait.result()?.data?.pending).toBeUndefined(); + }); + + it('keeps waiting if the selected child is closed during its landing read', async () => { + const rootId = await startRoot(); + const child = await adopt(dependencies.mockProxyManager, rootId, 4242); + child.view.simulateStopped(1, 'breakpoint', { reason: 'breakpoint', threadId: 1, allThreadsStopped: true }); + const wait = track(sessionManager.waitForStop(rootId, 10_000)); + await vi.advanceTimersByTimeAsync(0); + + await sessionManager.closeSession(child.id); + await afterStop(); + expect(wait.result()).toBeUndefined(); + + dependencies.mockProxyManager.simulateStopped(1, 'breakpoint'); + await afterStop(); + expect(wait.result()).toMatchObject({ success: true, state: SessionState.PAUSED, data: { sessionId: rootId } }); + }); + + it('keeps the original deadline when a selected child resumes during the landing read', async () => { + const rootId = await startRoot(); + const child = await adopt(dependencies.mockProxyManager, rootId, 4242); + child.view.simulateStopped(1, 'breakpoint', { reason: 'breakpoint', threadId: 1, allThreadsStopped: true }); + const wait = track(sessionManager.waitForStop(rootId, 100)); + await vi.advanceTimersByTimeAsync(5); + await sessionManager.continue(child.id); + + await vi.advanceTimersByTimeAsync(94); + expect(wait.result()).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1); + expect(wait.result()).toMatchObject({ + success: true, + state: SessionState.RUNNING, + data: { sessionId: rootId, pending: true } + }); + }); + + it('still honors cancellation after a selected child resumes during the landing read', async () => { + const rootId = await startRoot(); + const child = await adopt(dependencies.mockProxyManager, rootId, 4242); + child.view.simulateStopped(1, 'breakpoint', { reason: 'breakpoint', threadId: 1, allThreadsStopped: true }); + const controller = new AbortController(); + const wait = track(sessionManager.waitForStop(rootId, 10_000, controller.signal)); + await vi.advanceTimersByTimeAsync(0); + await sessionManager.continue(child.id); + await afterStop(); + expect(wait.result()).toBeUndefined(); + + controller.abort(); + await vi.advanceTimersByTimeAsync(0); + expect(wait.result()).toMatchObject({ + success: true, + state: SessionState.RUNNING, + data: { sessionId: rootId, pending: true } + }); + }); + + it('does not select another paused child after alternating children exhaust the original deadline', async () => { + const rootId = await startRoot(); + const first = await adopt(dependencies.mockProxyManager, rootId, 4242); + const second = await adopt(dependencies.mockProxyManager, rootId, 4343); + first.view.simulateStopped(1, 'breakpoint', { reason: 'breakpoint', threadId: 1, allThreadsStopped: true }); + second.view.simulateStopped(1, 'breakpoint', { reason: 'breakpoint', threadId: 1, allThreadsStopped: true }); + const wait = track(sessionManager.waitForStop(rootId, 15)); + + // The first landing finishes at 10 ms and reselects the second child. + await vi.advanceTimersByTimeAsync(5); + await sessionManager.continue(first.id); + await vi.advanceTimersByTimeAsync(10); + // The second landing finishes at 20 ms. The first child is paused again, + // but another selection now would restart work beyond the 15 ms budget. + first.view.simulateStopped(1, 'breakpoint', { reason: 'breakpoint', threadId: 1, allThreadsStopped: true }); + await sessionManager.continue(second.id); + await vi.advanceTimersByTimeAsync(5); + + expect(wait.result()).toMatchObject({ + success: true, + state: SessionState.RUNNING, + data: { + sessionId: rootId, + pending: true, + children: [ + expect.objectContaining({ sessionId: first.id, state: SessionState.PAUSED }), + expect.objectContaining({ sessionId: second.id, state: SessionState.RUNNING }) + ] + } + }); + }); + + it('preserves ended-at-settle for a selected child that naturally ends during the landing read', async () => { + const rootId = await startRoot(); + const child = await adopt(dependencies.mockProxyManager, rootId, 4242); + const wait = track(sessionManager.waitForStop(rootId, 10_000)); + child.view.simulateStopped(1, 'exception', { reason: 'exception', threadId: 1, allThreadsStopped: true }); + await vi.advanceTimersByTimeAsync(0); + + child.view.simulateEvent('terminated'); + await afterStop(); + + expect(wait.result()).toMatchObject({ + success: true, + state: SessionState.STOPPED, + data: { sessionId: child.id, lastStop: { reason: 'exception' } } + }); + expect(wait.result()?.data?.location).toBeUndefined(); + expect(sessionManager.getSession(child.id)).toBeDefined(); + expect(sessionManager.getSession(rootId)?.state).toBe(SessionState.RUNNING); + }); + + it('the pending answer carries the children and names the session asked for', async () => { + const rootId = await startRoot(); + const child = await adopt(dependencies.mockProxyManager, rootId, 4242); + const wait = track(sessionManager.waitForStop(rootId, 1_000)); + + await vi.advanceTimersByTimeAsync(1_050); + + const result = wait.result(); + expect(result).toMatchObject({ success: true, state: SessionState.RUNNING }); + expect(result?.data?.pending).toBe(true); + expect(result?.data?.sessionId).toBe(rootId); + expect(result?.data?.children).toEqual([expect.objectContaining({ sessionId: child.id, state: SessionState.RUNNING })]); + }); + + it("an errored child is reported for the parent's wait, naming the child", async () => { + const rootId = await startRoot(); + const child = await adopt(dependencies.mockProxyManager, rootId, 4242); + const wait = track(sessionManager.waitForStop(rootId, 10_000)); + + child.view.simulateError(new Error('connection lost')); + await afterStop(); + + const result = wait.result(); + expect(result).toMatchObject({ success: true, state: SessionState.ERROR }); + expect(result?.data?.sessionId).toBe(child.id); + expect(result?.data?.message).toMatch(/connection lost/); + expect(sessionManager.getSession(rootId)!.state).toBe(SessionState.RUNNING); + }); + + it('still refuses a wait on a session that was closed while waiting', async () => { + const rootId = await startRoot(); + const wait = track(sessionManager.waitForStop(rootId, 10_000)); + await vi.advanceTimersByTimeAsync(50); + + await sessionManager.closeSession(rootId); + await afterStop(); + + expect(wait.result()?.success).toBe(false); + expect(wait.result()?.error).toMatch(/closed while waiting/); + }); +}); diff --git a/tests/core/unit/session/session-state-wait.test.ts b/tests/core/unit/session/session-state-wait.test.ts index d5c674359..39c273a98 100644 --- a/tests/core/unit/session/session-state-wait.test.ts +++ b/tests/core/unit/session/session-state-wait.test.ts @@ -8,6 +8,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { SessionState, isTerminalSessionState } from '@debugmcp/shared'; import { waitForSessionState, + waitForSubtreeSettle, + type SettleWaitResult, type SessionStateWaitOutcome } from '../../../../src/session/execution/session-state-wait.js'; @@ -154,3 +156,99 @@ describe('waitForSessionState', () => { expect(h.outcome()).toBe('gone'); }); }); + +/** + * waitForSubtreeSettle: the same wait over a session and its descendants (the + * session tree, issue #1081). It re-reads the tree's selection after every + * change to the session or any descendant, with the same microtask deferral, + * and resolves with the session the selection names. + */ +function treeHarness() { + type Node = { id: string; state: SessionState; parentSessionId?: string }; + const nodes = new Map([['root', { id: 'root', state: SessionState.RUNNING }]]); + const listeners = new Set<() => void>(); + const notify = () => { + for (const listener of [...listeners]) listener(); + }; + const ctx = { + getSession: (sessionId: string) => { + const found = nodes.get(sessionId); + if (!found) throw new Error(`Managed session not found: ${sessionId}`); + return found; + }, + onSubtreeChange: (_sessionId: string, listener: () => void) => { + listeners.add(listener); + return () => { listeners.delete(listener); }; + }, + descendantsOf: (sessionId: string) => [...nodes.values()].filter((n) => n.parentSessionId === sessionId) + }; + let result: SettleWaitResult | undefined; + return { + wait: (timeoutMs: number) => { + void waitForSubtreeSettle(ctx as never, 'root', { timeoutMs }) + .then((settled) => { result = settled as SettleWaitResult; }); + }, + result: () => result, + adopt: (id: string, state: SessionState) => { nodes.set(id, { id, state, parentSessionId: 'root' }); notify(); }, + setState: (id: string, state: SessionState) => { nodes.get(id)!.state = state; notify(); }, + remove: (id: string) => { nodes.delete(id); notify(); }, + listenerCount: () => listeners.size + }; +} + +describe('waitForSubtreeSettle', () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + it('resolves with the descendant that paused after the wait began, and unsubscribes', async () => { + const h = treeHarness(); + h.wait(30_000); + h.adopt('child', SessionState.RUNNING); + await settleMicrotasks(); + expect(h.result()).toBeUndefined(); + h.setState('child', SessionState.PAUSED); + await settleMicrotasks(); + expect(h.result()).toEqual({ outcome: 'matched', value: expect.objectContaining({ id: 'child' }) }); + expect(h.listenerCount()).toBe(0); + }); + + it('does not report a descendant state entered and left within one call stack', async () => { + const h = treeHarness(); + h.adopt('child', SessionState.RUNNING); + h.wait(30_000); + h.setState('child', SessionState.PAUSED); + h.setState('child', SessionState.RUNNING); + await settleMicrotasks(); + expect(h.result()).toBeUndefined(); + expect(h.listenerCount()).toBe(1); + }); + + it('treats a descendant that is removed as a change, not as the wait being gone', async () => { + const h = treeHarness(); + h.adopt('child', SessionState.RUNNING); + h.wait(30_000); + h.remove('child'); + await settleMicrotasks(); + expect(h.result()).toBeUndefined(); + h.setState('root', SessionState.PAUSED); + await settleMicrotasks(); + expect(h.result()).toEqual({ outcome: 'matched', value: expect.objectContaining({ id: 'root' }) }); + }); + + it('reports the asked session being removed as gone', async () => { + const h = treeHarness(); + h.wait(30_000); + h.remove('root'); + await settleMicrotasks(); + expect(h.result()).toEqual({ outcome: 'gone' }); + expect(h.listenerCount()).toBe(0); + }); + + it('times out like the single-session wait', async () => { + const h = treeHarness(); + h.wait(1_000); + await vi.advanceTimersByTimeAsync(1_000); + expect(h.result()).toEqual({ outcome: 'timeout' }); + expect(h.listenerCount()).toBe(0); + }); +}); diff --git a/tests/core/unit/session/tree-settle.test.ts b/tests/core/unit/session/tree-settle.test.ts new file mode 100644 index 000000000..2197bab0c --- /dev/null +++ b/tests/core/unit/session/tree-settle.test.ts @@ -0,0 +1,70 @@ +/** + * selectSettledInTree: which session a tree-aware wait_for_stop answers for + * (the session tree, issue #1081; docs/architecture/session-tree.md, "wait_for_stop + * is tree-aware"). A pure read over the session and its descendants in the + * rule-2 order (depth first, adoption order within a level): + * 1. the session itself when it is paused or in error; + * 2. else the first descendant that is paused or in error; + * 3. else the session itself when it has ended; + * 4. else nothing — only descendants have ended and the session still runs. + */ +import { describe, it, expect } from 'vitest'; +import { SessionState } from '@debugmcp/shared'; +import { selectSettledInTree } from '../../../../src/session/execution/tree-settle.js'; + +const node = (id: string, state: SessionState) => ({ id, state }); + +describe('selectSettledInTree', () => { + it('answers a paused descendant for a running session (rule 2)', () => { + const parent = node('p', SessionState.RUNNING); + const child = node('c', SessionState.PAUSED); + expect(selectSettledInTree(parent, [child])?.id).toBe('c'); + }); + + it('answers the session itself before its paused descendants (rule 1)', () => { + const parent = node('p', SessionState.PAUSED); + expect(selectSettledInTree(parent, [node('c', SessionState.PAUSED)])?.id).toBe('p'); + }); + + it('counts an errored session as settled (rule 1) and an errored descendant too (rule 2)', () => { + expect(selectSettledInTree(node('p', SessionState.ERROR), [])?.id).toBe('p'); + expect(selectSettledInTree(node('p', SessionState.RUNNING), [node('c', SessionState.ERROR)])?.id).toBe('c'); + }); + + it('takes the first settled descendant in the order given — a paused grandchild before a paused later child', () => { + const descendants = [ + node('c1', SessionState.RUNNING), + node('g1', SessionState.PAUSED), + node('c2', SessionState.PAUSED) + ]; + expect(selectSettledInTree(node('p', SessionState.RUNNING), descendants)?.id).toBe('g1'); + }); + + it('skips descendants that have ended', () => { + const descendants = [node('c1', SessionState.STOPPED), node('c2', SessionState.PAUSED)]; + expect(selectSettledInTree(node('p', SessionState.RUNNING), descendants)?.id).toBe('c2'); + }); + + it('answers the session itself when it has ended and nothing below is paused (rule 3)', () => { + const parent = node('p', SessionState.STOPPED); + expect(selectSettledInTree(parent, [node('c', SessionState.STOPPED)])?.id).toBe('p'); + expect(selectSettledInTree(parent, [])?.id).toBe('p'); + }); + + it('answers a paused child of an ended session (rule 2 before rule 3)', () => { + const parent = node('p', SessionState.STOPPED); + expect(selectSettledInTree(parent, [node('c', SessionState.PAUSED)])?.id).toBe('c'); + }); + + it('keeps waiting when only descendants have ended and the session still runs (rule 4)', () => { + const parent = node('p', SessionState.RUNNING); + expect(selectSettledInTree(parent, [node('c', SessionState.STOPPED)])).toBeUndefined(); + }); + + it('keeps waiting for a running, initializing or created session with nothing settled below', () => { + for (const state of [SessionState.RUNNING, SessionState.INITIALIZING, SessionState.CREATED, SessionState.READY]) { + expect(selectSettledInTree(node('p', state), [node('c', SessionState.RUNNING)])).toBeUndefined(); + expect(selectSettledInTree(node('p', state), [])).toBeUndefined(); + } + }); +}); diff --git a/tests/test-utils/helpers/operations-internals.ts b/tests/test-utils/helpers/operations-internals.ts index 975c97a1e..25ea0a4d0 100644 --- a/tests/test-utils/helpers/operations-internals.ts +++ b/tests/test-utils/helpers/operations-internals.ts @@ -50,7 +50,7 @@ export interface OperationsInternals { /** The late-bound view the collaborators get; a test subscribes to state changes through it. */ opsContext: OperationsContext; /** Protected on SessionManagerCore; spied on to model a concurrent close during teardown. */ - stopProxyPreservingSession(session: ManagedSession): Promise; + stopProxyPreservingSession(session: ManagedSession): Promise; } /** The protected collaborators of an operations facade, for a test to drive. */ diff --git a/tests/test-utils/mocks/mock-proxy-manager.ts b/tests/test-utils/mocks/mock-proxy-manager.ts index d982a3b37..86fcf640e 100644 --- a/tests/test-utils/mocks/mock-proxy-manager.ts +++ b/tests/test-utils/mocks/mock-proxy-manager.ts @@ -4,7 +4,7 @@ import { EventEmitter } from 'events'; import { DebugProtocol } from '@vscode/debugprotocol'; import { IProxyManager, ProxyManagerEvents, type PreDetachResume } from '../../../src/proxy/proxy-manager.js'; -import type { ChildAdoptResult, IChildProxyView } from '../../../src/proxy/child-proxy-view.js'; +import type { ChildAdoptResult, ChildStopOptions, IChildProxyView } from '../../../src/proxy/child-proxy-view.js'; import type { ChildAdoptArgs } from '../../../src/proxy/dap-proxy-interfaces.js'; import type { ProxyMessage } from '../../../src/dap-core/types.js'; import { ProxyConfig } from '../../../src/proxy/proxy-config.js'; @@ -281,6 +281,8 @@ export class MockProxyManager extends EventEmitter implements IProxyManager { */ export class MockChildProxyView extends MockProxyManager implements IChildProxyView { public adoptCalls: ChildAdoptArgs[] = []; + /** How each stop() asked for the connection to be closed (undefined: the default, ending the child). */ + public stopOptions: Array = []; public shouldFailAdopt = false; /** When set, adopt() waits for resolveAdopt() instead of resolving on its own. */ public holdAdopt = false; @@ -313,8 +315,9 @@ export class MockChildProxyView extends MockProxyManager implements IChildProxyV this.adoptResolve = null; } - async stop(): Promise { + async stop(options?: ChildStopOptions): Promise { this.stopCalls++; + this.stopOptions.push(options); this._isRunning = false; } diff --git a/tests/unit/adapter-python/python-debug-adapter.test.ts b/tests/unit/adapter-python/python-debug-adapter.test.ts index 78e539522..d311f1509 100644 --- a/tests/unit/adapter-python/python-debug-adapter.test.ts +++ b/tests/unit/adapter-python/python-debug-adapter.test.ts @@ -446,7 +446,7 @@ describe('PythonDebugAdapter', () => { // debugpy's sub-process debugging reports every Python child the program // starts, and the server adopts each report as a child debug session (the // session tree, issue #1081): the transforms keep it off unless asked, until - // the tree-aware wait_for_stop lands. + // the last step of #1081 flips the default. describe('sub-process debugging default (issues #1005/#1081)', () => { it('turns subProcess off for a launch that does not name it', async () => { const adapter = new PythonDebugAdapter(createDependencies()); @@ -464,6 +464,7 @@ describe('PythonDebugAdapter', () => { expect(diagnostics[0].key).toBe('subProcess'); expect(diagnostics[0].scope).toBeUndefined(); expect(diagnostics[0].message).toMatch(/child debug session/); + expect(diagnostics[0].message).toMatch(/wait_for_stop on this session reports the child/); expect(diagnostics[0].message).toMatch(/#1081/); }); diff --git a/tests/unit/proxy/child-proxy-view.test.ts b/tests/unit/proxy/child-proxy-view.test.ts index 28281fe1b..98b8b904a 100644 --- a/tests/unit/proxy/child-proxy-view.test.ts +++ b/tests/unit/proxy/child-proxy-view.test.ts @@ -174,6 +174,14 @@ describe('ChildProxyView connection lifecycle and event contract', () => { expect(send).toHaveBeenCalledTimes(2); }); + it('closes its connection without ending the child when asked to release it (a detach cascade, issue #1081)', async () => { + await view.adopt({ reportId: 'report' }); + await view.stop({ terminateDebuggee: false }); + expect(send).toHaveBeenLastCalledWith('child', CHILD_CLOSE_COMMAND, { terminateDebuggee: false }, { timeoutMs: 5000 }); + expect(root.detachChildView).toHaveBeenCalledWith('child'); + expect(view.isRunning()).toBe(false); + }); + it('detaches an unused view without sending a close request', async () => { await view.stop(); expect(send).not.toHaveBeenCalled(); diff --git a/tests/unit/server-coverage.test.ts b/tests/unit/server-coverage.test.ts index f6ba8928f..e3d9133f9 100644 --- a/tests/unit/server-coverage.test.ts +++ b/tests/unit/server-coverage.test.ts @@ -46,6 +46,7 @@ describe('Server Coverage - Error Paths and Edge Cases', () => { getSessionPolicy: vi.fn().mockReturnValue({}), createSession: vi.fn(), closeSession: vi.fn(), + closeSessionReporting: vi.fn(), closeAllSessions: vi.fn(), getAllSessions: vi.fn(), setBreakpoint: vi.fn(), diff --git a/tests/unit/session-manager-operations-coverage.test.ts b/tests/unit/session-manager-operations-coverage.test.ts index 8385a4dce..07ed0e55b 100644 --- a/tests/unit/session-manager-operations-coverage.test.ts +++ b/tests/unit/session-manager-operations-coverage.test.ts @@ -1248,11 +1248,12 @@ describe('Session Manager Operations Coverage - Error Paths and Edge Cases', () // concurrent close_debug_session lands in: model it by removing the // session from the store while stopProxyPreservingSession is in flight. vi.spyOn(internals(operations), 'stopProxyPreservingSession') - .mockImplementation(async (session: ManagedSession) => { + .mockImplementation(async (session: ManagedSession): Promise => { session.proxyManager = undefined; mockSessionStore.getOrThrow.mockImplementation((sessionId: string) => { throw new SessionNotFoundError(sessionId); }); + return 0; }); const result = await operations.startDebugging('test-session', 'test.py'); @@ -2149,11 +2150,12 @@ describe('Session Manager Operations Coverage - Error Paths and Edge Cases', () throw new Error('Timeout waiting for proxy initialization'); }); vi.spyOn(internals(operations), 'stopProxyPreservingSession') - .mockImplementation(async (session: ManagedSession) => { + .mockImplementation(async (session: ManagedSession): Promise => { session.proxyManager = undefined; mockSessionStore.getOrThrow.mockImplementation((sessionId: string) => { throw new SessionNotFoundError(sessionId); }); + return 0; }); const result = await operations.attachToProcess('test-session', { @@ -2212,8 +2214,9 @@ describe('Session Manager Operations Coverage - Error Paths and Edge Cases', () // Teardown (which only clears the proxy handle) must still run on the // failure path; the diagnostics come from the error and logDir. const stopSpy = vi.spyOn(internals(operations), 'stopProxyPreservingSession') - .mockImplementation(async (session: ManagedSession) => { + .mockImplementation(async (session: ManagedSession): Promise => { session.proxyManager = undefined; + return 0; }); const result = await operations.attachToProcess('test-session', {