diff --git a/docs/tools/debug.md b/docs/tools/debug.md index ebb874f2c..e0670b49f 100644 --- a/docs/tools/debug.md +++ b/docs/tools/debug.md @@ -122,19 +122,19 @@ Side-channel artifacts outside the model tool result: 1. Tool registration is conditional: `DebugTool.createIf()` in `packages/coding-agent/src/tools/debug.ts` returns `null` unless `session.settings.get("debug.enabled")` is true. `packages/coding-agent/src/tools/index.ts` wires the factory and rechecks the same setting in tool filtering. 2. `DebugTool.execute()` clamps `params.timeout` through `clampTimeout("debug", params.timeout)` and composes the caller `AbortSignal` with `AbortSignal.timeout(...)`. 3. `launch` and `attach` resolve cwd/program paths, select an adapter in `packages/coding-agent/src/dap/config.ts`, then delegate to `dapSessionManager.launch()` / `.attach()`. -4. `DapSessionManager.launch()` / `.attach()` enforce the single-session rule with `#ensureLaunchSlot()`, spawn the adapter through `DapClient.spawn()`, register listeners, send `initialize`, cache capabilities, start listening for an initial stop event before sending `launch`/`attach`, then complete the `initialized` → `configurationDone` handshake in `#completeConfigurationHandshake()`. -5. `DapClient.spawn()` starts the adapter detached with `NON_INTERACTIVE_ENV`. Most adapters use stdio; socket-mode adapters (`dlv`) use `#spawnSocketUnix()` on Linux or `#spawnSocketClientAddr()` on macOS/other. +4. `DapSessionManager.launch()` / `.attach()` enforce one root session, spawn the adapter through `DapClient.spawn()`, register listeners, send `initialize`, cache capabilities, subscribe for tree-wide stop events, send `launch`/`attach`, then complete the `initialized` → `configurationDone` handshake. +5. `DapClient.spawn()` starts adapters detached with `NON_INTERACTIVE_ENV`. Most adapters use stdio; socket-mode adapters (`dlv`) use an adapter-specific Unix/TCP transport, while TCP server adapters start with `${port}` substituted in their args. Child sessions reuse the root TCP server through `DapClient.connect()`. 6. `#registerSession()` in `packages/coding-agent/src/dap/session.ts` installs reverse-request handlers: - `runInTerminal`: spawns the requested debuggee command detached via `ptree.spawn()` and returns `{ processId }` - - `startDebugging`: logs the child-session request and returns `{}`; it does not create nested sessions - - events: `output`, `initialized`, `stopped`, `continued`, `exited`, `terminated` update cached session state -7. Operational actions (`set_breakpoint`, `evaluate`, `threads`, `read_memory`, `custom_request`, and similar) call `dapSessionManager` methods. Most flow through `#sendRequestWithConfig()`, which first sends `configurationDone` when required, then sends the DAP request, then updates `lastUsedAt`. -8. Breakpoint actions maintain local cached breakpoint sets in `DapSessionManager` and remap adapter responses back onto those cached records. -9. `continue` and the three step actions clear cached stop state, subscribe for `stopped`/`terminated`/`exited` before sending the DAP request, then `#awaitStopOutcome()` either returns the new stopped location or reports that the program is still running after timeout. + - `startDebugging`: connects a child DAP client to the root TCP server, forwards the requested `launch`/`attach` configuration, binds root breakpoints before `configurationDone`, and recursively installs the same handlers + - events: `output`, `initialized`, `stopped`, `continued`, `exited`, and `terminated` update cached session state; stopped children become the active target +7. Operational actions (`set_breakpoint`, `evaluate`, `threads`, `read_memory`, `custom_request`, and similar) call `dapSessionManager` methods. Most flow through `#sendRequestWithConfig()`, which first sends `configurationDone` when required, then sends the DAP request and refreshes the active session plus its ancestors. +8. Breakpoint actions synchronize desired breakpoint sets across the live root/child tree. New children receive those sets before their `configurationDone` request. +9. `continue` and the three step actions clear cached stop state, subscribe for a stop/termination event anywhere in the session tree before sending the DAP request, then `#awaitStopOutcome()` returns the active child’s stopped location or reports that the target remains running after timeout. 10. `pause` sends DAP `pause`, waits for a stopped event if needed, and reuses cached stop state if the program was already stopped. -11. `stack_trace`, `scopes`, `variables`, and `evaluate` default to the current stopped thread/frame when the caller omits ids and cached state is available. -12. `output` reads the in-memory output ring from `DapSessionManager.getOutput()`. `terminate` sends `terminate` when supported, always attempts `disconnect`, marks the session terminated, and disposes the client. -13. `sessions` reads the manager’s current map and formats all summaries. Although the manager stores a map, only one active session can exist because new launch/attach calls are blocked until the active one is terminated or cleaned up. +11. `stack_trace`, `scopes`, `variables`, and `evaluate` default to the current stopped child/thread/frame when the caller omits ids and cached state is available. +12. `output` reads the in-memory output ring from the active `DapSession`. `terminate` walks from the root through every child, sends best-effort `terminate`/`disconnect`, and disposes the complete tree even when an adapter times out. +13. `sessions` reads the manager’s current map and formats root and child summaries. Only one root tree can exist; recursive adapter-requested children are tracked with `parentSessionId` / `childSessionIds`. 14. The interactive selector in `packages/coding-agent/src/debug/index.ts` builds a `SelectList` of fixed values and dispatches each to a handler: - `performance`: `startCpuProfile()`, wait for Enter/Escape, stop profiling, read a 30-second work profile with `getWorkProfile(30)`, then bundle via `createReportBundle()` - `work`: read `getWorkProfile(30)`, write a temp SVG, open it externally @@ -320,12 +320,13 @@ Example `.omp/dap.json`: - `collectSystemInfo()` is best-effort for CPU probing; failure there falls back to `Unknown CPU`. ## Notes -- `packages/coding-agent/src/prompts/tools/debug.md` tells the model only one active session is supported; that is not advisory, it is enforced in code. -- `configurationDone` is sent automatically both during launch/attach handshake and lazily before later requests if the adapter required it and the initial handshake did not complete. -- `startDebugging` reverse requests are acknowledged but not implemented; child debug sessions are not spawned. -- `output` exposes the merged `output` event stream only; the tool does not distinguish stdout, stderr, and console categories. -- Session summaries expose `needsConfigurationDone`; this is derived from adapter capabilities and whether `configurationDone` has been sent. -- Source breakpoint file paths are normalized with `path.resolve()` before caching and sending to the adapter. +- `packages/coding-agent/src/prompts/tools/debug.md` tells the model only one active root session is supported. Adapter-requested child sessions belong to that root tree. +- The default JavaScript/TypeScript adapter runs vscode-js-debug’s `dapDebugServer.js` over TCP. Install it with Mason or set `JS_DEBUG_DAP_SERVER` to a release-tarball server path. +- `configurationDone` is sent automatically during root and child launch/attach handshakes and lazily before later requests if the initial handshake did not complete. +- `startDebugging` reverse requests create recursive child sessions on the same TCP server; a stopped child becomes the target for thread-level actions. +- `output` exposes the active session’s merged `output` event stream only; the tool does not distinguish stdout, stderr, and console categories. +- Session summaries expose `needsConfigurationDone`, `parentSessionId`, and `childSessionIds`. +- Source breakpoint file paths are normalized with `path.resolve()` before caching and synchronizing across the tree. - `evaluate` defaults to `repl`, so the tool can forward raw debugger commands when the adapter supports them. - `disassemble` resolves its target from `memory_reference` first, then the current stopped session's `instructionPointerReference`; it throws if neither is present. - `RawSseDebugBuffer.recordEvent()` increments `totalEvents` before bounded retention. A snapshot can therefore show fewer retained records than total observed events. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4522715df..ef37634a6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed JavaScript/TypeScript debugging by launching vscode-js-debug over TCP, handling recursive `startDebugging` child sessions, synchronizing breakpoints across the session tree, and terminating every child connection ([#5984](https://github.com/can1357/oh-my-pi/issues/5984)). + ## [17.0.4] - 2026-07-18 ### Fixed diff --git a/packages/coding-agent/src/dap/client.ts b/packages/coding-agent/src/dap/client.ts index 7d9c8446b..c75879eca 100644 --- a/packages/coding-agent/src/dap/client.ts +++ b/packages/coding-agent/src/dap/client.ts @@ -55,6 +55,8 @@ export class DapClient { readonly adapter: DapResolvedAdapter; readonly cwd: string; readonly proc: DapClientState["proc"]; + /** TCP server port reused by child DAP sessions. */ + readonly port?: number; /** ReadableStream of DAP bytes — from proc.stdout (stdio) or a socket (socket mode). */ readonly #readable: ReadableStream; /** Write sink — proc.stdin (stdio) or a socket (socket mode). */ @@ -78,7 +80,12 @@ export class DapClient { adapter: DapResolvedAdapter, cwd: string, proc: DapClientState["proc"], - options?: { readable?: ReadableStream; writeSink?: DapWriteSink; socket?: { end(): void } }, + options?: { + readable?: ReadableStream; + writeSink?: DapWriteSink; + socket?: { end(): void }; + port?: number; + }, ) { this.adapter = adapter; this.cwd = cwd; @@ -86,6 +93,7 @@ export class DapClient { this.#readable = options?.readable ?? (proc.stdout as ReadableStream); this.#writeSink = options?.writeSink ?? proc.stdin; this.#socket = options?.socket; + this.port = options?.port; this.proc.exited.then( () => this.#rejectPendingWritesForExit(), () => this.#rejectPendingWritesForExit(), @@ -96,6 +104,9 @@ export class DapClient { if (adapter.connectMode === "socket") { return DapClient.#spawnSocket({ adapter, cwd, socketReadyTimeoutMs }); } + if (adapter.connectMode === "tcp") { + return DapClient.#spawnTcp({ adapter, cwd, socketReadyTimeoutMs }); + } // Merge non-interactive env and start in a new session (detached → setsid) // so the adapter process tree has no controlling terminal. Without this, // debuggee children can reach /dev/tty and trigger SIGTTIN, suspending @@ -118,6 +129,85 @@ export class DapClient { return client; } + /** Connect to another session on an existing TCP DAP server. */ + static async connect({ + adapter, + cwd, + host, + port, + }: { + adapter: DapResolvedAdapter; + cwd: string; + host: string; + port: number; + }): Promise { + const exited = Promise.withResolvers(); + const { readable, writeSink, socket } = await connectTcpSocket(host, port, () => exited.resolve()); + const proc = { + exited: exited.promise, + exitCode: null, + stdin: { write: () => 0, flush: () => undefined }, + stdout: new ReadableStream(), + stderr: new ReadableStream(), + peekStderr: () => "", + kill: () => { + exited.resolve(); + return true; + }, + } as unknown as DapClientState["proc"]; + const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket, port }); + exited.promise.then(() => client.#handleProcessExit()); + void client.#startMessageReader(); + return client; + } + + /** Spawn an adapter that listens on a caller-selected TCP port. */ + static async #spawnTcp({ adapter, cwd, socketReadyTimeoutMs }: DapSpawnOptions): Promise { + const host = "127.0.0.1"; + const reservation = Bun.listen({ + hostname: host, + port: 0, + socket: { + open() {}, + data() {}, + close() {}, + error() {}, + }, + }); + const port = reservation.port; + reservation.stop(true); + const args = adapter.args.map(arg => arg.replaceAll("$" + "{port}", String(port))); + const proc = ptree.spawn([adapter.resolvedCommand, ...args], { + cwd, + stdin: "pipe", + env: { + ...Bun.env, + ...NON_INTERACTIVE_ENV, + }, + detached: true, + }); + + try { + const { readable, writeSink, socket } = await waitForTcpTransport( + host, + port, + socketReadyTimeoutMs ?? SOCKET_READY_TIMEOUT_MS, + proc, + ); + const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket, port }); + proc.exited.then(() => client.#handleProcessExit()); + void client.#startMessageReader(); + return client; + } catch (error) { + try { + proc.kill(); + } catch { + /* proc may already be dead */ + } + throw error; + } + } + /** * Spawn a socket-mode adapter (e.g. dlv). * Linux: connect to a unix domain socket via --listen=unix: @@ -659,6 +749,83 @@ async function waitForCondition( throw new Error(`Socket not ready after ${timeoutMs}ms`); } +/** Connect once to a TCP DAP server. */ +async function connectTcpSocket(host: string, port: number, onClose?: () => void): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + let streamController: ReadableStreamDefaultController; + let opened = false; + const readable = new ReadableStream({ + start(controller) { + streamController = controller; + }, + }); + + void Bun.connect({ + hostname: host, + port, + socket: { + open(socket) { + opened = true; + resolve({ + readable, + writeSink: socketToSink(socket), + socket, + }); + }, + data(_socket, data) { + streamController.enqueue(new Uint8Array(data)); + }, + close() { + onClose?.(); + if (!opened) { + reject(new Error(`Connection to TCP port ${host}:${port} closed before opening`)); + } + try { + streamController.close(); + } catch { + /* already closed */ + } + }, + error(_socket, error) { + onClose?.(); + if (!opened) { + reject(error); + } + try { + streamController.error(error); + } catch { + /* already closed */ + } + }, + }, + }).catch(error => { + onClose?.(); + reject(error); + }); + return promise; +} + +/** Wait for a TCP DAP server and retain the first successful connection. */ +async function waitForTcpTransport( + host: string, + port: number, + timeoutMs: number, + proc: { exitCode: number | null }, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (proc.exitCode !== null) { + throw new Error(`Adapter process exited before TCP port ${host}:${port} was ready`); + } + try { + return await connectTcpSocket(host, port); + } catch { + await Bun.sleep(50); + } + } + throw new Error(`TCP port ${host}:${port} was not ready after ${timeoutMs}ms`); +} + interface SocketTransport { readable: ReadableStream; writeSink: DapWriteSink; diff --git a/packages/coding-agent/src/dap/config.ts b/packages/coding-agent/src/dap/config.ts index 0aaa27eea..b59ae7065 100644 --- a/packages/coding-agent/src/dap/config.ts +++ b/packages/coding-agent/src/dap/config.ts @@ -10,6 +10,8 @@ import DEFAULTS from "./defaults.json" with { type: "json" }; import type { DapAdapterConfig, DapResolvedAdapter } from "./types"; const EXTENSIONLESS_DEBUGGER_ORDER: readonly string[] = ["gdb", "lldb-dap"]; +const JS_DEBUG_SERVER_ENV = "JS_DEBUG_DAP_SERVER"; +const DAP_PORT_ARGUMENT = "$" + "{port}"; interface NormalizedConfig { adapters: Record; @@ -45,7 +47,7 @@ function normalizeObject(value: unknown): Record { function normalizeAdapterConfig(config: unknown): DapAdapterConfig | null { if (!isRecord(config)) return null; if (typeof config.command !== "string" || config.command.length === 0) return null; - const connectMode = config.connectMode === "socket" ? ("socket" as const) : undefined; + const connectMode = config.connectMode === "socket" || config.connectMode === "tcp" ? config.connectMode : undefined; return { command: config.command, args: normalizeStringArray(config.args), @@ -184,6 +186,52 @@ function normalizeCommandForCwd(command: string, cwd: string): string { return command; } +function resolveJsDebugServerPath(cwd: string): string | null { + const configured = process.env[JS_DEBUG_SERVER_ENV]; + const dataHome = process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share"); + const candidates = [ + ...(configured ? [path.resolve(cwd, configured)] : []), + path.join(dataHome, "nvim", "mason", "packages", "js-debug-adapter", "js-debug", "src", "dapDebugServer.js"), + path.join(os.homedir(), ".local", "opt", "js-debug", "src", "dapDebugServer.js"), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + return null; +} + +function resolveDefaultJsDebugAdapter( + adapterName: string, + config: DapAdapterConfig, + cwd: string, + localRoots?: readonly string[], +): DapResolvedAdapter | null | undefined { + if (adapterName !== "js-debug-adapter" || config.command !== "js-debug-adapter") { + return undefined; + } + const serverPath = resolveJsDebugServerPath(cwd); + if (!serverPath) return null; + const nodeCommand = resolveCommand("node", cwd, { + cache: WhichCachePolicy.Fresh, + PATH: process.env.PATH, + localRoots, + }); + const resolvedCommand = nodeCommand ?? process.execPath; + return { + name: adapterName, + command: nodeCommand ? "node" : "bun", + args: [serverPath, DAP_PORT_ARGUMENT, "127.0.0.1"], + resolvedCommand, + languages: config.languages ?? [], + fileTypes: config.fileTypes ?? [], + rootMarkers: config.rootMarkers ?? [], + launchDefaults: config.launchDefaults ?? {}, + attachDefaults: config.attachDefaults ?? {}, + connectMode: "tcp", + acceptsDirectoryProgram: config.acceptsDirectoryProgram === true, + }; +} + function resolveAdapterFromConfig( adapterName: string, configs: Record, @@ -192,6 +240,8 @@ function resolveAdapterFromConfig( ): DapResolvedAdapter | null { const config = configs[adapterName]; if (!config) return null; + const jsDebugAdapter = resolveDefaultJsDebugAdapter(adapterName, config, cwd, localRoots); + if (jsDebugAdapter !== undefined) return jsDebugAdapter; const normalizedCommand = normalizeCommandForCwd(config.command, cwd); const commandIsBare = !path.isAbsolute(config.command) && !config.command.includes("/") && !config.command.includes("\\"); diff --git a/packages/coding-agent/src/dap/session.ts b/packages/coding-agent/src/dap/session.ts index f7e185b69..b1c135f50 100644 --- a/packages/coding-agent/src/dap/session.ts +++ b/packages/coding-agent/src/dap/session.ts @@ -93,6 +93,15 @@ interface DapSession { initializedSeen: boolean; needsConfigurationDone: boolean; configurationDoneSent: boolean; + parentSessionId?: string; + childSessionIds: Set; + port?: number; +} + +interface DapTreeOutcomeWaiter { + rootSessionId: string; + resolve(value: unknown): void; + reject(reason: unknown): void; } export interface DapOutputSnapshot { @@ -243,6 +252,8 @@ function buildSummary(session: DapSession): DapSessionSummary { outputTruncated: session.outputTruncated, exitCode: session.exitCode, needsConfigurationDone: session.needsConfigurationDone && !session.configurationDoneSent, + parentSessionId: session.parentSessionId, + childSessionIds: session.childSessionIds.size > 0 ? [...session.childSessionIds] : undefined, }; } @@ -251,6 +262,7 @@ export class DapSessionManager { #activeSessionId: string | null = null; #cleanupLoopPromise?: Promise; #nextId = 0; + #treeOutcomeWaiters = new Set(); constructor() { this.#startCleanupTimer(); @@ -318,17 +330,22 @@ export class DapSessionManager { await launchPromise; // Try to capture initial stopped state (e.g. stopOnEntry). // Timeout is acceptable — the program may simply be running. + let resultSession = session; try { await untilAborted(signal, initialStopPromise); - if (session.status === "stopped") { - await this.#fetchTopFrame(session, signal, Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS)); + const active = this.#getActiveSessionOrNull(); + if (active && this.#getRootSession(active).id === session.id) { + resultSession = active; + } + if (resultSession.status === "stopped") { + await this.#fetchTopFrame(resultSession, signal, Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS)); } } catch { if (session.initializedSeen && session.status === "launching") { session.status = session.configurationDoneSent ? "running" : "configuring"; } } - return buildSummary(session); + return buildSummary(resultSession); } catch (error) { await this.#disposeSession(session); const mapped = mapDebugpyMissingModule(options.adapter.name, error); @@ -376,17 +393,22 @@ export class DapSessionManager { await throwPreferredDapStartError("attach", attachFailure, error); } await attachPromise; + let resultSession = session; try { await untilAborted(signal, initialStopPromise); - if (session.status === "stopped") { - await this.#fetchTopFrame(session, signal, Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS)); + const active = this.#getActiveSessionOrNull(); + if (active && this.#getRootSession(active).id === session.id) { + resultSession = active; + } + if (resultSession.status === "stopped") { + await this.#fetchTopFrame(resultSession, signal, Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS)); } } catch { if (session.initializedSeen && session.status === "launching") { session.status = session.configurationDoneSent ? "running" : "configuring"; } } - return buildSummary(session); + return buildSummary(resultSession); } catch (error) { await this.#disposeSession(session); const mapped = mapDebugpyMissingModule(options.adapter.name, error); @@ -414,6 +436,62 @@ export class DapSessionManager { ); return run; } + async #syncBreakpointTree( + origin: DapSession, + command: string, + args: unknown, + prepare: (session: DapSession) => void, + apply: (session: DapSession, breakpoints: DapBreakpoint[] | undefined) => void, + signal?: AbortSignal, + timeoutMs: number = 30_000, + ): Promise { + const sessions = this.#getTreeSessions(origin).filter( + session => session.status !== "terminated" && session.client.isAlive(), + ); + for (const session of sessions) prepare(session); + await this.#serializeBreakpointMutation( + origin, + async () => { + const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>( + origin, + command, + args, + signal, + timeoutMs, + ); + apply(origin, response?.breakpoints); + }, + signal, + ); + await Promise.all( + sessions + .filter(session => session !== origin) + .map(async session => { + try { + await this.#serializeBreakpointMutation( + session, + async () => { + const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>( + session, + command, + args, + signal, + timeoutMs, + ); + apply(session, response?.breakpoints); + }, + signal, + ); + } catch (error) { + logger.warn("Failed to synchronize breakpoint request with child debug session", { + sessionId: session.id, + command, + error: toErrorMessage(error), + }); + } + }), + ); + } async setBreakpoint( file: string, @@ -423,123 +501,127 @@ export class DapSessionManager { timeoutMs: number = 30_000, ) { const session = this.#touchActiveSession(); - return this.#serializeBreakpointMutation( + const sourcePath = normalizePath(file); + const root = this.#getRootSession(session); + const current = [...(root.breakpoints.get(sourcePath) ?? [])].filter(entry => entry.line !== line); + current.push({ verified: false, line, condition }); + current.sort((left, right) => left.line - right.line); + const args = { + source: { path: sourcePath, name: path.basename(sourcePath) }, + breakpoints: current.map(entry => ({ + line: entry.line, + ...(entry.condition ? { condition: entry.condition } : {}), + })), + }; + await this.#syncBreakpointTree( session, - async () => { - const sourcePath = normalizePath(file); - const current = [...(session.breakpoints.get(sourcePath) ?? [])]; - const deduped = current.filter(entry => entry.line !== line); - deduped.push({ verified: false, line, condition }); - deduped.sort((left, right) => left.line - right.line); - const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>( - session, - "setBreakpoints", - { - source: { path: sourcePath, name: path.basename(sourcePath) }, - breakpoints: deduped.map(entry => ({ - line: entry.line, - ...(entry.condition ? { condition: entry.condition } : {}), - })), - }, - signal, - timeoutMs, - ); - session.breakpoints.set(sourcePath, this.#mapSourceBreakpoints(deduped, response?.breakpoints)); - return { - snapshot: buildSummary(session), - breakpoints: session.breakpoints.get(sourcePath) ?? [], + "setBreakpoints", + args, + target => + target.breakpoints.set( sourcePath, - }; - }, + current.map(entry => ({ ...entry, verified: false })), + ), + (target, response) => target.breakpoints.set(sourcePath, this.#mapSourceBreakpoints(current, response)), signal, + timeoutMs, ); + return { + snapshot: buildSummary(session), + breakpoints: session.breakpoints.get(sourcePath) ?? [], + sourcePath, + }; } async removeBreakpoint(file: string, line: number, signal?: AbortSignal, timeoutMs: number = 30_000) { const session = this.#touchActiveSession(); - return this.#serializeBreakpointMutation( - session, - async () => { - const sourcePath = normalizePath(file); - const current = [...(session.breakpoints.get(sourcePath) ?? [])].filter(entry => entry.line !== line); - const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>( - session, - "setBreakpoints", - { - source: { path: sourcePath, name: path.basename(sourcePath) }, - breakpoints: current.map(entry => ({ - line: entry.line, - ...(entry.condition ? { condition: entry.condition } : {}), - })), - }, - signal, - timeoutMs, - ); - if (current.length === 0) { - session.breakpoints.delete(sourcePath); - } else { - session.breakpoints.set(sourcePath, this.#mapSourceBreakpoints(current, response?.breakpoints)); - } - return { - snapshot: buildSummary(session), - breakpoints: session.breakpoints.get(sourcePath) ?? [], + const sourcePath = normalizePath(file); + const root = this.#getRootSession(session); + const current = [...(root.breakpoints.get(sourcePath) ?? [])].filter(entry => entry.line !== line); + const args = { + source: { path: sourcePath, name: path.basename(sourcePath) }, + breakpoints: current.map(entry => ({ + line: entry.line, + ...(entry.condition ? { condition: entry.condition } : {}), + })), + }; + const prepare = (target: DapSession) => { + if (current.length === 0) target.breakpoints.delete(sourcePath); + else + target.breakpoints.set( sourcePath, - }; + current.map(entry => ({ ...entry, verified: false })), + ); + }; + await this.#syncBreakpointTree( + session, + "setBreakpoints", + args, + prepare, + (target, response) => { + if (current.length === 0) target.breakpoints.delete(sourcePath); + else target.breakpoints.set(sourcePath, this.#mapSourceBreakpoints(current, response)); }, signal, + timeoutMs, ); + return { + snapshot: buildSummary(session), + breakpoints: session.breakpoints.get(sourcePath) ?? [], + sourcePath, + }; } async setFunctionBreakpoint(name: string, condition?: string, signal?: AbortSignal, timeoutMs: number = 30_000) { const session = this.#touchActiveSession(); - return this.#serializeBreakpointMutation( + const current = this.#getRootSession(session).functionBreakpoints.filter(entry => entry.name !== name); + current.push({ verified: false, name, condition }); + current.sort((left, right) => left.name.localeCompare(right.name)); + const args = { + breakpoints: current.map(entry => ({ + name: entry.name, + ...(entry.condition ? { condition: entry.condition } : {}), + })), + }; + await this.#syncBreakpointTree( session, - async () => { - const current = session.functionBreakpoints.filter(entry => entry.name !== name); - current.push({ verified: false, name, condition }); - current.sort((left, right) => left.name.localeCompare(right.name)); - const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>( - session, - "setFunctionBreakpoints", - { - breakpoints: current.map(entry => ({ - name: entry.name, - ...(entry.condition ? { condition: entry.condition } : {}), - })), - }, - signal, - timeoutMs, - ); - session.functionBreakpoints = this.#mapFunctionBreakpoints(current, response?.breakpoints); - return { snapshot: buildSummary(session), breakpoints: session.functionBreakpoints }; + "setFunctionBreakpoints", + args, + target => { + target.functionBreakpoints = current.map(entry => ({ ...entry, verified: false })); + }, + (target, response) => { + target.functionBreakpoints = this.#mapFunctionBreakpoints(current, response); }, signal, + timeoutMs, ); + return { snapshot: buildSummary(session), breakpoints: session.functionBreakpoints }; } async removeFunctionBreakpoint(name: string, signal?: AbortSignal, timeoutMs: number = 30_000) { const session = this.#touchActiveSession(); - return this.#serializeBreakpointMutation( + const current = this.#getRootSession(session).functionBreakpoints.filter(entry => entry.name !== name); + const args = { + breakpoints: current.map(entry => ({ + name: entry.name, + ...(entry.condition ? { condition: entry.condition } : {}), + })), + }; + await this.#syncBreakpointTree( session, - async () => { - const current = session.functionBreakpoints.filter(entry => entry.name !== name); - const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>( - session, - "setFunctionBreakpoints", - { - breakpoints: current.map(entry => ({ - name: entry.name, - ...(entry.condition ? { condition: entry.condition } : {}), - })), - }, - signal, - timeoutMs, - ); - session.functionBreakpoints = this.#mapFunctionBreakpoints(current, response?.breakpoints); - return { snapshot: buildSummary(session), breakpoints: session.functionBreakpoints }; + "setFunctionBreakpoints", + args, + target => { + target.functionBreakpoints = current.map(entry => ({ ...entry, verified: false })); + }, + (target, response) => { + target.functionBreakpoints = this.#mapFunctionBreakpoints(current, response); }, signal, + timeoutMs, ); + return { snapshot: buildSummary(session), breakpoints: session.functionBreakpoints }; } async setInstructionBreakpoint( @@ -551,37 +633,33 @@ export class DapSessionManager { timeoutMs: number = 30_000, ) { const session = this.#touchActiveSession(); - return this.#serializeBreakpointMutation( + const current = this.#getRootSession(session).instructionBreakpoints.filter( + entry => entry.instructionReference !== instructionReference || entry.offset !== offset, + ); + current.push({ instructionReference, offset, condition, hitCondition }); + current.sort((left, right) => { + const referenceOrder = left.instructionReference.localeCompare(right.instructionReference); + return referenceOrder !== 0 ? referenceOrder : (left.offset ?? 0) - (right.offset ?? 0); + }); + const args = { breakpoints: current } satisfies DapSetInstructionBreakpointsArguments; + let responseBreakpoints: DapBreakpoint[] | undefined; + await this.#syncBreakpointTree( session, - async () => { - const current = session.instructionBreakpoints.filter( - entry => entry.instructionReference !== instructionReference || entry.offset !== offset, - ); - current.push({ instructionReference, offset, condition, hitCondition }); - current.sort((left, right) => { - const referenceOrder = left.instructionReference.localeCompare(right.instructionReference); - if (referenceOrder !== 0) { - return referenceOrder; - } - return (left.offset ?? 0) - (right.offset ?? 0); - }); - const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>( - session, - "setInstructionBreakpoints", - { - breakpoints: current, - } satisfies DapSetInstructionBreakpointsArguments, - signal, - timeoutMs, - ); - session.instructionBreakpoints = current; - return { - snapshot: buildSummary(session), - breakpoints: this.#mapInstructionBreakpoints(current, response?.breakpoints), - }; + "setInstructionBreakpoints", + args, + target => { + target.instructionBreakpoints = current.map(entry => ({ ...entry })); + }, + (target, response) => { + if (target === session) responseBreakpoints = response; }, signal, + timeoutMs, ); + return { + snapshot: buildSummary(session), + breakpoints: this.#mapInstructionBreakpoints(current, responseBreakpoints), + }; } async removeInstructionBreakpoint( @@ -591,35 +669,29 @@ export class DapSessionManager { timeoutMs: number = 30_000, ) { const session = this.#touchActiveSession(); - return this.#serializeBreakpointMutation( + const current = this.#getRootSession(session).instructionBreakpoints.filter(entry => { + if (entry.instructionReference !== instructionReference) return true; + return offset !== undefined && entry.offset !== offset; + }); + const args = { breakpoints: current } satisfies DapSetInstructionBreakpointsArguments; + let responseBreakpoints: DapBreakpoint[] | undefined; + await this.#syncBreakpointTree( session, - async () => { - const current = session.instructionBreakpoints.filter(entry => { - if (entry.instructionReference !== instructionReference) { - return true; - } - if (offset === undefined) { - return false; - } - return entry.offset !== offset; - }); - const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>( - session, - "setInstructionBreakpoints", - { - breakpoints: current, - } satisfies DapSetInstructionBreakpointsArguments, - signal, - timeoutMs, - ); - session.instructionBreakpoints = current; - return { - snapshot: buildSummary(session), - breakpoints: this.#mapInstructionBreakpoints(current, response?.breakpoints), - }; + "setInstructionBreakpoints", + args, + target => { + target.instructionBreakpoints = current.map(entry => ({ ...entry })); + }, + (target, response) => { + if (target === session) responseBreakpoints = response; }, signal, + timeoutMs, ); + return { + snapshot: buildSummary(session), + breakpoints: this.#mapInstructionBreakpoints(current, responseBreakpoints), + }; } async dataBreakpointInfo( @@ -653,54 +725,52 @@ export class DapSessionManager { timeoutMs: number = 30_000, ) { const session = this.#touchActiveSession(); - return this.#serializeBreakpointMutation( + const current = this.#getRootSession(session).dataBreakpoints.filter(entry => entry.dataId !== dataId); + current.push({ dataId, accessType, condition, hitCondition }); + current.sort((left, right) => left.dataId.localeCompare(right.dataId)); + const args = { breakpoints: current } satisfies DapSetDataBreakpointsArguments; + let responseBreakpoints: DapBreakpoint[] | undefined; + await this.#syncBreakpointTree( session, - async () => { - const current = session.dataBreakpoints.filter(entry => entry.dataId !== dataId); - current.push({ dataId, accessType, condition, hitCondition }); - current.sort((left, right) => left.dataId.localeCompare(right.dataId)); - const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>( - session, - "setDataBreakpoints", - { - breakpoints: current, - } satisfies DapSetDataBreakpointsArguments, - signal, - timeoutMs, - ); - session.dataBreakpoints = current; - return { - snapshot: buildSummary(session), - breakpoints: this.#mapDataBreakpoints(current, response?.breakpoints), - }; + "setDataBreakpoints", + args, + target => { + target.dataBreakpoints = current.map(entry => ({ ...entry })); + }, + (target, response) => { + if (target === session) responseBreakpoints = response; }, signal, + timeoutMs, ); + return { + snapshot: buildSummary(session), + breakpoints: this.#mapDataBreakpoints(current, responseBreakpoints), + }; } async removeDataBreakpoint(dataId: string, signal?: AbortSignal, timeoutMs: number = 30_000) { const session = this.#touchActiveSession(); - return this.#serializeBreakpointMutation( + const current = this.#getRootSession(session).dataBreakpoints.filter(entry => entry.dataId !== dataId); + const args = { breakpoints: current } satisfies DapSetDataBreakpointsArguments; + let responseBreakpoints: DapBreakpoint[] | undefined; + await this.#syncBreakpointTree( session, - async () => { - const current = session.dataBreakpoints.filter(entry => entry.dataId !== dataId); - const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>( - session, - "setDataBreakpoints", - { - breakpoints: current, - } satisfies DapSetDataBreakpointsArguments, - signal, - timeoutMs, - ); - session.dataBreakpoints = current; - return { - snapshot: buildSummary(session), - breakpoints: this.#mapDataBreakpoints(current, response?.breakpoints), - }; + "setDataBreakpoints", + args, + target => { + target.dataBreakpoints = current.map(entry => ({ ...entry })); + }, + (target, response) => { + if (target === session) responseBreakpoints = response; }, signal, + timeoutMs, ); + return { + snapshot: buildSummary(session), + breakpoints: this.#mapDataBreakpoints(current, responseBreakpoints), + }; } async disassemble( @@ -998,27 +1068,35 @@ export class DapSessionManager { async terminate(signal?: AbortSignal, timeoutMs: number = 30_000): Promise { const session = this.#getActiveSessionOrNull(); if (!session) return null; - session.lastUsedAt = Date.now(); - if (session.status !== "terminated") { - if (session.capabilities?.supportsTerminateRequest) { - await untilAborted( - signal, - session.client.sendRequest("terminate", undefined, signal, timeoutMs).catch(() => undefined), - ); - } - await untilAborted( - signal, - session.client - .sendRequest("disconnect", { terminateDebuggee: true }, signal, timeoutMs) - .catch(() => undefined), - ); - } - session.status = "terminated"; + this.#touchSessionAndAncestors(session); + const root = this.#getRootSession(session); const summary = buildSummary(session); - await this.#disposeSession(session); + await this.#terminateSessionTree(root, signal, timeoutMs); return summary; } + async #terminateSessionTree(session: DapSession, signal?: AbortSignal, timeoutMs: number = 30_000): Promise { + session.status = "terminated"; + try { + for (const childId of [...session.childSessionIds]) { + const child = this.#sessions.get(childId); + if (child) { + await this.#terminateSessionTree(child, signal, timeoutMs); + } + } + if (session.capabilities?.supportsTerminateRequest) { + await session.client.sendRequest("terminate", undefined, signal, timeoutMs).catch(() => undefined); + } + await session.client + .sendRequest("disconnect", { terminateDebuggee: true }, signal, timeoutMs) + .catch(() => undefined); + } catch { + /* Disposal remains mandatory when a caller aborts best-effort DAP shutdown. */ + } finally { + this.#disposeSession(session); + } + } + #startCleanupTimer(): void { if (this.#cleanupLoopPromise) return; this.#cleanupLoopPromise = this.#runCleanupLoop(); @@ -1048,17 +1126,156 @@ export class DapSessionManager { } } - async #ensureLaunchSlot(): Promise { - const active = this.#getActiveSessionOrNull(); - if (!active) return; - if (active.status === "terminated" || !active.client.isAlive()) { - await this.#disposeSession(active); - return; + async #startChildSession( + parent: DapSession, + request: "launch" | "attach", + configuration: Record, + timeoutMs: number = 30_000, + ): Promise { + if (parent.adapter.connectMode !== "tcp" || parent.port === undefined) { + throw new Error(`DAP adapter ${parent.adapter.name} cannot accept child session connections`); + } + const cwd = path.resolve(parent.cwd, typeof configuration.cwd === "string" ? configuration.cwd : "."); + const client = await DapClient.connect({ + adapter: parent.adapter, + cwd, + host: "127.0.0.1", + port: parent.port, + }); + const child = this.#registerSession( + client, + parent.adapter, + cwd, + typeof configuration.program === "string" ? configuration.program : undefined, + parent.id, + ); + try { + child.capabilities = await client.initialize( + this.#buildInitializeArguments(parent.adapter), + undefined, + timeoutMs, + ); + child.needsConfigurationDone = child.capabilities.supportsConfigurationDoneRequest === true; + const startFailure: DapStartRequestFailure = { rejected: false }; + const startPromise = trackDapStartRequest( + client.sendRequest(request, { ...configuration, cwd }, undefined, timeoutMs), + startFailure, + ); + startPromise.catch(() => {}); + try { + await this.#completeConfigurationHandshake(child, undefined, timeoutMs); + } catch (error) { + await throwPreferredDapStartError(request, startFailure, error); + } + await startPromise; + } catch (error) { + await this.#disposeSession(child); + throw error; } - throw new Error(`Debug session ${active.id} is still active. Terminate it before launching another.`); } - #registerSession(client: DapClient, adapter: DapResolvedAdapter, cwd: string, program?: string): DapSession { + async #applyRootBreakpointsToSession( + session: DapSession, + signal?: AbortSignal, + timeoutMs: number = 30_000, + ): Promise { + const root = this.#getRootSession(session); + for (const [sourcePath, entries] of root.breakpoints) { + try { + const response = await session.client.sendRequest<{ breakpoints?: DapBreakpoint[] }>( + "setBreakpoints", + { + source: { path: sourcePath, name: path.basename(sourcePath) }, + breakpoints: entries.map(entry => ({ + line: entry.line, + ...(entry.condition ? { condition: entry.condition } : {}), + })), + }, + signal, + timeoutMs, + ); + session.breakpoints.set(sourcePath, this.#mapSourceBreakpoints(entries, response?.breakpoints)); + } catch (error) { + logger.warn("Failed to bind source breakpoints in child debug session", { + sessionId: session.id, + sourcePath, + error: toErrorMessage(error), + }); + } + } + if (root.functionBreakpoints.length > 0) { + try { + const response = await session.client.sendRequest<{ breakpoints?: DapBreakpoint[] }>( + "setFunctionBreakpoints", + { + breakpoints: root.functionBreakpoints.map(entry => ({ + name: entry.name, + ...(entry.condition ? { condition: entry.condition } : {}), + })), + }, + signal, + timeoutMs, + ); + session.functionBreakpoints = this.#mapFunctionBreakpoints(root.functionBreakpoints, response?.breakpoints); + } catch (error) { + logger.warn("Failed to bind function breakpoints in child debug session", { + sessionId: session.id, + error: toErrorMessage(error), + }); + } + } + if (root.instructionBreakpoints.length > 0) { + try { + await session.client.sendRequest( + "setInstructionBreakpoints", + { breakpoints: root.instructionBreakpoints } satisfies DapSetInstructionBreakpointsArguments, + signal, + timeoutMs, + ); + session.instructionBreakpoints = root.instructionBreakpoints.map(entry => ({ ...entry })); + } catch (error) { + logger.warn("Failed to bind instruction breakpoints in child debug session", { + sessionId: session.id, + error: toErrorMessage(error), + }); + } + } + if (root.dataBreakpoints.length > 0) { + try { + await session.client.sendRequest( + "setDataBreakpoints", + { breakpoints: root.dataBreakpoints } satisfies DapSetDataBreakpointsArguments, + signal, + timeoutMs, + ); + session.dataBreakpoints = root.dataBreakpoints.map(entry => ({ ...entry })); + } catch (error) { + logger.debug("Failed to bind data breakpoints in child debug session", { + sessionId: session.id, + error: toErrorMessage(error), + }); + } + } + } + + async #ensureLaunchSlot(): Promise { + for (const session of [...this.#sessions.values()]) { + if (session.status === "terminated" || !session.client.isAlive()) { + this.#disposeSession(session); + } + } + const root = [...this.#sessions.values()].find(session => !session.parentSessionId); + if (!root) return; + throw new Error(`Debug session ${root.id} is still active. Terminate it before launching another.`); + } + + #registerSession( + client: DapClient, + adapter: DapResolvedAdapter, + cwd: string, + program?: string, + parentSessionId?: string, + ): DapSession { const session: DapSession = { id: `debug-${++this.#nextId}`, adapter, @@ -1083,6 +1300,9 @@ export class DapSessionManager { initializedSeen: false, needsConfigurationDone: false, configurationDoneSent: false, + parentSessionId, + childSessionIds: new Set(), + port: client.port, }; client.onReverseRequest("runInTerminal", async rawArgs => { const args = (rawArgs ?? {}) as DapRunInTerminalArguments; @@ -1093,7 +1313,7 @@ export class DapSessionManager { Object.entries(args.env ?? {}).filter((entry): entry is [string, string] => entry[1] !== null), ); const proc = ptree.spawn(args.args, { - cwd: args.cwd ?? session.cwd, + cwd: path.resolve(session.cwd, args.cwd ?? "."), stdin: "pipe", env: { ...Bun.env, @@ -1115,6 +1335,7 @@ export class DapSessionManager { request, name: typeof configuration.name === "string" ? configuration.name : undefined, }); + await this.#startChildSession(session, request, configuration); return {}; }); client.onEvent("output", body => { @@ -1126,6 +1347,8 @@ export class DapSessionManager { }); client.onEvent("stopped", body => { this.#handleStoppedEvent(session, body as DapStoppedEventBody); + this.#activeSessionId = session.id; + this.#resolveTreeOutcome(session); }); client.onEvent("continued", body => { const continued = body as { threadId?: number } | undefined; @@ -1135,19 +1358,30 @@ export class DapSessionManager { }); client.onEvent("exited", body => { session.exitCode = (body as DapExitedEventBody | undefined)?.exitCode; + session.status = "terminated"; + this.#resolveTreeOutcome(session); }); client.onEvent("terminated", () => { session.status = "terminated"; + this.#resolveTreeOutcome(session); }); this.#sessions.set(session.id, session); - this.#activeSessionId = session.id; + if (parentSessionId) { + this.#sessions.get(parentSessionId)?.childSessionIds.add(session.id); + } else { + this.#activeSessionId = session.id; + } const heartbeat = setInterval(() => { if (!client.isAlive()) { session.status = "terminated"; } }, HEARTBEAT_INTERVAL_MS); heartbeat.unref?.(); - client.proc.exited.finally(() => clearInterval(heartbeat)); + void client.proc.exited.finally(() => { + clearInterval(heartbeat); + session.status = "terminated"; + this.#resolveTreeOutcome(session); + }); return session; } @@ -1178,7 +1412,11 @@ export class DapSessionManager { signal?: AbortSignal, timeoutMs: number = 30_000, ): Promise { - if (!session.needsConfigurationDone || session.configurationDoneSent) { + if (session.configurationDoneSent) return; + if (!session.needsConfigurationDone) { + if (session.parentSessionId) { + await this.#applyRootBreakpointsToSession(session, signal, timeoutMs); + } return; } // Wait for the initialized event if we haven't seen it yet. @@ -1191,6 +1429,9 @@ export class DapSessionManager { return; } } + if (session.parentSessionId) { + await this.#applyRootBreakpointsToSession(session, signal, timeoutMs); + } await session.client.sendRequest("configurationDone", {}, signal, timeoutMs); session.configurationDoneSent = true; if (session.status === "configuring") { @@ -1261,19 +1502,39 @@ export class DapSessionManager { * MUST be called before the command that triggers the event. */ #prepareStopOutcome(session: DapSession, signal?: AbortSignal, timeoutMs: number = 30_000): Promise { - const promises = [ - session.client.waitForEvent("stopped", undefined, signal, timeoutMs), - session.client.waitForEvent("terminated", undefined, signal, timeoutMs), - session.client.waitForEvent("exited", undefined, signal, timeoutMs), - ]; - // Promise.race leaves the losing waiters pending; their timeouts would - // otherwise surface as unhandled rejections once they fire. - for (const p of promises) { - p.catch(() => {}); + const { promise, resolve, reject } = Promise.withResolvers(); + const rootSessionId = this.#getRootSession(session).id; + let timeout: NodeJS.Timeout | undefined; + let abortHandler: (() => void) | undefined; + const cleanup = () => { + clearTimeout(timeout); + if (signal && abortHandler) signal.removeEventListener("abort", abortHandler); + this.#treeOutcomeWaiters.delete(waiter); + }; + const waiter: DapTreeOutcomeWaiter = { + rootSessionId, + resolve: value => { + cleanup(); + resolve(value); + }, + reject: reason => { + cleanup(); + reject(reason); + }, + }; + this.#treeOutcomeWaiters.add(waiter); + timeout = setTimeout( + () => waiter.reject(new Error(`DAP session tree outcome timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + if (signal) { + abortHandler = () => + waiter.reject(signal.reason instanceof Error ? signal.reason : new Error("Debug operation aborted")); + if (signal.aborted) abortHandler(); + else signal.addEventListener("abort", abortHandler, { once: true }); } - const outcome = Promise.race(promises); - outcome.catch(() => {}); - return outcome; + promise.catch(() => {}); + return promise; } /** @@ -1287,17 +1548,29 @@ export class DapSessionManager { ): Promise { try { await untilAborted(signal, outcomePromise); - if (session.status === "stopped") { - await this.#fetchTopFrame(session, signal, Math.min(timeoutMs, 5_000)); + const active = this.#getActiveSessionOrNull(); + const resultSession = + active && this.#getRootSession(active).id === this.#getRootSession(session).id ? active : session; + if (resultSession.status === "stopped") { + await this.#fetchTopFrame(resultSession, signal, Math.min(timeoutMs, 5_000)); } const state = - session.status === "stopped" ? "stopped" : session.status === "terminated" ? "terminated" : "running"; - return { snapshot: buildSummary(session), state, timedOut: false }; + resultSession.status === "stopped" + ? "stopped" + : resultSession.status === "terminated" + ? "terminated" + : "running"; + return { snapshot: buildSummary(resultSession), state, timedOut: false }; } catch (error) { - if (signal?.aborted) { - throw error; - } - return { snapshot: buildSummary(session), state: "running", timedOut: session.status === "running" }; + if (signal?.aborted) throw error; + const active = this.#getActiveSessionOrNull(); + const resultSession = + active && this.#getRootSession(active).id === this.#getRootSession(session).id ? active : session; + return { + snapshot: buildSummary(resultSession), + state: "running", + timedOut: resultSession.status === "running", + }; } } @@ -1326,7 +1599,7 @@ export class DapSessionManager { ): Promise { await this.#ensureConfigurationDone(session, signal, timeoutMs); const body = await session.client.sendRequest(command, args, signal, timeoutMs); - session.lastUsedAt = Date.now(); + this.#touchSessionAndAncestors(session); return body; } @@ -1403,7 +1676,7 @@ export class DapSessionManager { #touchActiveSession(): DapSession { const session = this.#getActiveSessionOrThrow(); - session.lastUsedAt = Date.now(); + this.#touchSessionAndAncestors(session); if (session.status !== "terminated" && !session.client.isAlive()) { session.status = "terminated"; } @@ -1429,11 +1702,63 @@ export class DapSessionManager { return session; } - #disposeSession(session: DapSession) { - if (this.#activeSessionId === session.id) { - this.#activeSessionId = null; + #getRootSession(session: DapSession): DapSession { + let root = session; + while (root.parentSessionId) { + const parent = this.#sessions.get(root.parentSessionId); + if (!parent) break; + root = parent; + } + return root; + } + + #getTreeSessions(session: DapSession): DapSession[] { + const sessions: DapSession[] = []; + const pending = [this.#getRootSession(session)]; + while (pending.length > 0) { + const current = pending.pop(); + if (!current) continue; + sessions.push(current); + for (const childId of current.childSessionIds) { + const child = this.#sessions.get(childId); + if (child) pending.push(child); + } + } + return sessions; + } + + #touchSessionAndAncestors(session: DapSession): void { + const now = Date.now(); + let current: DapSession | undefined = session; + while (current) { + current.lastUsedAt = now; + current = current.parentSessionId ? this.#sessions.get(current.parentSessionId) : undefined; + } + } + + #resolveTreeOutcome(session: DapSession): void { + const rootId = this.#getRootSession(session).id; + for (const waiter of [...this.#treeOutcomeWaiters]) { + if (waiter.rootSessionId === rootId) { + waiter.resolve(undefined); + } + } + } + + #disposeSession(session: DapSession): void { + if (!this.#sessions.has(session.id)) return; + for (const childId of [...session.childSessionIds]) { + const child = this.#sessions.get(childId); + if (child) this.#disposeSession(child); } this.#sessions.delete(session.id); + if (session.parentSessionId) { + this.#sessions.get(session.parentSessionId)?.childSessionIds.delete(session.id); + } + if (this.#activeSessionId === session.id) { + const parent = session.parentSessionId ? this.#sessions.get(session.parentSessionId) : undefined; + this.#activeSessionId = parent?.id ?? this.#sessions.values().next().value?.id ?? null; + } void session.client.dispose().catch(() => {}); } } diff --git a/packages/coding-agent/src/dap/types.ts b/packages/coding-agent/src/dap/types.ts index 542e9088c..3d74b23a1 100644 --- a/packages/coding-agent/src/dap/types.ts +++ b/packages/coding-agent/src/dap/types.ts @@ -484,10 +484,9 @@ export interface DapAdapterConfig { launchDefaults?: Record; attachDefaults?: Record; /** "stdio" (default): communicate via stdin/stdout pipes. - * "socket": adapter uses a network socket instead of stdio. - * On Linux, connects via a unix domain socket. - * On macOS, the adapter dials into a local TCP listener (--client-addr). */ - connectMode?: "stdio" | "socket"; + * "socket": adapter-specific socket launch (currently Delve). + * "tcp": spawn a DAP server with `${port}` substituted in `args`, then connect to it. */ + connectMode?: "stdio" | "socket" | "tcp"; /** When true, the adapter accepts a directory as the launch `program` * (e.g. dlv treats it as a Go package path). When false/undefined, the * debug tool rejects directory programs upfront. */ @@ -504,7 +503,7 @@ export interface DapResolvedAdapter { rootMarkers: string[]; launchDefaults: Record; attachDefaults: Record; - connectMode: "stdio" | "socket"; + connectMode: "stdio" | "socket" | "tcp"; acceptsDirectoryProgram: boolean; } @@ -581,6 +580,8 @@ export interface DapSessionSummary { outputTruncated: boolean; exitCode?: number; needsConfigurationDone: boolean; + parentSessionId?: string; + childSessionIds?: string[]; } export interface DapContinueOutcome { diff --git a/packages/coding-agent/src/prompts/tools/debug.md b/packages/coding-agent/src/prompts/tools/debug.md index b39b65940..e5c59a2c0 100644 --- a/packages/coding-agent/src/prompts/tools/debug.md +++ b/packages/coding-agent/src/prompts/tools/debug.md @@ -4,5 +4,6 @@ Only one active session at a time. `program` is a target path, not a shell comma Adapters: - Python: `debugpy` (`pip install debugpy`) +- JavaScript/TypeScript: vscode-js-debug via Mason, or set `JS_DEBUG_DAP_SERVER` to its `dapDebugServer.js` - Go: Delve (`go install github.com/go-delve/delve/cmd/dlv@latest`) - Ruby: `rdbg` (`gem install debug`) diff --git a/packages/coding-agent/src/tools/debug.ts b/packages/coding-agent/src/tools/debug.ts index 690243120..2fcd8e5ac 100644 --- a/packages/coding-agent/src/tools/debug.ts +++ b/packages/coding-agent/src/tools/debug.ts @@ -504,12 +504,15 @@ const ADAPTER_UNAVAILABLE_MESSAGES: Readonly> = { debugpy: "adapter 'debugpy' is not available: python not found in PATH", dlv: "adapter 'dlv' is not available: install with 'go install github.com/go-delve/delve/cmd/dlv@latest'", rdbg: "adapter 'rdbg' is not available: install with 'gem install debug'", + "js-debug-adapter": + "adapter 'js-debug-adapter' is not available: install vscode-js-debug with Mason or set JS_DEBUG_DAP_SERVER to dapDebugServer.js", }; const ADAPTER_CANONICAL_COMMANDS: Readonly> = { debugpy: "python", dlv: "dlv", rdbg: "rdbg", + "js-debug-adapter": "js-debug-adapter", }; function formatAdapterUnavailable(adapterName: string, command: string, cwd: string): string { diff --git a/packages/coding-agent/test/debug/dap-multi-session.test.ts b/packages/coding-agent/test/debug/dap-multi-session.test.ts new file mode 100644 index 000000000..ceeae7dd8 --- /dev/null +++ b/packages/coding-agent/test/debug/dap-multi-session.test.ts @@ -0,0 +1,182 @@ +import { afterEach, describe, expect, it, spyOn, vi } from "bun:test"; +import { DapClient } from "@oh-my-pi/pi-coding-agent/dap/client"; +import { DapSessionManager } from "@oh-my-pi/pi-coding-agent/dap/session"; +import type { + DapCapabilities, + DapClientState, + DapEventMessage, + DapResolvedAdapter, +} from "@oh-my-pi/pi-coding-agent/dap/types"; + +const TEST_ADAPTER: DapResolvedAdapter = { + name: "js-debug-adapter", + command: "node", + args: ["dapDebugServer.js", "$" + "{port}", "127.0.0.1"], + resolvedCommand: "node", + languages: ["javascript", "typescript"], + fileTypes: [".js", ".ts"], + rootMarkers: ["package.json"], + launchDefaults: { request: "launch", type: "pwa-node", stopOnEntry: true }, + attachDefaults: { request: "attach", type: "pwa-node" }, + connectMode: "tcp", + acceptsDirectoryProgram: false, +}; + +type EventHandler = (body: unknown, event: DapEventMessage) => void | Promise; +type ReverseHandler = (args: unknown) => unknown | Promise; + +class FakeDapClient { + readonly proc: DapClientState["proc"]; + readonly port = 8123; + readonly requests: Array<{ command: string; args: unknown }> = []; + readonly #events = new Map>(); + readonly #reverseHandlers = new Map(); + readonly #exited = Promise.withResolvers(); + #alive = true; + disposed = false; + + constructor(readonly childConfiguration?: Record) { + this.proc = { + exited: this.#exited.promise, + exitCode: null, + stdin: { write: () => 0, flush: () => undefined }, + stdout: new ReadableStream(), + stderr: new ReadableStream(), + peekStderr: () => "", + kill: () => { + this.#alive = false; + this.#exited.resolve(); + return true; + }, + } as unknown as DapClientState["proc"]; + } + + async initialize(): Promise { + queueMicrotask(() => this.#emit("initialized", {})); + return { supportsConfigurationDoneRequest: true }; + } + + async sendRequest(command: string, args?: unknown): Promise { + this.requests.push({ command, args }); + if (command === "launch") { + if (this.childConfiguration) { + queueMicrotask(() => { + void this.#emitReverse("startDebugging", { + request: "launch", + configuration: this.childConfiguration, + }); + }); + } else { + queueMicrotask(() => this.#emit("stopped", { reason: "entry", threadId: 7 })); + } + } + if (command === "threads") return { threads: [{ id: 7, name: "target.js" }] }; + if (command === "stackTrace") { + return { + stackFrames: [{ id: 70, name: "main", line: 2, column: 1, source: { path: "/tmp/target.js" } }], + }; + } + if (command.endsWith("Breakpoints")) { + const breakpointArgs = args as { breakpoints?: unknown[] } | undefined; + return { breakpoints: (breakpointArgs?.breakpoints ?? []).map((_, id) => ({ id, verified: true })) }; + } + return {}; + } + + waitForEvent(event: string): Promise { + const { promise, resolve } = Promise.withResolvers(); + const unsubscribe = this.onEvent(event, body => { + unsubscribe(); + resolve(body); + }); + return promise; + } + + onEvent(event: string, handler: EventHandler): () => void { + const handlers = this.#events.get(event) ?? new Set(); + handlers.add(handler); + this.#events.set(event, handlers); + return () => handlers.delete(handler); + } + + onReverseRequest(command: string, handler: ReverseHandler): () => void { + this.#reverseHandlers.set(command, handler); + return () => this.#reverseHandlers.delete(command); + } + + isAlive(): boolean { + return this.#alive; + } + + async dispose(): Promise { + this.disposed = true; + this.#alive = false; + this.#exited.resolve(); + } + + #emit(event: string, body: unknown): void { + const message: DapEventMessage = { seq: 1, type: "event", event, body }; + for (const handler of this.#events.get(event) ?? []) void handler(body, message); + } + + async #emitReverse(command: string, args: unknown): Promise { + const handler = this.#reverseHandlers.get(command); + if (!handler) throw new Error(`Missing reverse handler for ${command}`); + await handler(args); + } +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("DAP multi-session debugging", () => { + it("routes recursive js-debug children, breakpoints, and termination through one session tree", async () => { + const root = new FakeDapClient({ + name: "target.js", + type: "pwa-node", + __pendingTargetId: "child", + program: "/tmp/target.js", + }); + const child = new FakeDapClient({ + name: "[worker 1]", + type: "pwa-node", + __pendingTargetId: "grandchild", + }); + const grandchild = new FakeDapClient(); + const children = [child, grandchild]; + spyOn(DapClient, "spawn").mockResolvedValue(root as unknown as DapClient); + spyOn(DapClient, "connect").mockImplementation(async () => { + const next = children.shift(); + if (!next) throw new Error("Unexpected child DAP connection"); + return next as unknown as DapClient; + }); + const manager = new DapSessionManager(); + + const launched = await manager.launch( + { adapter: TEST_ADAPTER, program: "/tmp/target.js", cwd: "/tmp" }, + undefined, + 1_000, + ); + + expect(launched.status).toBe("stopped"); + expect(launched.parentSessionId).toBeDefined(); + expect(launched.line).toBe(2); + expect(manager.listSessions()).toHaveLength(3); + + const breakpoint = await manager.setBreakpoint("/tmp/target.js", 2, undefined, undefined, 1_000); + expect(breakpoint.breakpoints).toEqual([ + { line: 2, condition: undefined, id: 0, verified: true, message: undefined }, + ]); + for (const client of [root, child, grandchild]) { + expect(client.requests.filter(request => request.command === "setBreakpoints")).toHaveLength(1); + } + + await manager.terminate(undefined, 1_000); + expect(manager.listSessions()).toEqual([]); + for (const client of [root, child, grandchild]) { + expect(client.requests.some(request => request.command === "disconnect")).toBe(true); + expect(client.disposed).toBe(true); + } + }); +});