From 970e115140c6f5f82aec9a16a04e3f9aa7b1421c Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 8 Apr 2026 04:16:45 +0200 Subject: [PATCH] feat(dap): added socket-mode transport and improved session initialization for DAP adapters - Added socket-mode transport support for DAP adapters, enabling network socket debuggers like dlv alongside stdio mode. - Improved extensionless binary debugging to prioritize native debuggers and adapters with root markers. - Fixed DAP session initialization to subscribe to stop events before launching/attaching, preventing missed stopOnEntry events. - Refactored session event handling to fetch stack frames outside dispatch loop, eliminating deadlocks and improving responsiveness. - Enhanced evaluate requests to default to top stopped frame when frameId is not explicitly provided. --- packages/coding-agent/CHANGELOG.md | 10 +- packages/coding-agent/src/dap/client.ts | 270 +++++++++++++++++++- packages/coding-agent/src/dap/config.ts | 15 +- packages/coding-agent/src/dap/defaults.json | 1 + packages/coding-agent/src/dap/session.ts | 177 ++++++++----- packages/coding-agent/src/dap/types.ts | 6 + 6 files changed, 404 insertions(+), 75 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9e9a75c37..db3e8f738 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,9 +1,10 @@ # Changelog ## [Unreleased] - ### Added +- Socket-mode DAP adapter support for debuggers like dlv that communicate via network sockets instead of stdio; Linux uses unix domain sockets, macOS/other platforms use TCP with client-addr dialing +- Improved extensionless binary debugging: native debuggers (gdb, lldb-dap) and adapters with root markers are now preferred over unrelated adapters like debugpy - Debug tool with DAP (Debug Adapter Protocol) support for launching and attaching debuggers, setting breakpoints, stepping through execution, inspecting threads/stack/variables, and evaluating expressions - Debug adapter configuration for gdb, lldb-dap, debugpy, and dlv with language/file-type matching and root marker detection - Debug session management with support for source and function breakpoints, conditional breakpoints, stack trace inspection, scope/variable exploration, and program output capture @@ -19,11 +20,14 @@ ### Changed +- DAP session initialization now subscribes to stop events before launching/attaching to avoid missing stopOnEntry events +- Stack frame fetching moved outside the event dispatch loop to prevent deadlocks and improve responsiveness +- Evaluate requests now default to the top stopped frame when frameId is not explicitly provided - Eager todo enforcement now skips prompts ending with question marks or exclamation marks, treating them as queries or commands rather than statements requiring task planning - Chunk read output now displays fully-qualified anchor paths (e.g., `[class_Worker.fn_run#CRC]`) instead of bare names, making targets unambiguous for edits - Chunk edit tool documentation clarified: `target` must be the fully-qualified path with `#CRC` suffix; added guidance to run `read(path="file", sel="?")` for canonical target listings when anchor style is unclear - Chunk read tool documentation updated: `sel` parameter now documents the `?` selector for canonical target listings, and clarifies that default output shows full paths -- Chunk edit schema and tool contract: explicit `op` (`replace`, `delete`, `append`, `prepend`, `after`, `before`); sibling inserts use `anchor` instead of separate after/before target fields; `replace` supports optional `line` / `end_line` for line-scoped edits (former splice-style behavior); insert ops omit CRC where appropriate, mutations require checksum on target +- Chunk edit schema and tool contract: explicit `op` (`replace`, `append`, `prepend`, `after`, `before`); use `replace` with empty `content` to remove a chunk (no separate `delete` op); sibling inserts use `anchor` instead of separate after/before target fields; insert ops omit CRC where appropriate, mutations require checksum on target - Chunk path handling: parse selector and CRC separately, sanitize selectors (strip filename prefixes, uppercase checksums), accept embedded `#CRC` on targets, auto-accept stale CRC for later ops in the same batch on the same chunk - Chunk UX: streaming and final edit previews show chunk edits next to hashline edits with op-specific labels; prompt docs shortened with rules table, `…` in examples, and helper-based path/anchor samples - `log_experiment` only reverts files modified by the run; prompts and errors document that pre-existing dirty files are preserved; richer pending-run error context; `init_experiment` no-ops when the contract matches unless `new_segment`; secondary metrics informational only (no `force` for drift) @@ -43,6 +47,8 @@ ### Fixed +- DAP stopped event handling no longer blocks the message reader, preventing potential deadlocks during rapid event sequences +- Chunk-mode whole-chunk replaces now preserve attached leading comments and docblocks when replacement content starts at the declaration, preventing accidental comment loss during agent edits - Chunk edit error messages now consistently report checksum mismatches with the format `did not match checksum "XXXX"` instead of variable phrasing - Chunk selector validation for edits now rejects non-canonical selectors (suffix-only like `fn_run` or prefix-stripped like `run`), requiring fully-qualified paths to prevent ambiguity - Plan review previews now re-append at the chat tail on refresh, keeping them adjacent to the active selector instead of updating off-screen diff --git a/packages/coding-agent/src/dap/client.ts b/packages/coding-agent/src/dap/client.ts index bdef67e59..6e0297813 100644 --- a/packages/coding-agent/src/dap/client.ts +++ b/packages/coding-agent/src/dap/client.ts @@ -17,6 +17,12 @@ interface DapSpawnOptions { cwd: string; } +/** Minimal write interface shared by Bun.FileSink and Bun TCP sockets. */ +interface DapWriteSink { + write(data: string | Uint8Array): number | Promise; + flush(): number | Promise | undefined; +} + type DapEventHandler = (body: unknown, event: DapEventMessage) => void | Promise; const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; @@ -49,7 +55,7 @@ function parseMessage( }; } -async function writeMessage(sink: Bun.FileSink, message: DapRequestMessage | DapResponseMessage): Promise { +async function writeMessage(sink: DapWriteSink, message: DapRequestMessage | DapResponseMessage): Promise { const content = JSON.stringify(message); sink.write(`Content-Length: ${Buffer.byteLength(content, "utf-8")}\r\n\r\n`); sink.write(content); @@ -65,6 +71,12 @@ export class DapClient { readonly adapter: DapResolvedAdapter; readonly cwd: string; readonly proc: DapClientState["proc"]; + /** 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). */ + readonly #writeSink: DapWriteSink; + /** Optional socket to close on dispose (socket mode only). */ + readonly #socket?: { end(): void }; #requestSeq = 0; #pendingRequests = new Map(); #messageBuffer = Buffer.alloc(0); @@ -75,13 +87,24 @@ export class DapClient { #eventHandlers = new Map>(); #anyEventHandlers = new Set(); - constructor(adapter: DapResolvedAdapter, cwd: string, proc: DapClientState["proc"]) { + constructor( + adapter: DapResolvedAdapter, + cwd: string, + proc: DapClientState["proc"], + options?: { readable?: ReadableStream; writeSink?: DapWriteSink; socket?: { end(): void } }, + ) { this.adapter = adapter; this.cwd = cwd; this.proc = proc; + this.#readable = options?.readable ?? (proc.stdout as ReadableStream); + this.#writeSink = options?.writeSink ?? proc.stdin; + this.#socket = options?.socket; } static async spawn({ adapter, cwd }: DapSpawnOptions): Promise { + if (adapter.connectMode === "socket") { + return DapClient.#spawnSocket({ adapter, cwd }); + } // 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 @@ -104,6 +127,118 @@ export class DapClient { return client; } + /** + * Spawn a socket-mode adapter (e.g. dlv). + * Linux: connect to a unix domain socket via --listen=unix: + * macOS/other: the adapter dials into our TCP listener via --client-addr + */ + static async #spawnSocket({ adapter, cwd }: DapSpawnOptions): Promise { + const env = { + ...Bun.env, + ...NON_INTERACTIVE_ENV, + }; + const isLinux = process.platform === "linux"; + + if (isLinux) { + return DapClient.#spawnSocketUnix({ adapter, cwd, env }); + } + return DapClient.#spawnSocketClientAddr({ adapter, cwd, env }); + } + + /** Linux: spawn adapter with --listen=unix:, then connect to the socket. */ + static async #spawnSocketUnix({ + adapter, + cwd, + env, + }: { + adapter: DapResolvedAdapter; + cwd: string; + env: Record; + }): Promise { + const socketPath = `/tmp/dap-${adapter.name}-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`; + const proc = ptree.spawn([adapter.resolvedCommand, ...adapter.args, `--listen=unix:${socketPath}`], { + cwd, + stdin: "pipe", + env, + detached: true, + }); + + // Wait for the socket file to appear (dlv needs to start listening) + await waitForCondition( + () => { + try { + Bun.file(socketPath).size; + return true; + } catch { + return false; + } + }, + 10_000, + proc, + ); + + const { readable, writeSink, socket } = await connectSocket({ unix: socketPath }); + const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket }); + proc.exited.then(() => client.#handleProcessExit()); + void client.#startMessageReader(); + return client; + } + + /** macOS/other: listen on a random TCP port, spawn adapter with --client-addr, accept connection. */ + static async #spawnSocketClientAddr({ + adapter, + cwd, + env, + }: { + adapter: DapResolvedAdapter; + cwd: string; + env: Record; + }): Promise { + const { promise: connPromise, resolve: resolveConn } = Promise.withResolvers>(); + + // Listen on port 0 (OS picks a free port) + const server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open(socket) { + resolveConn(socket); + }, + data() {}, + close() {}, + error() {}, + }, + }); + + const port = server.port; + const proc = ptree.spawn([adapter.resolvedCommand, ...adapter.args, `--client-addr=127.0.0.1:${port}`], { + cwd, + stdin: "pipe", + env, + detached: true, + }); + + // Wait for dlv to connect (with timeout) + let rawSocket: Bun.Socket; + const { promise: timeoutPromise, reject: rejectTimeout } = Promise.withResolvers(); + const connectTimeout = setTimeout( + () => rejectTimeout(new Error(`${adapter.name} did not connect within 10s`)), + 10_000, + ); + try { + rawSocket = await Promise.race([connPromise, timeoutPromise]); + } finally { + clearTimeout(connectTimeout); + server.stop(); + } + + const { readable, writeSink, socket } = wrapBunSocket(rawSocket); + const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket }); + proc.exited.then(() => client.#handleProcessExit()); + void client.#startMessageReader(); + return client; + } + get capabilities(): DapCapabilities | undefined { return this.#capabilities; } @@ -235,7 +370,7 @@ export class DapClient { }); this.#lastActivity = Date.now(); try { - await writeMessage(this.proc.stdin, request); + await writeMessage(this.#writeSink, request); } catch (error) { this.#pendingRequests.delete(requestSeq); cleanup(); @@ -254,13 +389,18 @@ export class DapClient { ...(message ? { message } : {}), ...(body !== undefined ? { body } : {}), }; - await writeMessage(this.proc.stdin, response); + await writeMessage(this.#writeSink, response); } async dispose(): Promise { if (this.#disposed) return; this.#disposed = true; this.#rejectPendingRequests(new Error(`DAP adapter ${this.adapter.name} disposed`)); + try { + this.#socket?.end(); + } catch { + /* socket may already be closed */ + } try { this.proc.kill(); } catch (error) { @@ -275,7 +415,7 @@ export class DapClient { async #startMessageReader(): Promise { if (this.#isReading) return; this.#isReading = true; - const reader = (this.proc.stdout as ReadableStream).getReader(); + const reader = this.#readable.getReader(); try { while (true) { const { done, value } = await reader.read(); @@ -379,3 +519,123 @@ export class DapClient { this.#pendingRequests.clear(); } } + +/** Poll a condition until it returns true, or timeout/process exit. */ +async function waitForCondition( + check: () => boolean, + timeoutMs: number, + proc: { exitCode: number | null }, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (check()) return; + if (proc.exitCode !== null) { + throw new Error("Adapter process exited before socket was ready"); + } + await Bun.sleep(50); + } + throw new Error(`Socket not ready after ${timeoutMs}ms`); +} + +interface SocketTransport { + readable: ReadableStream; + writeSink: DapWriteSink; + socket: { end(): void }; +} + +/** Adapt a Bun.Socket to DapWriteSink. */ +function socketToSink(socket: Bun.Socket): DapWriteSink { + return { + write(data: string | Uint8Array) { + return socket.write(data); + }, + flush() { + socket.flush(); + }, + }; +} + +/** Connect to a unix domain socket and return DAP transport streams. */ +async function connectSocket(options: { unix: string }): Promise { + const { promise, resolve } = Promise.withResolvers(); + let streamController: ReadableStreamDefaultController; + + const readable = new ReadableStream({ + start(controller) { + streamController = controller; + }, + }); + + Bun.connect({ + unix: options.unix, + socket: { + open(socket) { + resolve({ + readable, + writeSink: socketToSink(socket), + socket, + }); + }, + data(_socket, data) { + streamController.enqueue(new Uint8Array(data)); + }, + close() { + try { + streamController.close(); + } catch { + /* already closed */ + } + }, + error(_socket, err) { + try { + streamController.error(err); + } catch { + /* already closed */ + } + }, + }, + }); + + return promise; +} + +/** Wrap an already-connected Bun.Socket into DAP transport streams. */ +function wrapBunSocket(rawSocket: Bun.Socket): SocketTransport { + let streamController: ReadableStreamDefaultController; + + const readable = new ReadableStream({ + start(controller) { + streamController = controller; + }, + }); + + // Attach data/close/error handlers to the already-open socket + rawSocket.reload({ + socket: { + open() {}, + data(_socket, data) { + streamController.enqueue(new Uint8Array(data)); + }, + close() { + try { + streamController.close(); + } catch { + /* already closed */ + } + }, + error(_socket, err) { + try { + streamController.error(err); + } catch { + /* already closed */ + } + }, + }, + }); + + return { + readable, + writeSink: socketToSink(rawSocket), + socket: rawSocket, + }; +} diff --git a/packages/coding-agent/src/dap/config.ts b/packages/coding-agent/src/dap/config.ts index 7d2306fe1..271a38fc6 100644 --- a/packages/coding-agent/src/dap/config.ts +++ b/packages/coding-agent/src/dap/config.ts @@ -18,6 +18,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; return { command: config.command, args: normalizeStringArray(config.args), @@ -26,6 +27,7 @@ function normalizeAdapterConfig(config: unknown): DapAdapterConfig | null { rootMarkers: normalizeStringArray(config.rootMarkers), launchDefaults: normalizeObject(config.launchDefaults), attachDefaults: normalizeObject(config.attachDefaults), + ...(connectMode ? { connectMode } : {}), }; } @@ -61,6 +63,7 @@ export function resolveAdapter(adapterName: string, cwd: string): DapResolvedAda rootMarkers: config.rootMarkers ?? [], launchDefaults: config.launchDefaults ?? {}, attachDefaults: config.attachDefaults ?? {}, + connectMode: config.connectMode ?? "stdio", }; } @@ -73,7 +76,17 @@ export function getAvailableAdapters(cwd: string): DapResolvedAdapter[] { function getMatchingAdapters(program: string, cwd: string): DapResolvedAdapter[] { const extension = path.extname(program).toLowerCase(); const available = getAvailableAdapters(cwd); - if (!extension) return available; + if (!extension) { + // For extensionless binaries, only consider native debuggers (gdb, lldb-dap) + // or adapters that match by root markers. Don't silently fall back to + // unrelated adapters like debugpy for a C binary. + const nativeDebuggers: ReadonlySet = new Set(EXTENSIONLESS_DEBUGGER_ORDER); + return available.filter( + adapter => + nativeDebuggers.has(adapter.name) || + (adapter.rootMarkers.length > 0 && hasRootMarkers(cwd, adapter.rootMarkers)), + ); + } const exactMatches = available.filter(adapter => adapter.fileTypes.includes(extension)); if (exactMatches.length > 0) { return exactMatches; diff --git a/packages/coding-agent/src/dap/defaults.json b/packages/coding-agent/src/dap/defaults.json index 05e22a6ea..dd3725236 100644 --- a/packages/coding-agent/src/dap/defaults.json +++ b/packages/coding-agent/src/dap/defaults.json @@ -61,6 +61,7 @@ "dlv": { "command": "dlv", "args": ["dap"], + "connectMode": "socket", "languages": ["go"], "fileTypes": [".go"], "rootMarkers": ["go.mod", "go.sum"], diff --git a/packages/coding-agent/src/dap/session.ts b/packages/coding-agent/src/dap/session.ts index 5b088ed1c..be2ecd472 100644 --- a/packages/coding-agent/src/dap/session.ts +++ b/packages/coding-agent/src/dap/session.ts @@ -62,11 +62,6 @@ interface DapSession { configurationDoneSent: boolean; } -interface DapEventWaitOptions { - timeoutMs: number; - signal?: AbortSignal; -} - export interface DapOutputSnapshot { snapshot: DapSessionSummary; output: string; @@ -189,16 +184,35 @@ export class DapSessionManager { cwd: options.cwd, args: options.args, }; + // Subscribe to stop events BEFORE launching so we don't miss + // stopOnEntry events that arrive before we start listening. + const initialStopPromise = this.#prepareStopOutcome( + session, + signal, + Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS), + ); // DAP spec: many adapters do not respond to launch until after // configurationDone. Fire launch, complete the config handshake, // then await the launch response. const launchPromise = client.sendRequest("launch", launchArguments, signal, timeoutMs); + // Mark handled so a fast error response doesn't become an unhandled + // rejection while we await the config handshake. The actual error + // still propagates when we await launchPromise below. + launchPromise.catch(() => {}); await this.#completeConfigurationHandshake(session, signal, timeoutMs); await launchPromise; - await this.#captureInitialState(session, { - timeoutMs: Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS), - signal, - }); + // Try to capture initial stopped state (e.g. stopOnEntry). + // Timeout is acceptable — the program may simply be running. + try { + await raceAbort(initialStopPromise, signal); + if (session.status === "stopped") { + await this.#fetchTopFrame(session, signal, Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS)); + } + } catch { + if (session.initializedSeen && session.status === "launching") { + session.status = session.configurationDoneSent ? "running" : "configuring"; + } + } return buildSummary(session); } catch (error) { await this.#disposeSession(session); @@ -228,13 +242,25 @@ export class DapSessionManager { ...(options.port !== undefined ? { port: options.port } : {}), ...(options.host ? { host: options.host } : {}), }; + const initialStopPromise = this.#prepareStopOutcome( + session, + signal, + Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS), + ); const attachPromise = client.sendRequest("attach", attachArguments, signal, timeoutMs); + attachPromise.catch(() => {}); await this.#completeConfigurationHandshake(session, signal, timeoutMs); await attachPromise; - await this.#captureInitialState(session, { - timeoutMs: Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS), - signal, - }); + try { + await raceAbort(initialStopPromise, signal); + if (session.status === "stopped") { + await this.#fetchTopFrame(session, signal, Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS)); + } + } catch { + if (session.initializedSeen && session.status === "launching") { + session.status = session.configurationDoneSent ? "running" : "configuring"; + } + } return buildSummary(session); } catch (error) { await this.#disposeSession(session); @@ -348,6 +374,12 @@ export class DapSessionManager { async continue(signal?: AbortSignal, timeoutMs: number = 30_000): Promise { const session = this.#touchActiveSession(); const threadId = await this.#resolveThreadId(session, signal, timeoutMs); + // Reset state and subscribe BEFORE sending continue to avoid missing + // events that arrive in the same buffer as the response. + session.stop = {}; + session.lastStackFrames = []; + session.status = "running"; + const outcomePromise = this.#prepareStopOutcome(session, signal, timeoutMs); await this.#sendRequestWithConfig( session, "continue", @@ -355,9 +387,7 @@ export class DapSessionManager { signal, timeoutMs, ); - session.status = "running"; - session.stop = {}; - return this.#waitForStopOutcome(session, signal, timeoutMs); + return this.#awaitStopOutcome(session, outcomePromise, signal, timeoutMs); } async pause(signal?: AbortSignal, timeoutMs: number = 30_000): Promise { @@ -471,10 +501,17 @@ export class DapSessionManager { timeoutMs: number = 30_000, ) { const session = this.#touchActiveSession(); + // Default to the top stopped frame so callers don't need to pass + // frame_id explicitly for the common case. + const effectiveFrameId = frameId ?? session.stop.frameId; const response = await this.#sendRequestWithConfig( session, "evaluate", - { expression, context, ...(frameId !== undefined ? { frameId } : {}) } satisfies DapEvaluateArguments, + { + expression, + context, + ...(effectiveFrameId !== undefined ? { frameId: effectiveFrameId } : {}), + } satisfies DapEvaluateArguments, signal, timeoutMs, ); @@ -575,12 +612,12 @@ export class DapSessionManager { client.onEvent("output", body => { truncateOutput(session, (body as DapOutputEventBody | undefined)?.output ?? ""); }); - client.onEvent("initialized", async () => { + client.onEvent("initialized", () => { session.initializedSeen = true; session.status = session.configurationDoneSent ? session.status : "configuring"; }); - client.onEvent("stopped", async body => { - await this.#handleStoppedEvent(session, body as DapStoppedEventBody); + client.onEvent("stopped", body => { + this.#handleStoppedEvent(session, body as DapStoppedEventBody); }); client.onEvent("continued", body => { const continued = body as { threadId?: number } | undefined; @@ -651,24 +688,7 @@ export class DapSessionManager { } } - async #captureInitialState(session: DapSession, options: DapEventWaitOptions): Promise { - try { - await raceAbort( - Promise.race([ - session.client.waitForEvent("stopped", undefined, options.signal, options.timeoutMs), - session.client.waitForEvent("initialized", undefined, options.signal, options.timeoutMs), - session.client.waitForEvent("terminated", undefined, options.signal, options.timeoutMs), - ]), - options.signal, - ); - } catch { - if (session.initializedSeen) { - session.status = session.configurationDoneSent ? "running" : "configuring"; - } - } - } - - async #handleStoppedEvent(session: DapSession, stopped: DapStoppedEventBody): Promise { + #handleStoppedEvent(session: DapSession, stopped: DapStoppedEventBody): void { session.status = "stopped"; session.stop = { threadId: stopped.threadId, @@ -677,24 +697,6 @@ export class DapSessionManager { text: stopped.text, }; session.lastStackFrames = []; - if (stopped.threadId === undefined) { - return; - } - try { - const response = await session.client.sendRequest( - "stackTrace", - { threadId: stopped.threadId, levels: 1 } satisfies DapStackTraceArguments, - undefined, - 5_000, - ); - session.lastStackFrames = response?.stackFrames ?? []; - this.#applyTopFrame(session, session.lastStackFrames[0]); - } catch (error) { - logger.debug("Failed to capture stopped frame", { - sessionId: session.id, - error: toErrorMessage(error), - }); - } } #applyTopFrame(session: DapSession, frame: DapStackFrame | undefined): void { @@ -706,28 +708,69 @@ export class DapSessionManager { session.stop.column = frame.column; } + /** + * Fetch the top stack frame from the adapter and apply it to the session's + * stop location. Called outside the event dispatch loop to avoid deadlocking + * the message reader. + */ + async #fetchTopFrame(session: DapSession, signal?: AbortSignal, timeoutMs: number = 5_000): Promise { + if (session.stop.threadId === undefined) return; + try { + const response = await session.client.sendRequest( + "stackTrace", + { threadId: session.stop.threadId, levels: 1 } satisfies DapStackTraceArguments, + signal, + timeoutMs, + ); + session.lastStackFrames = response?.stackFrames ?? []; + this.#applyTopFrame(session, session.lastStackFrames[0]); + } catch (error) { + logger.debug("Failed to capture stopped frame", { + sessionId: session.id, + error: toErrorMessage(error), + }); + } + } + async #step(command: "stepIn" | "stepOut" | "next", signal?: AbortSignal, timeoutMs: number = 30_000) { const session = this.#touchActiveSession(); const threadId = await this.#resolveThreadId(session, signal, timeoutMs); - await this.#sendRequestWithConfig(session, command, { threadId } satisfies DapStepArguments, signal, timeoutMs); + // Reset state and subscribe BEFORE sending the step command to avoid + // missing events that arrive in the same buffer as the response. + session.stop = {}; + session.lastStackFrames = []; session.status = "running"; - return this.#waitForStopOutcome(session, signal, timeoutMs); + const outcomePromise = this.#prepareStopOutcome(session, signal, timeoutMs); + await this.#sendRequestWithConfig(session, command, { threadId } satisfies DapStepArguments, signal, timeoutMs); + return this.#awaitStopOutcome(session, outcomePromise, signal, timeoutMs); } - async #waitForStopOutcome( + /** + * Create a promise that resolves when the session stops, terminates, or exits. + * MUST be called before the command that triggers the event. + */ + #prepareStopOutcome(session: DapSession, signal?: AbortSignal, timeoutMs: number = 30_000): Promise { + return Promise.race([ + session.client.waitForEvent("stopped", undefined, signal, timeoutMs), + session.client.waitForEvent("terminated", undefined, signal, timeoutMs), + session.client.waitForEvent("exited", undefined, signal, timeoutMs), + ]); + } + + /** + * Await a pre-subscribed stop outcome, then fetch the top frame if stopped. + */ + async #awaitStopOutcome( session: DapSession, + outcomePromise: Promise, signal?: AbortSignal, timeoutMs: number = 30_000, ): Promise { try { - await raceAbort( - Promise.race([ - session.client.waitForEvent("stopped", undefined, signal, timeoutMs), - session.client.waitForEvent("terminated", undefined, signal, timeoutMs), - session.client.waitForEvent("exited", undefined, signal, timeoutMs), - ]), - signal, - ); + await raceAbort(outcomePromise, signal); + if (session.status === "stopped") { + await this.#fetchTopFrame(session, signal, Math.min(timeoutMs, 5_000)); + } const state = session.status === "stopped" ? "stopped" : session.status === "terminated" ? "terminated" : "running"; return { snapshot: buildSummary(session), state, timedOut: false }; diff --git a/packages/coding-agent/src/dap/types.ts b/packages/coding-agent/src/dap/types.ts index 59e605756..b1ffbf5ee 100644 --- a/packages/coding-agent/src/dap/types.ts +++ b/packages/coding-agent/src/dap/types.ts @@ -347,6 +347,11 @@ export interface DapAdapterConfig { rootMarkers?: string[]; 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"; } export interface DapResolvedAdapter { @@ -359,6 +364,7 @@ export interface DapResolvedAdapter { rootMarkers: string[]; launchDefaults: Record; attachDefaults: Record; + connectMode: "stdio" | "socket"; } export interface DapBreakpointRecord {