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.
This commit is contained in:
can1357
2026-04-08 04:16:45 +02:00
parent d19d51770f
commit 970e115140
6 changed files with 404 additions and 75 deletions
+8 -2
View File
@@ -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
+265 -5
View File
@@ -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<number>;
flush(): number | Promise<number> | undefined;
}
type DapEventHandler = (body: unknown, event: DapEventMessage) => void | Promise<void>;
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
@@ -49,7 +55,7 @@ function parseMessage(
};
}
async function writeMessage(sink: Bun.FileSink, message: DapRequestMessage | DapResponseMessage): Promise<void> {
async function writeMessage(sink: DapWriteSink, message: DapRequestMessage | DapResponseMessage): Promise<void> {
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<Uint8Array>;
/** 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<number, DapPendingRequest>();
#messageBuffer = Buffer.alloc(0);
@@ -75,13 +87,24 @@ export class DapClient {
#eventHandlers = new Map<string, Set<DapEventHandler>>();
#anyEventHandlers = new Set<DapEventHandler>();
constructor(adapter: DapResolvedAdapter, cwd: string, proc: DapClientState["proc"]) {
constructor(
adapter: DapResolvedAdapter,
cwd: string,
proc: DapClientState["proc"],
options?: { readable?: ReadableStream<Uint8Array>; writeSink?: DapWriteSink; socket?: { end(): void } },
) {
this.adapter = adapter;
this.cwd = cwd;
this.proc = proc;
this.#readable = options?.readable ?? (proc.stdout as ReadableStream<Uint8Array>);
this.#writeSink = options?.writeSink ?? proc.stdin;
this.#socket = options?.socket;
}
static async spawn({ adapter, cwd }: DapSpawnOptions): Promise<DapClient> {
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:<path>
* macOS/other: the adapter dials into our TCP listener via --client-addr
*/
static async #spawnSocket({ adapter, cwd }: DapSpawnOptions): Promise<DapClient> {
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:<path>, then connect to the socket. */
static async #spawnSocketUnix({
adapter,
cwd,
env,
}: {
adapter: DapResolvedAdapter;
cwd: string;
env: Record<string, string | undefined>;
}): Promise<DapClient> {
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<string, string | undefined>;
}): Promise<DapClient> {
const { promise: connPromise, resolve: resolveConn } = Promise.withResolvers<Bun.Socket<undefined>>();
// 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<undefined>;
const { promise: timeoutPromise, reject: rejectTimeout } = Promise.withResolvers<never>();
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<void> {
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<void> {
if (this.#isReading) return;
this.#isReading = true;
const reader = (this.proc.stdout as ReadableStream<Uint8Array>).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<void> {
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<Uint8Array>;
writeSink: DapWriteSink;
socket: { end(): void };
}
/** Adapt a Bun.Socket to DapWriteSink. */
function socketToSink(socket: Bun.Socket<undefined>): 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<SocketTransport> {
const { promise, resolve } = Promise.withResolvers<SocketTransport>();
let streamController: ReadableStreamDefaultController<Uint8Array>;
const readable = new ReadableStream<Uint8Array>({
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<undefined>): SocketTransport {
let streamController: ReadableStreamDefaultController<Uint8Array>;
const readable = new ReadableStream<Uint8Array>({
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,
};
}
+14 -1
View File
@@ -18,6 +18,7 @@ function normalizeObject(value: unknown): Record<string, unknown> {
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<string> = 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;
@@ -61,6 +61,7 @@
"dlv": {
"command": "dlv",
"args": ["dap"],
"connectMode": "socket",
"languages": ["go"],
"fileTypes": [".go"],
"rootMarkers": ["go.mod", "go.sum"],
+110 -67
View File
@@ -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<DapContinueOutcome> {
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<DapContinueResponse>(
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<DapSessionSummary> {
@@ -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<DapEvaluateResponse>(
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<void> {
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<void> {
#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<DapStackTraceResponse>(
"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<void> {
if (session.stop.threadId === undefined) return;
try {
const response = await session.client.sendRequest<DapStackTraceResponse>(
"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<unknown> {
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<unknown>,
signal?: AbortSignal,
timeoutMs: number = 30_000,
): Promise<DapContinueOutcome> {
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 };
+6
View File
@@ -347,6 +347,11 @@ export interface DapAdapterConfig {
rootMarkers?: string[];
launchDefaults?: Record<string, unknown>;
attachDefaults?: Record<string, unknown>;
/** "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<string, unknown>;
attachDefaults: Record<string, unknown>;
connectMode: "stdio" | "socket";
}
export interface DapBreakpointRecord {