Files
oh-my-pi/packages/coding-agent/src/dap/session.ts
T
oldschoola 96aad47eb6 fix(coding-agent): address PR #1388 review feedback
Refactor:
- session.ts: extract mapDebugpyMissingModule helper; replace the duplicated
  inline check in launch/attach catch blocks. Add jsdoc on DapStartRequestFailure.settled
  documenting per-call ownership and how throwPreferredDapStartError consumes it.
- path-utils.ts: replace the no-op keepOpaqueResourceUri branch with an
  OPAQUE_RESOURCE_SCHEMES Set so the structure carries the intent. Functionally
  equivalent; new opaque schemes become a one-line Set change.

Tests:
- dap-launch-failures: cover the debugpy stderr -> 'pip install debugpy'
  rewrite for launch and attach, plus a negative case (non-debugpy adapter
  with the substring in stderr is left untouched).
- dap-launch-failures: model the delayed-launch-failure case the new
  settled-race in throwPreferredDapStartError defends against. FakeDapClient
  gains optional launchErrorDelayMs/attachErrorDelayMs.
- dap-launch-failures (DebugTool): assert adapter:'debugpy' early-throw
  surfaces 'python not found in PATH' on both launch and attach when
  selectLaunchAdapter/selectAttachAdapter return null, and the unspecified-adapter
  path still falls back to the generic 'No debugger adapter' error.
- find.ts: export validateFindPathInputs and pin the new backslash-escape
  semantics (\, no longer trips the comma-joined heuristic) plus the
  existing brace-expansion and rejection paths.
- patch.ts: cover the post-write verification error message. The user-facing
  ToolError must contain the caller-supplied relative path and not the
  absolute resolvedPath (which still lives in the structured context for
  log correlation).
- split-internal-url-sel: reword two mcp:// test comments that described a
  'peeler refuses' guard that doesn't exist; rename the tests to reflect the
  actual opaque-scheme rule.
2026-05-26 01:54:09 -07:00

1340 lines
43 KiB
TypeScript

import * as path from "node:path";
import * as timers from "node:timers/promises";
import { logger, ptree, untilAborted } from "@oh-my-pi/pi-utils";
import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
import { DapClient } from "./client";
import type {
DapAttachArguments,
DapAttachSessionOptions,
DapBreakpoint,
DapBreakpointRecord,
DapCapabilities,
DapContinueArguments,
DapContinueOutcome,
DapContinueResponse,
DapDataBreakpoint,
DapDataBreakpointInfoArguments,
DapDataBreakpointInfoResponse,
DapDataBreakpointRecord,
DapDisassembleArguments,
DapDisassembledInstruction,
DapDisassembleResponse,
DapEvaluateArguments,
DapEvaluateResponse,
DapExitedEventBody,
DapFunctionBreakpoint,
DapFunctionBreakpointRecord,
DapInitializeArguments,
DapInstructionBreakpoint,
DapInstructionBreakpointRecord,
DapLaunchArguments,
DapLaunchSessionOptions,
DapLoadedSourcesResponse,
DapModule,
DapModulesArguments,
DapModulesResponse,
DapOutputEventBody,
DapPauseArguments,
DapReadMemoryArguments,
DapReadMemoryResponse,
DapResolvedAdapter,
DapRunInTerminalArguments,
DapRunInTerminalResponse,
DapScopesArguments,
DapScopesResponse,
DapSessionStatus,
DapSessionSummary,
DapSetDataBreakpointsArguments,
DapSetInstructionBreakpointsArguments,
DapSource,
DapSourceBreakpoint,
DapStackFrame,
DapStackTraceArguments,
DapStackTraceResponse,
DapStartDebuggingArguments,
DapStepArguments,
DapStopLocation,
DapStoppedEventBody,
DapThread,
DapThreadsResponse,
DapVariablesArguments,
DapVariablesResponse,
DapWriteMemoryArguments,
DapWriteMemoryResponse,
} from "./types";
interface DapSession {
id: string;
adapter: DapResolvedAdapter;
cwd: string;
program?: string;
client: DapClient;
status: DapSessionStatus;
launchedAt: number;
lastUsedAt: number;
breakpoints: Map<string, DapBreakpointRecord[]>;
functionBreakpoints: DapFunctionBreakpointRecord[];
instructionBreakpoints: DapInstructionBreakpoint[];
dataBreakpoints: DapDataBreakpoint[];
output: string;
outputBytes: number;
outputTruncated: boolean;
stop: DapStopLocation;
threads: DapThread[];
lastStackFrames: DapStackFrame[];
exitCode?: number;
capabilities?: DapCapabilities;
initializedSeen: boolean;
needsConfigurationDone: boolean;
configurationDoneSent: boolean;
}
export interface DapOutputSnapshot {
snapshot: DapSessionSummary;
output: string;
}
const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
const CLEANUP_INTERVAL_MS = 30 * 1000;
const HEARTBEAT_INTERVAL_MS = 5 * 1000;
const MAX_OUTPUT_BYTES = 128 * 1024;
const STOP_CAPTURE_TIMEOUT_MS = 5_000;
function toErrorMessage(value: unknown): string {
if (value instanceof Error) return value.message;
return String(value);
}
interface DapStartRequestFailure {
rejected: boolean;
error?: unknown;
/**
* Resolves (never rejects) when the underlying launch/attach request
* settles either way. Set by {@link trackDapStartRequest} on each call,
* so a single failure object must not be reused across launch attempts.
* Consumed by {@link throwPreferredDapStartError} to bound how long to
* wait for a delayed adapter-side rejection before falling back to the
* cascade error from configurationDone.
*/
settled?: Promise<void>;
}
function trackDapStartRequest<T>(promise: Promise<T>, failure: DapStartRequestFailure): Promise<T> {
const tracked = promise.catch(error => {
failure.rejected = true;
failure.error = error;
throw error;
});
failure.settled = tracked.then(
() => {},
() => {},
);
return tracked;
}
function combineDapStartErrors(command: "launch" | "attach", startError: unknown, configurationError: unknown): Error {
const startMessage = toErrorMessage(startError);
const configurationMessage = toErrorMessage(configurationError);
if (startMessage === configurationMessage) {
return startError instanceof Error ? startError : new Error(startMessage);
}
return new Error(
`DAP ${command} failed: ${startMessage}\nDAP configurationDone also failed: ${configurationMessage}`,
);
}
async function throwPreferredDapStartError(
command: "launch" | "attach",
startFailure: DapStartRequestFailure,
configurationError: unknown,
): Promise<never> {
await Promise.race([startFailure.settled ?? Promise.resolve(), timers.setTimeout(50)]);
if (startFailure.rejected) {
throw combineDapStartErrors(command, startFailure.error, configurationError);
}
throw configurationError;
}
const DEBUGPY_MISSING_MODULE_RE = /No module named ['"]?debugpy['"]?/;
/**
* Map a generic adapter-side failure into the targeted `pip install debugpy`
* hint when the adapter is debugpy and stderr/the wrapping error mentions
* the missing module. Returns null when the heuristic does not apply, so the
* caller can rethrow the original error untouched.
*/
function mapDebugpyMissingModule(adapterName: string, error: unknown): Error | null {
if (adapterName !== "debugpy") return null;
if (!DEBUGPY_MISSING_MODULE_RE.test(toErrorMessage(error))) return null;
return new Error("adapter 'debugpy' is not available: install with 'pip install debugpy'");
}
function normalizePath(filePath: string): string {
return path.resolve(filePath);
}
function truncateOutput(session: DapSession, output: string): void {
if (!output) return;
session.output += output;
session.outputBytes += Buffer.byteLength(output, "utf-8");
while (Buffer.byteLength(session.output, "utf-8") > MAX_OUTPUT_BYTES) {
session.output = session.output.slice(Math.min(1024, session.output.length));
session.outputTruncated = true;
}
}
function summarizeBreakpointCount(breakpoints: Map<string, DapBreakpointRecord[]>): number {
let total = 0;
for (const entries of breakpoints.values()) {
total += entries.length;
}
return total;
}
function buildSummary(session: DapSession): DapSessionSummary {
return {
id: session.id,
adapter: session.adapter.name,
cwd: session.cwd,
program: session.program,
status: session.status,
launchedAt: new Date(session.launchedAt).toISOString(),
lastUsedAt: new Date(session.lastUsedAt).toISOString(),
threadId: session.stop.threadId,
frameId: session.stop.frameId,
stopReason: session.stop.reason,
stopDescription: session.stop.description ?? session.stop.text,
frameName: session.stop.frameName,
instructionPointerReference: session.stop.instructionPointerReference,
source: session.stop.source,
line: session.stop.line,
column: session.stop.column,
breakpointFiles: session.breakpoints.size,
breakpointCount: summarizeBreakpointCount(session.breakpoints),
functionBreakpointCount: session.functionBreakpoints.length,
outputBytes: session.outputBytes,
outputTruncated: session.outputTruncated,
exitCode: session.exitCode,
needsConfigurationDone: session.needsConfigurationDone && !session.configurationDoneSent,
};
}
export class DapSessionManager {
#sessions = new Map<string, DapSession>();
#activeSessionId: string | null = null;
#cleanupLoopPromise?: Promise<void>;
#nextId = 0;
constructor() {
this.#startCleanupTimer();
}
getActiveSession(): DapSessionSummary | null {
const session = this.#getActiveSessionOrNull();
return session ? buildSummary(session) : null;
}
listSessions(): DapSessionSummary[] {
return Array.from(this.#sessions.values()).map(buildSummary);
}
getCapabilities(): DapCapabilities | null {
return this.#getActiveSessionOrNull()?.capabilities ?? null;
}
async launch(
options: DapLaunchSessionOptions,
signal?: AbortSignal,
timeoutMs: number = 30_000,
): Promise<DapSessionSummary> {
await this.#ensureLaunchSlot();
const client = await DapClient.spawn({ adapter: options.adapter, cwd: options.cwd });
const session = this.#registerSession(client, options.adapter, options.cwd, options.program);
try {
session.capabilities = await client.initialize(
this.#buildInitializeArguments(options.adapter),
signal,
timeoutMs,
);
session.needsConfigurationDone = session.capabilities.supportsConfigurationDoneRequest === true;
const launchArguments: DapLaunchArguments = {
...options.adapter.launchDefaults,
program: options.program,
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 launchFailure: DapStartRequestFailure = { rejected: false };
const launchPromise = trackDapStartRequest(
client.sendRequest("launch", launchArguments, signal, timeoutMs),
launchFailure,
);
// 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(() => {});
try {
await this.#completeConfigurationHandshake(session, signal, timeoutMs);
} catch (error) {
await throwPreferredDapStartError("launch", launchFailure, error);
}
await launchPromise;
// Try to capture initial stopped state (e.g. stopOnEntry).
// Timeout is acceptable — the program may simply be running.
try {
await untilAborted(signal, initialStopPromise);
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);
const mapped = mapDebugpyMissingModule(options.adapter.name, error);
if (mapped) throw mapped;
throw error;
}
}
async attach(
options: DapAttachSessionOptions,
signal?: AbortSignal,
timeoutMs: number = 30_000,
): Promise<DapSessionSummary> {
await this.#ensureLaunchSlot();
const client = await DapClient.spawn({ adapter: options.adapter, cwd: options.cwd });
const session = this.#registerSession(client, options.adapter, options.cwd);
try {
session.capabilities = await client.initialize(
this.#buildInitializeArguments(options.adapter),
signal,
timeoutMs,
);
session.needsConfigurationDone = session.capabilities.supportsConfigurationDoneRequest === true;
const attachArguments: DapAttachArguments = {
...options.adapter.attachDefaults,
cwd: options.cwd,
...(options.pid !== undefined ? { pid: options.pid, processId: options.pid } : {}),
...(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 attachFailure: DapStartRequestFailure = { rejected: false };
const attachPromise = trackDapStartRequest(
client.sendRequest("attach", attachArguments, signal, timeoutMs),
attachFailure,
);
attachPromise.catch(() => {});
try {
await this.#completeConfigurationHandshake(session, signal, timeoutMs);
} catch (error) {
await throwPreferredDapStartError("attach", attachFailure, error);
}
await attachPromise;
try {
await untilAborted(signal, initialStopPromise);
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);
const mapped = mapDebugpyMissingModule(options.adapter.name, error);
if (mapped) throw mapped;
throw error;
}
}
async setBreakpoint(
file: string,
line: number,
condition?: string,
signal?: AbortSignal,
timeoutMs: number = 30_000,
) {
const session = this.#touchActiveSession();
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<DapSourceBreakpoint>(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) ?? [],
sourcePath,
};
}
async removeBreakpoint(file: string, line: number, signal?: AbortSignal, timeoutMs: number = 30_000) {
const session = this.#touchActiveSession();
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<DapSourceBreakpoint>(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) ?? [],
sourcePath,
};
}
async setFunctionBreakpoint(name: string, condition?: string, signal?: AbortSignal, timeoutMs: number = 30_000) {
const session = this.#touchActiveSession();
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<DapFunctionBreakpoint>(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 };
}
async removeFunctionBreakpoint(name: string, signal?: AbortSignal, timeoutMs: number = 30_000) {
const session = this.#touchActiveSession();
const current = session.functionBreakpoints.filter(entry => entry.name !== name);
const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
session,
"setFunctionBreakpoints",
{
breakpoints: current.map<DapFunctionBreakpoint>(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 };
}
async setInstructionBreakpoint(
instructionReference: string,
offset?: number,
condition?: string,
hitCondition?: string,
signal?: AbortSignal,
timeoutMs: number = 30_000,
) {
const session = this.#touchActiveSession();
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),
};
}
async removeInstructionBreakpoint(
instructionReference: string,
offset?: number,
signal?: AbortSignal,
timeoutMs: number = 30_000,
) {
const session = this.#touchActiveSession();
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),
};
}
async dataBreakpointInfo(
name: string,
variablesReference?: number,
frameId?: number,
signal?: AbortSignal,
timeoutMs: number = 30_000,
): Promise<{ snapshot: DapSessionSummary; info: DapDataBreakpointInfoResponse }> {
const session = this.#touchActiveSession();
const info = await this.#sendRequestWithConfig<DapDataBreakpointInfoResponse>(
session,
"dataBreakpointInfo",
{
name,
...(variablesReference !== undefined ? { variablesReference } : {}),
...(frameId !== undefined ? { frameId } : {}),
} satisfies DapDataBreakpointInfoArguments,
signal,
timeoutMs,
);
return { snapshot: buildSummary(session), info };
}
async setDataBreakpoint(
dataId: string,
accessType?: "read" | "write" | "readWrite",
condition?: string,
hitCondition?: string,
signal?: AbortSignal,
timeoutMs: number = 30_000,
) {
const session = this.#touchActiveSession();
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),
};
}
async removeDataBreakpoint(dataId: string, signal?: AbortSignal, timeoutMs: number = 30_000) {
const session = this.#touchActiveSession();
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),
};
}
async disassemble(
memoryReference: string,
instructionCount: number,
offset?: number,
instructionOffset?: number,
resolveSymbols?: boolean,
signal?: AbortSignal,
timeoutMs: number = 30_000,
): Promise<{ snapshot: DapSessionSummary; instructions: DapDisassembledInstruction[] }> {
const session = this.#touchActiveSession();
const response = await this.#sendRequestWithConfig<DapDisassembleResponse>(
session,
"disassemble",
{
memoryReference,
instructionCount,
...(offset !== undefined ? { offset } : {}),
...(instructionOffset !== undefined ? { instructionOffset } : {}),
...(resolveSymbols !== undefined ? { resolveSymbols } : {}),
} satisfies DapDisassembleArguments,
signal,
timeoutMs,
);
return { snapshot: buildSummary(session), instructions: response?.instructions ?? [] };
}
async readMemory(
memoryReference: string,
count: number,
offset?: number,
signal?: AbortSignal,
timeoutMs: number = 30_000,
): Promise<{ snapshot: DapSessionSummary; address: string; data?: string; unreadableBytes?: number }> {
const session = this.#touchActiveSession();
const response = await this.#sendRequestWithConfig<DapReadMemoryResponse>(
session,
"readMemory",
{
memoryReference,
count,
...(offset !== undefined ? { offset } : {}),
} satisfies DapReadMemoryArguments,
signal,
timeoutMs,
);
return {
snapshot: buildSummary(session),
address: response?.address ?? memoryReference,
data: response?.data,
unreadableBytes: response?.unreadableBytes,
};
}
async writeMemory(
memoryReference: string,
data: string,
offset?: number,
allowPartial?: boolean,
signal?: AbortSignal,
timeoutMs: number = 30_000,
): Promise<{ snapshot: DapSessionSummary; offset?: number; bytesWritten?: number }> {
const session = this.#touchActiveSession();
const response = await this.#sendRequestWithConfig<DapWriteMemoryResponse>(
session,
"writeMemory",
{
memoryReference,
data,
...(offset !== undefined ? { offset } : {}),
...(allowPartial !== undefined ? { allowPartial } : {}),
} satisfies DapWriteMemoryArguments,
signal,
timeoutMs,
);
return {
snapshot: buildSummary(session),
offset: response?.offset,
bytesWritten: response?.bytesWritten,
};
}
async modules(
startModule?: number,
moduleCount?: number,
signal?: AbortSignal,
timeoutMs: number = 30_000,
): Promise<{ snapshot: DapSessionSummary; modules: DapModule[] }> {
const session = this.#touchActiveSession();
const response = await this.#sendRequestWithConfig<DapModulesResponse>(
session,
"modules",
{
...(startModule !== undefined ? { startModule } : {}),
...(moduleCount !== undefined ? { moduleCount } : {}),
} satisfies DapModulesArguments,
signal,
timeoutMs,
);
return { snapshot: buildSummary(session), modules: response?.modules ?? [] };
}
async loadedSources(
signal?: AbortSignal,
timeoutMs: number = 30_000,
): Promise<{ snapshot: DapSessionSummary; sources: DapSource[] }> {
const session = this.#touchActiveSession();
const response = await this.#sendRequestWithConfig<DapLoadedSourcesResponse>(
session,
"loadedSources",
{},
signal,
timeoutMs,
);
return { snapshot: buildSummary(session), sources: response?.sources ?? [] };
}
async customRequest(
command: string,
args?: Record<string, unknown>,
signal?: AbortSignal,
timeoutMs: number = 30_000,
): Promise<{ snapshot: DapSessionSummary; body: unknown }> {
const session = this.#touchActiveSession();
const body = await this.#sendRequestWithConfig<unknown>(session, command, args, signal, timeoutMs);
return { snapshot: buildSummary(session), body };
}
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",
{ threadId } satisfies DapContinueArguments,
signal,
timeoutMs,
);
return this.#awaitStopOutcome(session, outcomePromise, signal, timeoutMs);
}
async pause(signal?: AbortSignal, timeoutMs: number = 30_000): Promise<DapSessionSummary> {
const session = this.#touchActiveSession();
if (session.status === "stopped") {
return buildSummary(session);
}
const threadId = await this.#resolveThreadId(session, signal, timeoutMs);
await this.#sendRequestWithConfig(session, "pause", { threadId } satisfies DapPauseArguments, signal, timeoutMs);
// The stopped event may already have been processed by #handleStoppedEvent
// between the request and here. Wait for it, but tolerate timeout if the
// session already transitioned.
try {
await untilAborted(
signal,
session.client.waitForEvent<DapStoppedEventBody>("stopped", undefined, signal, timeoutMs),
);
} catch {
// Timeout or abort — report current state regardless
}
return buildSummary(session);
}
async stepIn(signal?: AbortSignal, timeoutMs: number = 30_000): Promise<DapContinueOutcome> {
return this.#step("stepIn", signal, timeoutMs);
}
async stepOut(signal?: AbortSignal, timeoutMs: number = 30_000): Promise<DapContinueOutcome> {
return this.#step("stepOut", signal, timeoutMs);
}
async stepOver(signal?: AbortSignal, timeoutMs: number = 30_000): Promise<DapContinueOutcome> {
return this.#step("next", signal, timeoutMs);
}
async threads(
signal?: AbortSignal,
timeoutMs: number = 30_000,
): Promise<{ snapshot: DapSessionSummary; threads: DapThread[] }> {
const session = this.#touchActiveSession();
const response = await this.#sendRequestWithConfig<DapThreadsResponse>(
session,
"threads",
undefined,
signal,
timeoutMs,
);
session.threads = response?.threads ?? [];
return { snapshot: buildSummary(session), threads: session.threads };
}
async stackTrace(
frameCount: number | undefined,
signal?: AbortSignal,
timeoutMs: number = 30_000,
): Promise<{ snapshot: DapSessionSummary; stackFrames: DapStackFrame[]; totalFrames?: number }> {
const session = this.#touchActiveSession();
const threadId = await this.#resolveThreadId(session, signal, timeoutMs);
const response = await this.#sendRequestWithConfig<DapStackTraceResponse>(
session,
"stackTrace",
{
threadId,
...(frameCount !== undefined ? { levels: frameCount } : {}),
} satisfies DapStackTraceArguments,
signal,
timeoutMs,
);
session.lastStackFrames = response?.stackFrames ?? [];
this.#applyTopFrame(session, session.lastStackFrames[0]);
return {
snapshot: buildSummary(session),
stackFrames: session.lastStackFrames,
totalFrames: response?.totalFrames,
};
}
async scopes(frameId: number | undefined, signal?: AbortSignal, timeoutMs: number = 30_000) {
const session = this.#touchActiveSession();
const resolvedFrameId = frameId ?? session.stop.frameId;
if (resolvedFrameId === undefined) {
throw new Error("No active stack frame. Run stack_trace first or supply frame_id.");
}
const response = await this.#sendRequestWithConfig<DapScopesResponse>(
session,
"scopes",
{ frameId: resolvedFrameId } satisfies DapScopesArguments,
signal,
timeoutMs,
);
return { snapshot: buildSummary(session), scopes: response?.scopes ?? [] };
}
async variables(variableReference: number, signal?: AbortSignal, timeoutMs: number = 30_000) {
const session = this.#touchActiveSession();
const response = await this.#sendRequestWithConfig<DapVariablesResponse>(
session,
"variables",
{ variablesReference: variableReference } satisfies DapVariablesArguments,
signal,
timeoutMs,
);
return { snapshot: buildSummary(session), variables: response?.variables ?? [] };
}
async evaluate(
expression: string,
context: DapEvaluateArguments["context"],
frameId: number | undefined,
signal?: AbortSignal,
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,
...(effectiveFrameId !== undefined ? { frameId: effectiveFrameId } : {}),
} satisfies DapEvaluateArguments,
signal,
timeoutMs,
);
return { snapshot: buildSummary(session), evaluation: response };
}
getOutput(limitBytes?: number): DapOutputSnapshot {
const session = this.#touchActiveSession();
if (!limitBytes || limitBytes <= 0 || Buffer.byteLength(session.output, "utf-8") <= limitBytes) {
return { snapshot: buildSummary(session), output: session.output };
}
let sliceStart = session.output.length;
let remaining = limitBytes;
while (sliceStart > 0 && remaining > 0) {
sliceStart -= 1;
remaining -= Buffer.byteLength(session.output[sliceStart] ?? "", "utf-8");
}
return { snapshot: buildSummary(session), output: session.output.slice(sliceStart) };
}
async terminate(signal?: AbortSignal, timeoutMs: number = 30_000): Promise<DapSessionSummary | null> {
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";
const summary = buildSummary(session);
await this.#disposeSession(session);
return summary;
}
#startCleanupTimer(): void {
if (this.#cleanupLoopPromise) return;
this.#cleanupLoopPromise = this.#runCleanupLoop();
}
async #runCleanupLoop(): Promise<void> {
for await (const _ of timers.setInterval(CLEANUP_INTERVAL_MS, null, { ref: false })) {
try {
this.#cleanupIdleSessions();
} catch (error) {
logger.error("DAP idle session cleanup failed", { error: toErrorMessage(error) });
}
}
}
#cleanupIdleSessions(): void {
if (this.#sessions.size === 0) return;
const now = Date.now();
for (const session of this.#sessions.values()) {
if (
session.status === "terminated" ||
now - session.lastUsedAt > IDLE_TIMEOUT_MS ||
!session.client.isAlive()
) {
this.#disposeSession(session);
}
}
}
async #ensureLaunchSlot(): Promise<void> {
const active = this.#getActiveSessionOrNull();
if (!active) return;
if (active.status === "terminated" || !active.client.isAlive()) {
await this.#disposeSession(active);
return;
}
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 {
const session: DapSession = {
id: `debug-${++this.#nextId}`,
adapter,
cwd,
program,
client,
status: "launching",
launchedAt: Date.now(),
lastUsedAt: Date.now(),
breakpoints: new Map(),
functionBreakpoints: [],
instructionBreakpoints: [],
dataBreakpoints: [],
output: "",
outputBytes: 0,
outputTruncated: false,
stop: {},
threads: [],
lastStackFrames: [],
initializedSeen: false,
needsConfigurationDone: false,
configurationDoneSent: false,
};
client.onReverseRequest("runInTerminal", async rawArgs => {
const args = (rawArgs ?? {}) as DapRunInTerminalArguments;
if (!Array.isArray(args.args) || args.args.length === 0) {
throw new Error("runInTerminal request did not include a command");
}
const env = Object.fromEntries(
Object.entries(args.env ?? {}).filter((entry): entry is [string, string] => entry[1] !== null),
);
const proc = ptree.spawn(args.args, {
cwd: args.cwd ?? session.cwd,
stdin: "pipe",
env: {
...Bun.env,
...NON_INTERACTIVE_ENV,
...env,
},
detached: true,
});
return { processId: proc.pid } satisfies DapRunInTerminalResponse;
});
client.onReverseRequest("startDebugging", async rawArgs => {
const startArgs = (rawArgs ?? {}) as Partial<DapStartDebuggingArguments>;
const request = startArgs.request === "attach" ? "attach" : "launch";
const configuration =
startArgs.configuration && typeof startArgs.configuration === "object" ? startArgs.configuration : {};
logger.debug("Adapter requested child debug session", {
adapter: session.adapter.name,
sessionId: session.id,
request,
name: typeof configuration.name === "string" ? configuration.name : undefined,
});
return {};
});
client.onEvent("output", body => {
truncateOutput(session, (body as DapOutputEventBody | undefined)?.output ?? "");
});
client.onEvent("initialized", () => {
session.initializedSeen = true;
session.status = session.configurationDoneSent ? session.status : "configuring";
});
client.onEvent("stopped", body => {
this.#handleStoppedEvent(session, body as DapStoppedEventBody);
});
client.onEvent("continued", body => {
const continued = body as { threadId?: number } | undefined;
session.status = "running";
session.stop = { threadId: continued?.threadId };
session.lastStackFrames = [];
});
client.onEvent("exited", body => {
session.exitCode = (body as DapExitedEventBody | undefined)?.exitCode;
});
client.onEvent("terminated", () => {
session.status = "terminated";
});
this.#sessions.set(session.id, session);
this.#activeSessionId = session.id;
const heartbeat = setInterval(() => {
if (!client.isAlive()) {
session.status = "terminated";
}
}, HEARTBEAT_INTERVAL_MS);
heartbeat.unref?.();
client.proc.exited.finally(() => clearInterval(heartbeat));
return session;
}
#buildInitializeArguments(adapter: DapResolvedAdapter): DapInitializeArguments {
return {
clientID: "omp",
clientName: "Oh My Pi",
adapterID: adapter.name,
locale: "en-US",
linesStartAt1: true,
columnsStartAt1: true,
pathFormat: "path",
supportsRunInTerminalRequest: true,
supportsStartDebuggingRequest: true,
supportsMemoryReferences: true,
supportsVariableType: true,
supportsInvalidatedEvent: true,
};
}
/**
* Wait for the adapter's `initialized` event (if not already received),
* then send `configurationDone`. Many adapters block the `launch`/`attach`
* response until this handshake completes.
*/
async #completeConfigurationHandshake(
session: DapSession,
signal?: AbortSignal,
timeoutMs: number = 30_000,
): Promise<void> {
if (!session.needsConfigurationDone || session.configurationDoneSent) {
return;
}
// Wait for the initialized event if we haven't seen it yet.
if (!session.initializedSeen) {
try {
await untilAborted(signal, session.client.waitForEvent("initialized", undefined, signal, timeoutMs));
} catch {
// Adapter may not send initialized (e.g. it already terminated).
// Proceed anyway — the launch/attach response will surface any real error.
return;
}
}
await session.client.sendRequest("configurationDone", {}, signal, timeoutMs);
session.configurationDoneSent = true;
if (session.status === "configuring") {
session.status = "running";
}
}
#handleStoppedEvent(session: DapSession, stopped: DapStoppedEventBody): void {
session.status = "stopped";
session.stop = {
threadId: stopped.threadId,
reason: stopped.reason,
description: stopped.description,
text: stopped.text,
};
session.lastStackFrames = [];
}
#applyTopFrame(session: DapSession, frame: DapStackFrame | undefined): void {
if (!frame) return;
session.stop.frameId = frame.id;
session.stop.frameName = frame.name;
session.stop.instructionPointerReference = frame.instructionPointerReference;
session.stop.source = frame.source;
session.stop.line = frame.line;
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);
// 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";
const outcomePromise = this.#prepareStopOutcome(session, signal, timeoutMs);
await this.#sendRequestWithConfig(session, command, { threadId } satisfies DapStepArguments, signal, timeoutMs);
return this.#awaitStopOutcome(session, outcomePromise, signal, timeoutMs);
}
/**
* 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> {
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 outcome = Promise.race(promises);
outcome.catch(() => {});
return outcome;
}
/**
* 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 untilAborted(signal, outcomePromise);
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 };
} catch (error) {
if (signal?.aborted) {
throw error;
}
return { snapshot: buildSummary(session), state: "running", timedOut: session.status === "running" };
}
}
async #resolveThreadId(session: DapSession, signal?: AbortSignal, timeoutMs: number = 30_000): Promise<number> {
if (session.stop.threadId !== undefined) {
return session.stop.threadId;
}
if (session.threads.length > 0) {
return session.threads[0].id;
}
const response = await session.client.sendRequest<DapThreadsResponse>("threads", undefined, signal, timeoutMs);
session.threads = response?.threads ?? [];
const threadId = session.threads[0]?.id;
if (threadId === undefined) {
throw new Error("Debugger reported no threads.");
}
return threadId;
}
async #sendRequestWithConfig<TBody>(
session: DapSession,
command: string,
args: unknown,
signal?: AbortSignal,
timeoutMs: number = 30_000,
): Promise<TBody> {
await this.#ensureConfigurationDone(session, signal, timeoutMs);
const body = await session.client.sendRequest<TBody>(command, args, signal, timeoutMs);
session.lastUsedAt = Date.now();
return body;
}
async #ensureConfigurationDone(
session: DapSession,
signal?: AbortSignal,
timeoutMs: number = 30_000,
): Promise<void> {
if (!session.needsConfigurationDone || session.configurationDoneSent) {
return;
}
await session.client.sendRequest("configurationDone", {}, signal, timeoutMs);
session.configurationDoneSent = true;
if (session.status === "configuring") {
session.status = "running";
}
}
#mapSourceBreakpoints(
input: DapBreakpointRecord[],
responseBreakpoints: DapBreakpoint[] | undefined,
): DapBreakpointRecord[] {
return input.map((entry, index) => ({
line: entry.line,
condition: entry.condition,
id: responseBreakpoints?.[index]?.id,
verified: responseBreakpoints?.[index]?.verified ?? false,
message: responseBreakpoints?.[index]?.message,
}));
}
#mapFunctionBreakpoints(
input: DapFunctionBreakpointRecord[],
responseBreakpoints: DapBreakpoint[] | undefined,
): DapFunctionBreakpointRecord[] {
return input.map((entry, index) => ({
name: entry.name,
condition: entry.condition,
id: responseBreakpoints?.[index]?.id,
verified: responseBreakpoints?.[index]?.verified ?? false,
message: responseBreakpoints?.[index]?.message,
}));
}
#mapInstructionBreakpoints(
input: DapInstructionBreakpoint[],
responseBreakpoints: DapBreakpoint[] | undefined,
): DapInstructionBreakpointRecord[] {
return input.map((entry, index) => ({
instructionReference: responseBreakpoints?.[index]?.instructionReference ?? entry.instructionReference,
offset: responseBreakpoints?.[index]?.offset ?? entry.offset,
condition: entry.condition,
hitCondition: entry.hitCondition,
id: responseBreakpoints?.[index]?.id,
verified: responseBreakpoints?.[index]?.verified ?? false,
message: responseBreakpoints?.[index]?.message,
}));
}
#mapDataBreakpoints(
input: DapDataBreakpoint[],
responseBreakpoints: DapBreakpoint[] | undefined,
): DapDataBreakpointRecord[] {
return input.map((entry, index) => ({
dataId: entry.dataId,
accessType: entry.accessType,
condition: entry.condition,
hitCondition: entry.hitCondition,
id: responseBreakpoints?.[index]?.id,
verified: responseBreakpoints?.[index]?.verified ?? false,
message: responseBreakpoints?.[index]?.message,
}));
}
#touchActiveSession(): DapSession {
const session = this.#getActiveSessionOrThrow();
session.lastUsedAt = Date.now();
if (session.status !== "terminated" && !session.client.isAlive()) {
session.status = "terminated";
}
return session;
}
#getActiveSessionOrNull(): DapSession | null {
if (!this.#activeSessionId) {
return null;
}
const session = this.#sessions.get(this.#activeSessionId) ?? null;
if (!session) {
this.#activeSessionId = null;
}
return session;
}
#getActiveSessionOrThrow(): DapSession {
const session = this.#getActiveSessionOrNull();
if (!session) {
throw new Error("No active debug session. Launch or attach first.");
}
return session;
}
#disposeSession(session: DapSession) {
if (this.#activeSessionId === session.id) {
this.#activeSessionId = null;
}
this.#sessions.delete(session.id);
void session.client.dispose().catch(() => {});
}
}
export const dapSessionManager = new DapSessionManager();