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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user