4fcab2c789
The runInTerminal reverse-request handler spawned the debuggee via ptree.spawn (which always pipes stdout) but discarded the ChildProcess after reading its PID, leaving stdout unconsumed. ptree drains only stderr; the exposed stdout stream must be read or Bun buffers it unboundedly in-process. An output-heavy debuggee therefore grew omp's memory toward OOM (measured ~3.9GB RSS for a ~2GB-output child) and its output was lost from the session. Continuously drain the child's stdout into the session output buffer via truncateOutput, bounding memory at MAX_OUTPUT_BYTES and surfacing the program's output like the adapter's own output events. Fixes #8111
1869 lines
62 KiB
TypeScript
1869 lines
62 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[];
|
|
/** Serializes breakpoint mutations — see #serializeBreakpointMutation. */
|
|
breakpointMutationQueue: Promise<void>;
|
|
/** Recent output chunks; trimmed from the front when over MAX_OUTPUT_BYTES. */
|
|
outputChunks: string[];
|
|
/** Cumulative bytes of output ever received (reported in summaries). */
|
|
outputBytes: number;
|
|
/** Bytes currently buffered in outputChunks. */
|
|
outputBufferedBytes: number;
|
|
outputTruncated: boolean;
|
|
stop: DapStopLocation;
|
|
threads: DapThread[];
|
|
lastStackFrames: DapStackFrame[];
|
|
exitCode?: number;
|
|
capabilities?: DapCapabilities;
|
|
initializedSeen: boolean;
|
|
needsConfigurationDone: boolean;
|
|
configurationDoneSent: boolean;
|
|
parentSessionId?: string;
|
|
childSessionIds: Set<string>;
|
|
port?: number;
|
|
}
|
|
|
|
interface DapTreeOutcomeWaiter {
|
|
rootSessionId: string;
|
|
resolve(value: unknown): void;
|
|
reject(reason: unknown): void;
|
|
}
|
|
|
|
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;
|
|
const bytes = Buffer.byteLength(output, "utf-8");
|
|
session.outputChunks.push(output);
|
|
session.outputBytes += bytes;
|
|
session.outputBufferedBytes += bytes;
|
|
// Trim whole chunks from the front, but only while the remainder still
|
|
// holds a full MAX_OUTPUT_BYTES tail — dropping the front chunk whenever
|
|
// the total exceeded the cap could retain far less than the cap (e.g.
|
|
// [120KB, 10KB] would keep only 10KB). Recomputing one big string's byte
|
|
// length per 1KB trim iteration was O(n^2) inside the event dispatch loop.
|
|
while (session.outputChunks.length > 1) {
|
|
const frontBytes = Buffer.byteLength(session.outputChunks[0], "utf-8");
|
|
if (session.outputBufferedBytes - frontBytes < MAX_OUTPUT_BYTES) break;
|
|
session.outputChunks.shift();
|
|
session.outputBufferedBytes -= frontBytes;
|
|
session.outputTruncated = true;
|
|
}
|
|
if (session.outputBufferedBytes > MAX_OUTPUT_BYTES) {
|
|
// Byte-slice the front chunk's head so exactly the cap remains (a torn
|
|
// code point at the cut decodes as U+FFFD, acceptable for log output).
|
|
const front = session.outputChunks[0];
|
|
const frontBytes = Buffer.byteLength(front, "utf-8");
|
|
const excess = session.outputBufferedBytes - MAX_OUTPUT_BYTES;
|
|
const kept = Buffer.from(front, "utf-8").subarray(excess).toString("utf-8");
|
|
session.outputChunks[0] = kept;
|
|
session.outputBufferedBytes += Buffer.byteLength(kept, "utf-8") - frontBytes;
|
|
session.outputTruncated = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drain a `runInTerminal` debuggee's stdout into the session output buffer.
|
|
*
|
|
* `ptree.spawn` always pipes stdout and only eagerly drains stderr; the exposed
|
|
* stdout stream must be consumed or Bun buffers it unboundedly in this process
|
|
* (a chatty debuggee grows omp toward OOM). The reverse-request path has no
|
|
* terminal surface here, so route the child's stdout through {@link
|
|
* truncateOutput}: this bounds memory at `MAX_OUTPUT_BYTES` and surfaces the
|
|
* program's output to the agent, mirroring the adapter's own `output` events.
|
|
* Runs in the background for the child's lifetime; a killed child or closed pipe
|
|
* ends the loop quietly.
|
|
*/
|
|
async function drainTerminalStdout(stream: ReadableStream<Uint8Array>, session: DapSession): Promise<void> {
|
|
const decoder = new TextDecoder();
|
|
try {
|
|
for await (const chunk of stream) {
|
|
truncateOutput(session, decoder.decode(chunk, { stream: true }));
|
|
}
|
|
truncateOutput(session, decoder.decode());
|
|
} catch {
|
|
// Child killed or pipe closed mid-stream; nothing more to surface.
|
|
}
|
|
}
|
|
|
|
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,
|
|
parentSessionId: session.parentSessionId,
|
|
childSessionIds: session.childSessionIds.size > 0 ? [...session.childSessionIds] : undefined,
|
|
};
|
|
}
|
|
|
|
export class DapSessionManager {
|
|
#sessions = new Map<string, DapSession>();
|
|
#activeSessionId: string | null = null;
|
|
#cleanupLoopPromise?: Promise<void>;
|
|
#nextId = 0;
|
|
#treeOutcomeWaiters = new Set<DapTreeOutcomeWaiter>();
|
|
|
|
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,
|
|
...(options.extraLaunchArguments ?? {}),
|
|
program: options.program,
|
|
cwd: options.cwd,
|
|
...(options.args !== undefined ? { 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.
|
|
let resultSession = session;
|
|
try {
|
|
await untilAborted(signal, initialStopPromise);
|
|
const active = this.#getActiveSessionOrNull();
|
|
if (active && this.#getRootSession(active).id === session.id) {
|
|
resultSession = active;
|
|
}
|
|
if (resultSession.status === "stopped") {
|
|
await this.#fetchTopFrame(resultSession, signal, Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS));
|
|
}
|
|
} catch {
|
|
if (session.initializedSeen && session.status === "launching") {
|
|
session.status = session.configurationDoneSent ? "running" : "configuring";
|
|
}
|
|
}
|
|
return buildSummary(resultSession);
|
|
} 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;
|
|
let resultSession = session;
|
|
try {
|
|
await untilAborted(signal, initialStopPromise);
|
|
const active = this.#getActiveSessionOrNull();
|
|
if (active && this.#getRootSession(active).id === session.id) {
|
|
resultSession = active;
|
|
}
|
|
if (resultSession.status === "stopped") {
|
|
await this.#fetchTopFrame(resultSession, signal, Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS));
|
|
}
|
|
} catch {
|
|
if (session.initializedSeen && session.status === "launching") {
|
|
session.status = session.configurationDoneSent ? "running" : "configuring";
|
|
}
|
|
}
|
|
return buildSummary(resultSession);
|
|
} catch (error) {
|
|
await this.#disposeSession(session);
|
|
const mapped = mapDebugpyMissingModule(options.adapter.name, error);
|
|
if (mapped) throw mapped;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Serialize breakpoint mutations per session: every mutator does a
|
|
* read-modify-write of session state around an await, and the adapter-side
|
|
* set*Breakpoints request replaces the whole list — concurrent mutations
|
|
* would silently drop each other's breakpoints on both sides.
|
|
*/
|
|
#serializeBreakpointMutation<T>(session: DapSession, mutate: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
const run = session.breakpointMutationQueue.then(() => {
|
|
// A mutation can sit behind several queued 30s predecessors; honor a
|
|
// caller abort at dequeue instead of running a request nobody awaits.
|
|
if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error("Aborted");
|
|
return mutate();
|
|
});
|
|
session.breakpointMutationQueue = run.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
return run;
|
|
}
|
|
async #syncBreakpointTree(
|
|
origin: DapSession,
|
|
command: string,
|
|
args: unknown,
|
|
prepare: (session: DapSession) => void,
|
|
apply: (session: DapSession, breakpoints: DapBreakpoint[] | undefined) => void,
|
|
signal?: AbortSignal,
|
|
timeoutMs: number = 30_000,
|
|
): Promise<void> {
|
|
const sessions = this.#getTreeSessions(origin).filter(
|
|
session => session.status !== "terminated" && session.client.isAlive(),
|
|
);
|
|
for (const session of sessions) prepare(session);
|
|
await this.#serializeBreakpointMutation(
|
|
origin,
|
|
async () => {
|
|
const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
|
|
origin,
|
|
command,
|
|
args,
|
|
signal,
|
|
timeoutMs,
|
|
);
|
|
apply(origin, response?.breakpoints);
|
|
},
|
|
signal,
|
|
);
|
|
await Promise.all(
|
|
sessions
|
|
.filter(session => session !== origin)
|
|
.map(async session => {
|
|
try {
|
|
await this.#serializeBreakpointMutation(
|
|
session,
|
|
async () => {
|
|
const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
|
|
session,
|
|
command,
|
|
args,
|
|
signal,
|
|
timeoutMs,
|
|
);
|
|
apply(session, response?.breakpoints);
|
|
},
|
|
signal,
|
|
);
|
|
} catch (error) {
|
|
logger.warn("Failed to synchronize breakpoint request with child debug session", {
|
|
sessionId: session.id,
|
|
command,
|
|
error: toErrorMessage(error),
|
|
});
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
async setBreakpoint(
|
|
file: string,
|
|
line: number,
|
|
condition?: string,
|
|
signal?: AbortSignal,
|
|
timeoutMs: number = 30_000,
|
|
) {
|
|
const session = this.#touchActiveSession();
|
|
const sourcePath = normalizePath(file);
|
|
const root = this.#getRootSession(session);
|
|
const current = [...(root.breakpoints.get(sourcePath) ?? [])].filter(entry => entry.line !== line);
|
|
current.push({ verified: false, line, condition });
|
|
current.sort((left, right) => left.line - right.line);
|
|
const args = {
|
|
source: { path: sourcePath, name: path.basename(sourcePath) },
|
|
breakpoints: current.map<DapSourceBreakpoint>(entry => ({
|
|
line: entry.line,
|
|
...(entry.condition ? { condition: entry.condition } : {}),
|
|
})),
|
|
};
|
|
await this.#syncBreakpointTree(
|
|
session,
|
|
"setBreakpoints",
|
|
args,
|
|
target =>
|
|
target.breakpoints.set(
|
|
sourcePath,
|
|
current.map(entry => ({ ...entry, verified: false })),
|
|
),
|
|
(target, response) => target.breakpoints.set(sourcePath, this.#mapSourceBreakpoints(current, response)),
|
|
signal,
|
|
timeoutMs,
|
|
);
|
|
return {
|
|
snapshot: buildSummary(session),
|
|
breakpoints: session.breakpoints.get(sourcePath) ?? [],
|
|
sourcePath,
|
|
};
|
|
}
|
|
|
|
async removeBreakpoint(file: string, line: number, signal?: AbortSignal, timeoutMs: number = 30_000) {
|
|
const session = this.#touchActiveSession();
|
|
const sourcePath = normalizePath(file);
|
|
const root = this.#getRootSession(session);
|
|
const current = [...(root.breakpoints.get(sourcePath) ?? [])].filter(entry => entry.line !== line);
|
|
const args = {
|
|
source: { path: sourcePath, name: path.basename(sourcePath) },
|
|
breakpoints: current.map<DapSourceBreakpoint>(entry => ({
|
|
line: entry.line,
|
|
...(entry.condition ? { condition: entry.condition } : {}),
|
|
})),
|
|
};
|
|
const prepare = (target: DapSession) => {
|
|
if (current.length === 0) target.breakpoints.delete(sourcePath);
|
|
else
|
|
target.breakpoints.set(
|
|
sourcePath,
|
|
current.map(entry => ({ ...entry, verified: false })),
|
|
);
|
|
};
|
|
await this.#syncBreakpointTree(
|
|
session,
|
|
"setBreakpoints",
|
|
args,
|
|
prepare,
|
|
(target, response) => {
|
|
if (current.length === 0) target.breakpoints.delete(sourcePath);
|
|
else target.breakpoints.set(sourcePath, this.#mapSourceBreakpoints(current, response));
|
|
},
|
|
signal,
|
|
timeoutMs,
|
|
);
|
|
return {
|
|
snapshot: buildSummary(session),
|
|
breakpoints: session.breakpoints.get(sourcePath) ?? [],
|
|
sourcePath,
|
|
};
|
|
}
|
|
|
|
async setFunctionBreakpoint(name: string, condition?: string, signal?: AbortSignal, timeoutMs: number = 30_000) {
|
|
const session = this.#touchActiveSession();
|
|
const current = this.#getRootSession(session).functionBreakpoints.filter(entry => entry.name !== name);
|
|
current.push({ verified: false, name, condition });
|
|
current.sort((left, right) => left.name.localeCompare(right.name));
|
|
const args = {
|
|
breakpoints: current.map<DapFunctionBreakpoint>(entry => ({
|
|
name: entry.name,
|
|
...(entry.condition ? { condition: entry.condition } : {}),
|
|
})),
|
|
};
|
|
await this.#syncBreakpointTree(
|
|
session,
|
|
"setFunctionBreakpoints",
|
|
args,
|
|
target => {
|
|
target.functionBreakpoints = current.map(entry => ({ ...entry, verified: false }));
|
|
},
|
|
(target, response) => {
|
|
target.functionBreakpoints = this.#mapFunctionBreakpoints(current, response);
|
|
},
|
|
signal,
|
|
timeoutMs,
|
|
);
|
|
return { snapshot: buildSummary(session), breakpoints: session.functionBreakpoints };
|
|
}
|
|
|
|
async removeFunctionBreakpoint(name: string, signal?: AbortSignal, timeoutMs: number = 30_000) {
|
|
const session = this.#touchActiveSession();
|
|
const current = this.#getRootSession(session).functionBreakpoints.filter(entry => entry.name !== name);
|
|
const args = {
|
|
breakpoints: current.map<DapFunctionBreakpoint>(entry => ({
|
|
name: entry.name,
|
|
...(entry.condition ? { condition: entry.condition } : {}),
|
|
})),
|
|
};
|
|
await this.#syncBreakpointTree(
|
|
session,
|
|
"setFunctionBreakpoints",
|
|
args,
|
|
target => {
|
|
target.functionBreakpoints = current.map(entry => ({ ...entry, verified: false }));
|
|
},
|
|
(target, response) => {
|
|
target.functionBreakpoints = this.#mapFunctionBreakpoints(current, response);
|
|
},
|
|
signal,
|
|
timeoutMs,
|
|
);
|
|
return { snapshot: buildSummary(session), breakpoints: session.functionBreakpoints };
|
|
}
|
|
|
|
async setInstructionBreakpoint(
|
|
instructionReference: string,
|
|
offset?: number,
|
|
condition?: string,
|
|
hitCondition?: string,
|
|
signal?: AbortSignal,
|
|
timeoutMs: number = 30_000,
|
|
) {
|
|
const session = this.#touchActiveSession();
|
|
const current = this.#getRootSession(session).instructionBreakpoints.filter(
|
|
entry => entry.instructionReference !== instructionReference || entry.offset !== offset,
|
|
);
|
|
current.push({ instructionReference, offset, condition, hitCondition });
|
|
current.sort((left, right) => {
|
|
const referenceOrder = left.instructionReference.localeCompare(right.instructionReference);
|
|
return referenceOrder !== 0 ? referenceOrder : (left.offset ?? 0) - (right.offset ?? 0);
|
|
});
|
|
const args = { breakpoints: current } satisfies DapSetInstructionBreakpointsArguments;
|
|
let responseBreakpoints: DapBreakpoint[] | undefined;
|
|
await this.#syncBreakpointTree(
|
|
session,
|
|
"setInstructionBreakpoints",
|
|
args,
|
|
target => {
|
|
target.instructionBreakpoints = current.map(entry => ({ ...entry }));
|
|
},
|
|
(target, response) => {
|
|
if (target === session) responseBreakpoints = response;
|
|
},
|
|
signal,
|
|
timeoutMs,
|
|
);
|
|
return {
|
|
snapshot: buildSummary(session),
|
|
breakpoints: this.#mapInstructionBreakpoints(current, responseBreakpoints),
|
|
};
|
|
}
|
|
|
|
async removeInstructionBreakpoint(
|
|
instructionReference: string,
|
|
offset?: number,
|
|
signal?: AbortSignal,
|
|
timeoutMs: number = 30_000,
|
|
) {
|
|
const session = this.#touchActiveSession();
|
|
const current = this.#getRootSession(session).instructionBreakpoints.filter(entry => {
|
|
if (entry.instructionReference !== instructionReference) return true;
|
|
return offset !== undefined && entry.offset !== offset;
|
|
});
|
|
const args = { breakpoints: current } satisfies DapSetInstructionBreakpointsArguments;
|
|
let responseBreakpoints: DapBreakpoint[] | undefined;
|
|
await this.#syncBreakpointTree(
|
|
session,
|
|
"setInstructionBreakpoints",
|
|
args,
|
|
target => {
|
|
target.instructionBreakpoints = current.map(entry => ({ ...entry }));
|
|
},
|
|
(target, response) => {
|
|
if (target === session) responseBreakpoints = response;
|
|
},
|
|
signal,
|
|
timeoutMs,
|
|
);
|
|
return {
|
|
snapshot: buildSummary(session),
|
|
breakpoints: this.#mapInstructionBreakpoints(current, responseBreakpoints),
|
|
};
|
|
}
|
|
|
|
async dataBreakpointInfo(
|
|
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 = this.#getRootSession(session).dataBreakpoints.filter(entry => entry.dataId !== dataId);
|
|
current.push({ dataId, accessType, condition, hitCondition });
|
|
current.sort((left, right) => left.dataId.localeCompare(right.dataId));
|
|
const args = { breakpoints: current } satisfies DapSetDataBreakpointsArguments;
|
|
let responseBreakpoints: DapBreakpoint[] | undefined;
|
|
await this.#syncBreakpointTree(
|
|
session,
|
|
"setDataBreakpoints",
|
|
args,
|
|
target => {
|
|
target.dataBreakpoints = current.map(entry => ({ ...entry }));
|
|
},
|
|
(target, response) => {
|
|
if (target === session) responseBreakpoints = response;
|
|
},
|
|
signal,
|
|
timeoutMs,
|
|
);
|
|
return {
|
|
snapshot: buildSummary(session),
|
|
breakpoints: this.#mapDataBreakpoints(current, responseBreakpoints),
|
|
};
|
|
}
|
|
|
|
async removeDataBreakpoint(dataId: string, signal?: AbortSignal, timeoutMs: number = 30_000) {
|
|
const session = this.#touchActiveSession();
|
|
const current = this.#getRootSession(session).dataBreakpoints.filter(entry => entry.dataId !== dataId);
|
|
const args = { breakpoints: current } satisfies DapSetDataBreakpointsArguments;
|
|
let responseBreakpoints: DapBreakpoint[] | undefined;
|
|
await this.#syncBreakpointTree(
|
|
session,
|
|
"setDataBreakpoints",
|
|
args,
|
|
target => {
|
|
target.dataBreakpoints = current.map(entry => ({ ...entry }));
|
|
},
|
|
(target, response) => {
|
|
if (target === session) responseBreakpoints = response;
|
|
},
|
|
signal,
|
|
timeoutMs,
|
|
);
|
|
return {
|
|
snapshot: buildSummary(session),
|
|
breakpoints: this.#mapDataBreakpoints(current, responseBreakpoints),
|
|
};
|
|
}
|
|
|
|
async disassemble(
|
|
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();
|
|
// status is mutated by the event reader between awaits; check through a
|
|
// closure so TS does not carry stale narrowing from the early return.
|
|
const isStopped = () => session.status === "stopped";
|
|
if (isStopped()) {
|
|
return buildSummary(session);
|
|
}
|
|
const threadId = await this.#resolveThreadId(session, signal, timeoutMs);
|
|
// Subscribe BEFORE sending pause: the stopped event can arrive in the
|
|
// same chunk as the response and would otherwise be dispatched before
|
|
// the waiter subscribes, burning the whole timeout.
|
|
const stoppedPromise = session.client.waitForEvent<DapStoppedEventBody>("stopped", undefined, signal, timeoutMs);
|
|
stoppedPromise.catch(() => {});
|
|
await this.#sendRequestWithConfig(session, "pause", { threadId } satisfies DapPauseArguments, signal, timeoutMs);
|
|
if (!isStopped()) {
|
|
try {
|
|
await untilAborted(signal, stoppedPromise);
|
|
} 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 anchor = this.#touchActiveSession();
|
|
// A js-debug launch is a session tree: the root may be a threadless
|
|
// launcher while each real thread lives in a child (main script,
|
|
// `[worker N]`, …), and other adapters keep every thread on the root.
|
|
// Querying only the active session would surface just one session's
|
|
// threads, so aggregate across the whole live tree. No topology guess:
|
|
// a threadless launcher simply returns no threads (or an error we skip).
|
|
const targets = this.#liveTreeSessions(anchor);
|
|
const merged: DapThread[] = [];
|
|
const seen = new Set<string>();
|
|
for (const target of targets) {
|
|
let threads: DapThread[];
|
|
try {
|
|
const response = await this.#sendRequestWithConfig<DapThreadsResponse>(
|
|
target,
|
|
"threads",
|
|
undefined,
|
|
signal,
|
|
timeoutMs,
|
|
);
|
|
threads = response?.threads ?? [];
|
|
} catch (error) {
|
|
// Caller cancellation is not an adapter failure: propagate it instead
|
|
// of degrading a cancelled call into a successful partial result.
|
|
if (signal?.aborted) throw error;
|
|
logger.warn("Failed to list threads for debug session", {
|
|
sessionId: target.id,
|
|
error: toErrorMessage(error),
|
|
});
|
|
continue;
|
|
}
|
|
target.threads = threads;
|
|
// DAP thread IDs are scoped per client session, so identical IDs from
|
|
// different sessions (e.g. two identical worker scripts) are distinct
|
|
// live threads and MUST be preserved; only collapse an exact repeat
|
|
// within a single session's response.
|
|
for (const thread of threads) {
|
|
const key = `${target.id}\0${thread.id}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
merged.push(thread);
|
|
}
|
|
}
|
|
return { snapshot: buildSummary(anchor), threads: merged };
|
|
}
|
|
|
|
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();
|
|
const output = session.outputChunks.join("");
|
|
if (!limitBytes || limitBytes <= 0 || session.outputBufferedBytes <= limitBytes) {
|
|
return { snapshot: buildSummary(session), output };
|
|
}
|
|
// Byte-slice the tail once; a torn code point at the cut decodes as U+FFFD.
|
|
const buffer = Buffer.from(output, "utf-8");
|
|
if (buffer.length <= limitBytes) {
|
|
return { snapshot: buildSummary(session), output };
|
|
}
|
|
return { snapshot: buildSummary(session), output: buffer.subarray(buffer.length - limitBytes).toString("utf-8") };
|
|
}
|
|
|
|
async terminate(signal?: AbortSignal, timeoutMs: number = 30_000): Promise<DapSessionSummary | null> {
|
|
const session = this.#getActiveSessionOrNull();
|
|
if (!session) return null;
|
|
this.#touchSessionAndAncestors(session);
|
|
const root = this.#getRootSession(session);
|
|
const summary = buildSummary(session);
|
|
await this.#terminateSessionTree(root, signal, timeoutMs);
|
|
return summary;
|
|
}
|
|
|
|
async #terminateSessionTree(session: DapSession, signal?: AbortSignal, timeoutMs: number = 30_000): Promise<void> {
|
|
session.status = "terminated";
|
|
try {
|
|
for (const childId of [...session.childSessionIds]) {
|
|
const child = this.#sessions.get(childId);
|
|
if (child) {
|
|
await this.#terminateSessionTree(child, signal, timeoutMs);
|
|
}
|
|
}
|
|
if (session.capabilities?.supportsTerminateRequest) {
|
|
await session.client.sendRequest("terminate", undefined, signal, timeoutMs).catch(() => undefined);
|
|
}
|
|
await session.client
|
|
.sendRequest("disconnect", { terminateDebuggee: true }, signal, timeoutMs)
|
|
.catch(() => undefined);
|
|
} catch {
|
|
/* Disposal remains mandatory when a caller aborts best-effort DAP shutdown. */
|
|
} finally {
|
|
this.#disposeSession(session);
|
|
}
|
|
}
|
|
|
|
#startCleanupTimer(): void {
|
|
if (this.#cleanupLoopPromise) return;
|
|
this.#cleanupLoopPromise = this.#runCleanupLoop();
|
|
}
|
|
|
|
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 #startChildSession(
|
|
parent: DapSession,
|
|
request: "launch" | "attach",
|
|
configuration: Record<string, unknown>,
|
|
timeoutMs: number = 30_000,
|
|
): Promise<void> {
|
|
if (parent.adapter.connectMode !== "tcp" || parent.port === undefined) {
|
|
throw new Error(`DAP adapter ${parent.adapter.name} cannot accept child session connections`);
|
|
}
|
|
const cwd = path.resolve(parent.cwd, typeof configuration.cwd === "string" ? configuration.cwd : ".");
|
|
const client = await DapClient.connect({
|
|
adapter: parent.adapter,
|
|
cwd,
|
|
host: "127.0.0.1",
|
|
port: parent.port,
|
|
});
|
|
const child = this.#registerSession(
|
|
client,
|
|
parent.adapter,
|
|
cwd,
|
|
typeof configuration.program === "string" ? configuration.program : undefined,
|
|
parent.id,
|
|
);
|
|
try {
|
|
child.capabilities = await client.initialize(
|
|
this.#buildInitializeArguments(parent.adapter),
|
|
undefined,
|
|
timeoutMs,
|
|
);
|
|
child.needsConfigurationDone = child.capabilities.supportsConfigurationDoneRequest === true;
|
|
const startFailure: DapStartRequestFailure = { rejected: false };
|
|
const startPromise = trackDapStartRequest(
|
|
client.sendRequest(request, { ...configuration, cwd }, undefined, timeoutMs),
|
|
startFailure,
|
|
);
|
|
startPromise.catch(() => {});
|
|
try {
|
|
await this.#completeConfigurationHandshake(child, undefined, timeoutMs);
|
|
} catch (error) {
|
|
await throwPreferredDapStartError(request, startFailure, error);
|
|
}
|
|
await startPromise;
|
|
} catch (error) {
|
|
await this.#disposeSession(child);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async #applyRootBreakpointsToSession(
|
|
session: DapSession,
|
|
signal?: AbortSignal,
|
|
timeoutMs: number = 30_000,
|
|
): Promise<void> {
|
|
const root = this.#getRootSession(session);
|
|
for (const [sourcePath, entries] of root.breakpoints) {
|
|
try {
|
|
const response = await session.client.sendRequest<{ breakpoints?: DapBreakpoint[] }>(
|
|
"setBreakpoints",
|
|
{
|
|
source: { path: sourcePath, name: path.basename(sourcePath) },
|
|
breakpoints: entries.map<DapSourceBreakpoint>(entry => ({
|
|
line: entry.line,
|
|
...(entry.condition ? { condition: entry.condition } : {}),
|
|
})),
|
|
},
|
|
signal,
|
|
timeoutMs,
|
|
);
|
|
session.breakpoints.set(sourcePath, this.#mapSourceBreakpoints(entries, response?.breakpoints));
|
|
} catch (error) {
|
|
logger.warn("Failed to bind source breakpoints in child debug session", {
|
|
sessionId: session.id,
|
|
sourcePath,
|
|
error: toErrorMessage(error),
|
|
});
|
|
}
|
|
}
|
|
if (root.functionBreakpoints.length > 0) {
|
|
try {
|
|
const response = await session.client.sendRequest<{ breakpoints?: DapBreakpoint[] }>(
|
|
"setFunctionBreakpoints",
|
|
{
|
|
breakpoints: root.functionBreakpoints.map<DapFunctionBreakpoint>(entry => ({
|
|
name: entry.name,
|
|
...(entry.condition ? { condition: entry.condition } : {}),
|
|
})),
|
|
},
|
|
signal,
|
|
timeoutMs,
|
|
);
|
|
session.functionBreakpoints = this.#mapFunctionBreakpoints(root.functionBreakpoints, response?.breakpoints);
|
|
} catch (error) {
|
|
logger.warn("Failed to bind function breakpoints in child debug session", {
|
|
sessionId: session.id,
|
|
error: toErrorMessage(error),
|
|
});
|
|
}
|
|
}
|
|
if (root.instructionBreakpoints.length > 0) {
|
|
try {
|
|
await session.client.sendRequest(
|
|
"setInstructionBreakpoints",
|
|
{ breakpoints: root.instructionBreakpoints } satisfies DapSetInstructionBreakpointsArguments,
|
|
signal,
|
|
timeoutMs,
|
|
);
|
|
session.instructionBreakpoints = root.instructionBreakpoints.map(entry => ({ ...entry }));
|
|
} catch (error) {
|
|
logger.warn("Failed to bind instruction breakpoints in child debug session", {
|
|
sessionId: session.id,
|
|
error: toErrorMessage(error),
|
|
});
|
|
}
|
|
}
|
|
if (root.dataBreakpoints.length > 0) {
|
|
try {
|
|
await session.client.sendRequest(
|
|
"setDataBreakpoints",
|
|
{ breakpoints: root.dataBreakpoints } satisfies DapSetDataBreakpointsArguments,
|
|
signal,
|
|
timeoutMs,
|
|
);
|
|
session.dataBreakpoints = root.dataBreakpoints.map(entry => ({ ...entry }));
|
|
} catch (error) {
|
|
logger.debug("Failed to bind data breakpoints in child debug session", {
|
|
sessionId: session.id,
|
|
error: toErrorMessage(error),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
async #ensureLaunchSlot(): Promise<void> {
|
|
for (const session of [...this.#sessions.values()]) {
|
|
if (session.status === "terminated" || !session.client.isAlive()) {
|
|
this.#disposeSession(session);
|
|
}
|
|
}
|
|
const root = [...this.#sessions.values()].find(session => !session.parentSessionId);
|
|
if (!root) return;
|
|
throw new Error(`Debug session ${root.id} is still active. Terminate it before launching another.`);
|
|
}
|
|
|
|
#registerSession(
|
|
client: DapClient,
|
|
adapter: DapResolvedAdapter,
|
|
cwd: string,
|
|
program?: string,
|
|
parentSessionId?: string,
|
|
): DapSession {
|
|
const session: DapSession = {
|
|
id: `debug-${++this.#nextId}`,
|
|
adapter,
|
|
cwd,
|
|
program,
|
|
client,
|
|
status: "launching",
|
|
launchedAt: Date.now(),
|
|
lastUsedAt: Date.now(),
|
|
breakpoints: new Map(),
|
|
functionBreakpoints: [],
|
|
instructionBreakpoints: [],
|
|
dataBreakpoints: [],
|
|
breakpointMutationQueue: Promise.resolve(),
|
|
outputChunks: [],
|
|
outputBytes: 0,
|
|
outputBufferedBytes: 0,
|
|
outputTruncated: false,
|
|
stop: {},
|
|
threads: [],
|
|
lastStackFrames: [],
|
|
initializedSeen: false,
|
|
needsConfigurationDone: false,
|
|
configurationDoneSent: false,
|
|
parentSessionId,
|
|
childSessionIds: new Set(),
|
|
port: client.port,
|
|
};
|
|
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: path.resolve(session.cwd, args.cwd ?? "."),
|
|
stdin: "pipe",
|
|
env: {
|
|
...Bun.env,
|
|
...NON_INTERACTIVE_ENV,
|
|
...env,
|
|
},
|
|
detached: true,
|
|
});
|
|
// Consume the child's stdout — ptree pipes it but drains only stderr,
|
|
// so an unconsumed stream buffers unboundedly in this process.
|
|
void drainTerminalStdout(proc.stdout, session);
|
|
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,
|
|
});
|
|
await this.#startChildSession(session, request, configuration);
|
|
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);
|
|
this.#activeSessionId = session.id;
|
|
this.#resolveTreeOutcome(session);
|
|
});
|
|
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;
|
|
session.status = "terminated";
|
|
this.#reactivateAfterTermination(session);
|
|
this.#resolveTreeOutcome(session);
|
|
});
|
|
client.onEvent("terminated", () => {
|
|
session.status = "terminated";
|
|
this.#reactivateAfterTermination(session);
|
|
this.#resolveTreeOutcome(session);
|
|
});
|
|
this.#sessions.set(session.id, session);
|
|
if (parentSessionId) {
|
|
this.#sessions.get(parentSessionId)?.childSessionIds.add(session.id);
|
|
}
|
|
// Focus follows stops, not registrations: a lazily-attached child (e.g. a
|
|
// js-debug `[worker N]` session) must not steal focus from a sibling that
|
|
// is already stopped at a breakpoint / entry. Only claim focus when no
|
|
// live, stopped session currently holds it.
|
|
if (!this.#hasLiveStoppedActiveSession()) {
|
|
this.#activeSessionId = session.id;
|
|
}
|
|
const heartbeat = setInterval(() => {
|
|
if (!client.isAlive()) {
|
|
session.status = "terminated";
|
|
}
|
|
}, HEARTBEAT_INTERVAL_MS);
|
|
heartbeat.unref?.();
|
|
void client.proc.exited.finally(() => {
|
|
clearInterval(heartbeat);
|
|
session.status = "terminated";
|
|
this.#reactivateAfterTermination(session);
|
|
this.#resolveTreeOutcome(session);
|
|
});
|
|
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.configurationDoneSent) return;
|
|
if (!session.needsConfigurationDone) {
|
|
if (session.parentSessionId) {
|
|
await this.#applyRootBreakpointsToSession(session, signal, timeoutMs);
|
|
}
|
|
return;
|
|
}
|
|
// Wait for the initialized event if we haven't seen it yet.
|
|
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;
|
|
}
|
|
}
|
|
if (session.parentSessionId) {
|
|
await this.#applyRootBreakpointsToSession(session, signal, timeoutMs);
|
|
}
|
|
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 { promise, resolve, reject } = Promise.withResolvers<unknown>();
|
|
const rootSessionId = this.#getRootSession(session).id;
|
|
let timeout: NodeJS.Timeout | undefined;
|
|
let abortHandler: (() => void) | undefined;
|
|
const cleanup = () => {
|
|
clearTimeout(timeout);
|
|
if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
|
|
this.#treeOutcomeWaiters.delete(waiter);
|
|
};
|
|
const waiter: DapTreeOutcomeWaiter = {
|
|
rootSessionId,
|
|
resolve: value => {
|
|
cleanup();
|
|
resolve(value);
|
|
},
|
|
reject: reason => {
|
|
cleanup();
|
|
reject(reason);
|
|
},
|
|
};
|
|
this.#treeOutcomeWaiters.add(waiter);
|
|
timeout = setTimeout(
|
|
() => waiter.reject(new Error(`DAP session tree outcome timed out after ${timeoutMs}ms`)),
|
|
timeoutMs,
|
|
);
|
|
if (signal) {
|
|
abortHandler = () =>
|
|
waiter.reject(signal.reason instanceof Error ? signal.reason : new Error("Debug operation aborted"));
|
|
if (signal.aborted) abortHandler();
|
|
else signal.addEventListener("abort", abortHandler, { once: true });
|
|
}
|
|
promise.catch(() => {});
|
|
return promise;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
const active = this.#getActiveSessionOrNull();
|
|
const resultSession =
|
|
active && this.#getRootSession(active).id === this.#getRootSession(session).id ? active : session;
|
|
if (resultSession.status === "stopped") {
|
|
await this.#fetchTopFrame(resultSession, signal, Math.min(timeoutMs, 5_000));
|
|
}
|
|
const state =
|
|
resultSession.status === "stopped"
|
|
? "stopped"
|
|
: resultSession.status === "terminated"
|
|
? "terminated"
|
|
: "running";
|
|
return { snapshot: buildSummary(resultSession), state, timedOut: false };
|
|
} catch (error) {
|
|
if (signal?.aborted) throw error;
|
|
const active = this.#getActiveSessionOrNull();
|
|
const resultSession =
|
|
active && this.#getRootSession(active).id === this.#getRootSession(session).id ? active : session;
|
|
return {
|
|
snapshot: buildSummary(resultSession),
|
|
state: "running",
|
|
timedOut: resultSession.status === "running",
|
|
};
|
|
}
|
|
}
|
|
|
|
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);
|
|
this.#touchSessionAndAncestors(session);
|
|
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();
|
|
this.#touchSessionAndAncestors(session);
|
|
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;
|
|
}
|
|
|
|
/** True when the current active session is live and paused at a stop. */
|
|
#hasLiveStoppedActiveSession(): boolean {
|
|
const active = this.#getActiveSessionOrNull();
|
|
return active !== null && active.status === "stopped" && active.client.isAlive();
|
|
}
|
|
|
|
#getActiveSessionOrThrow(): DapSession {
|
|
const session = this.#getActiveSessionOrNull();
|
|
if (!session) {
|
|
throw new Error("No active debug session. Launch or attach first.");
|
|
}
|
|
return session;
|
|
}
|
|
|
|
#getRootSession(session: DapSession): DapSession {
|
|
let root = session;
|
|
while (root.parentSessionId) {
|
|
const parent = this.#sessions.get(root.parentSessionId);
|
|
if (!parent) break;
|
|
root = parent;
|
|
}
|
|
return root;
|
|
}
|
|
|
|
#getTreeSessions(session: DapSession): DapSession[] {
|
|
const sessions: DapSession[] = [];
|
|
const pending = [this.#getRootSession(session)];
|
|
while (pending.length > 0) {
|
|
const current = pending.pop();
|
|
if (!current) continue;
|
|
sessions.push(current);
|
|
for (const childId of current.childSessionIds) {
|
|
const child = this.#sessions.get(childId);
|
|
if (child) pending.push(child);
|
|
}
|
|
}
|
|
return sessions;
|
|
}
|
|
|
|
/**
|
|
* Live (non-terminated, connected) sessions in `session`'s tree, or the
|
|
* session itself when the tree has collapsed. Used to fan `threads` out
|
|
* across the whole tree; a threadless session just reports no threads, so
|
|
* this makes no assumption about which node owns them.
|
|
*/
|
|
#liveTreeSessions(session: DapSession): DapSession[] {
|
|
const live = this.#getTreeSessions(session).filter(
|
|
candidate => candidate.status !== "terminated" && candidate.client.isAlive(),
|
|
);
|
|
return live.length > 0 ? live : [session];
|
|
}
|
|
|
|
#touchSessionAndAncestors(session: DapSession): void {
|
|
const now = Date.now();
|
|
let current: DapSession | undefined = session;
|
|
while (current) {
|
|
current.lastUsedAt = now;
|
|
current = current.parentSessionId ? this.#sessions.get(current.parentSessionId) : undefined;
|
|
}
|
|
}
|
|
|
|
/** Point the active session at a live tree member when the active one terminates. */
|
|
#reactivateAfterTermination(session: DapSession): void {
|
|
if (this.#activeSessionId !== session.id) return;
|
|
const live = this.#getTreeSessions(session).filter(
|
|
candidate => candidate.status !== "terminated" && candidate.client.isAlive(),
|
|
);
|
|
if (live.length === 0) return;
|
|
const replacement =
|
|
live.find(candidate => candidate.status === "stopped") ??
|
|
live.find(candidate => candidate.parentSessionId !== undefined) ??
|
|
live[0];
|
|
this.#activeSessionId = replacement.id;
|
|
}
|
|
|
|
#resolveTreeOutcome(session: DapSession): void {
|
|
const rootId = this.#getRootSession(session).id;
|
|
for (const waiter of [...this.#treeOutcomeWaiters]) {
|
|
if (waiter.rootSessionId === rootId) {
|
|
waiter.resolve(undefined);
|
|
}
|
|
}
|
|
}
|
|
|
|
#disposeSession(session: DapSession): void {
|
|
if (!this.#sessions.has(session.id)) return;
|
|
for (const childId of [...session.childSessionIds]) {
|
|
const child = this.#sessions.get(childId);
|
|
if (child) this.#disposeSession(child);
|
|
}
|
|
this.#sessions.delete(session.id);
|
|
if (session.parentSessionId) {
|
|
this.#sessions.get(session.parentSessionId)?.childSessionIds.delete(session.id);
|
|
}
|
|
if (this.#activeSessionId === session.id) {
|
|
const parent = session.parentSessionId ? this.#sessions.get(session.parentSessionId) : undefined;
|
|
this.#activeSessionId = parent?.id ?? this.#sessions.values().next().value?.id ?? null;
|
|
}
|
|
void session.client.dispose().catch(() => {});
|
|
}
|
|
}
|
|
|
|
export const dapSessionManager = new DapSessionManager();
|