import type { DebuggerBackend } from './DebuggerBackend.ts';
import type { BreakpointInfo, BreakpointSpec, DebugExecutionState } from '../types.ts';
import type { CommandExecutor, InteractiveSpawner } from '../../execution/index.ts';
import { getDefaultCommandExecutor, getDefaultInteractiveSpawner } from '../../execution/index.ts';
import { log } from '../../logging/index.ts';
import type {
DapEvent,
EvaluateResponseBody,
ScopesResponseBody,
SetBreakpointsResponseBody,
StackTraceResponseBody,
StoppedEventBody,
ThreadsResponseBody,
VariablesResponseBody,
} from '../dap/types.ts';
import { DapTransport } from '../dap/transport.ts';
import { resolveLldbDapCommand } from '../dap/adapter-discovery.ts';
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
const LOG_PREFIX = '[DAP Backend]';
type FileLineBreakpointRecord = { line: number; condition?: string; id?: number };
type FunctionBreakpointRecord = { name: string; condition?: string; id?: number };
type BreakpointRecord = {
spec: BreakpointSpec;
condition?: string;
};
class DapBackend implements DebuggerBackend {
readonly kind = 'dap' as const;
private readonly executor: CommandExecutor;
private readonly spawner: InteractiveSpawner;
private readonly requestTimeoutMs: number;
private readonly logEvents: boolean;
private transport: DapTransport | null = null;
private unsubscribeEvents: (() => void) | null = null;
private attached = false;
private disposed = false;
private queue: Promise<unknown> = Promise.resolve();
private lastStoppedThreadId: number | null = null;
private executionState: DebugExecutionState = { status: 'unknown' };
private breakpointsById = new Map<number, BreakpointRecord>();
private fileLineBreakpointsByFile = new Map<string, FileLineBreakpointRecord[]>();
private functionBreakpoints: FunctionBreakpointRecord[] = [];
private nextSyntheticId = -1;
constructor(opts: {
executor: CommandExecutor;
spawner: InteractiveSpawner;
requestTimeoutMs: number;
logEvents: boolean;
}) {
this.executor = opts.executor;
this.spawner = opts.spawner;
this.requestTimeoutMs = opts.requestTimeoutMs;
this.logEvents = opts.logEvents;
}
async attach(opts: { pid: number; simulatorId: string; waitFor?: boolean }): Promise<void> {
void opts.simulatorId;
return this.enqueue(async () => {
if (this.disposed) {
throw new Error('DAP backend disposed');
}
if (this.attached) {
throw new Error('DAP backend already attached');
}
const adapterCommand = await resolveLldbDapCommand({ executor: this.executor });
const transport = new DapTransport({
spawner: this.spawner,
adapterCommand,
logPrefix: LOG_PREFIX,
});
this.transport = transport;
this.unsubscribeEvents = transport.onEvent((event) => this.handleEvent(event));
try {
const init = await this.request<
{
clientID: string;
clientName: string;
adapterID: string;
linesStartAt1: boolean;
columnsStartAt1: boolean;
pathFormat: string;
supportsVariableType: boolean;
supportsVariablePaging: boolean;
},
{ supportsConfigurationDoneRequest?: boolean }
>('initialize', {
clientID: 'xcodebuildmcp',
clientName: 'XcodeBuildMCP',
adapterID: 'lldb-dap',
linesStartAt1: true,
columnsStartAt1: true,
pathFormat: 'path',
supportsVariableType: true,
supportsVariablePaging: false,
});
await this.request('attach', {
pid: opts.pid,
waitFor: opts.waitFor ?? false,
});
if (init.supportsConfigurationDoneRequest !== false) {
await this.request('configurationDone', {});
}
this.attached = true;
log('info', `${LOG_PREFIX} attached to pid ${opts.pid}`);
} catch (error) {
this.cleanupTransport();
throw error;
}
});
}
async detach(): Promise<void> {
return this.enqueue(async () => {
if (!this.transport) return;
try {
await this.request('disconnect', { terminateDebuggee: false });
} finally {
this.cleanupTransport();
}
});
}
async runCommand(command: string, opts?: { timeoutMs?: number }): Promise<string> {
this.ensureAttached();
try {
const body = await this.request<
{ expression: string; context: string },
EvaluateResponseBody
>('evaluate', { expression: command, context: 'repl' }, opts);
return formatEvaluateResult(body);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (/evaluate|repl|not supported/i.test(message)) {
throw new Error(
'DAP backend does not support LLDB command evaluation. Set XCODEBUILDMCP_DEBUGGER_BACKEND=lldb-cli to use the CLI backend.',
);
}
throw error;
}
}
async resume(opts?: { threadId?: number }): Promise<void> {
return this.enqueue(async () => {
this.ensureAttached();
let threadId = opts?.threadId;
if (!threadId) {
const thread = await this.resolveThread();
threadId = thread.id;
}
await this.request('continue', { threadId });
this.executionState = { status: 'running' };
this.lastStoppedThreadId = null;
});
}
async addBreakpoint(
spec: BreakpointSpec,
opts?: { condition?: string },
): Promise<BreakpointInfo> {
return this.enqueue(async () => {
this.ensureAttached();
if (spec.kind === 'file-line') {
const current = this.fileLineBreakpointsByFile.get(spec.file) ?? [];
const nextBreakpoints = [...current, { line: spec.line, condition: opts?.condition }];
const updated = await this.setFileBreakpoints(spec.file, nextBreakpoints);
const added = updated[nextBreakpoints.length - 1];
if (!added?.id) {
throw new Error('DAP breakpoint id missing for file breakpoint.');
}
return {
id: added.id,
spec,
rawOutput: `Set breakpoint ${added.id} at ${spec.file}:${spec.line}`,
};
}
const nextBreakpoints = [
...this.functionBreakpoints,
{ name: spec.name, condition: opts?.condition },
];
const updated = await this.setFunctionBreakpoints(nextBreakpoints);
const added = updated[nextBreakpoints.length - 1];
if (!added?.id) {
throw new Error('DAP breakpoint id missing for function breakpoint.');
}
return {
id: added.id,
spec,
rawOutput: `Set breakpoint ${added.id} on ${spec.name}`,
};
});
}
async removeBreakpoint(id: number): Promise<string> {
return this.enqueue(async () => {
this.ensureAttached();
const record = this.breakpointsById.get(id);
if (!record) {
throw new Error(`Breakpoint not found: ${id}`);
}
if (record.spec.kind === 'file-line') {
const current = this.fileLineBreakpointsByFile.get(record.spec.file) ?? [];
const nextBreakpoints = current.filter((breakpoint) => breakpoint.id !== id);
await this.setFileBreakpoints(record.spec.file, nextBreakpoints);
} else {
const nextBreakpoints = this.functionBreakpoints.filter(
(breakpoint) => breakpoint.id !== id,
);
await this.setFunctionBreakpoints(nextBreakpoints);
}
return `Removed breakpoint ${id}.`;
});
}
async getStack(opts?: { threadIndex?: number; maxFrames?: number }): Promise<string> {
this.ensureAttached();
try {
const thread = await this.resolveThread(opts?.threadIndex);
const stack = await this.request<
{ threadId: number; startFrame?: number; levels?: number },
StackTraceResponseBody
>('stackTrace', {
threadId: thread.id,
startFrame: 0,
levels: opts?.maxFrames,
});
if (!stack.stackFrames.length) {
return `Thread ${thread.id}: no stack frames.`;
}
const threadLabel = thread.name
? `Thread ${thread.id} (${thread.name})`
: `Thread ${thread.id}`;
const formatted = stack.stackFrames.map((frame, index) => {
const location = frame.source?.path ?? frame.source?.name ?? 'unknown';
const line = frame.line ?? 0;
return `frame #${index}: ${frame.name} at ${location}:${line}`;
});
return [threadLabel, ...formatted].join('\n');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (/running|not stopped|no thread|no frames/i.test(message)) {
throw new Error('Process is running; pause or hit a breakpoint to fetch stack.');
}
throw error;
}
}
async getVariables(opts?: { frameIndex?: number }): Promise<string> {
this.ensureAttached();
try {
const thread = await this.resolveThread();
const frameIndex = opts?.frameIndex ?? 0;
const stack = await this.request<
{ threadId: number; startFrame?: number; levels?: number },
StackTraceResponseBody
>('stackTrace', {
threadId: thread.id,
startFrame: 0,
levels: frameIndex + 1,
});
if (stack.stackFrames.length <= frameIndex) {
throw new Error(`Frame index ${frameIndex} is out of range.`);
}
const frame = stack.stackFrames[frameIndex];
const scopes = await this.request<{ frameId: number }, ScopesResponseBody>('scopes', {
frameId: frame.id,
});
if (!scopes.scopes.length) {
return 'No scopes available.';
}
const sections: string[] = [];
for (const scope of scopes.scopes) {
if (!scope.variablesReference) {
sections.push(`${scope.name}:\n (no variables)`);
continue;
}
const vars = await this.request<{ variablesReference: number }, VariablesResponseBody>(
'variables',
{
variablesReference: scope.variablesReference,
},
);
if (!vars.variables.length) {
sections.push(`${scope.name}:\n (no variables)`);
continue;
}
const lines = vars.variables.map((variable) => ` ${formatVariable(variable)}`);
sections.push(`${scope.name}:\n${lines.join('\n')}`);
}
return sections.join('\n\n');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (/running|not stopped|no thread/i.test(message)) {
throw new Error('Process is running; pause or hit a breakpoint to fetch variables.');
}
throw error;
}
}
async getExecutionState(opts?: { timeoutMs?: number }): Promise<DebugExecutionState> {
return this.enqueue(async () => {
this.ensureAttached();
if (this.executionState.status !== 'unknown') {
return this.executionState;
}
try {
const body = await this.request<undefined, ThreadsResponseBody>('threads', undefined, opts);
const threads = body.threads ?? [];
if (!threads.length) {
return { status: 'unknown' };
}
const threadId = threads[0].id;
try {
await this.request<
{ threadId: number; startFrame?: number; levels?: number },
StackTraceResponseBody
>(
'stackTrace',
{ threadId, startFrame: 0, levels: 1 },
{ timeoutMs: opts?.timeoutMs ?? this.requestTimeoutMs },
);
const state: DebugExecutionState = { status: 'stopped', threadId };
this.executionState = state;
return state;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (/running|not stopped/i.test(message)) {
const state: DebugExecutionState = { status: 'running', description: message };
this.executionState = state;
return state;
}
return { status: 'unknown', description: message };
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (/running|not stopped/i.test(message)) {
return { status: 'running', description: message };
}
return { status: 'unknown', description: message };
}
});
}
async dispose(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
try {
this.cleanupTransport();
} catch (error) {
log('debug', `${LOG_PREFIX} dispose failed: ${String(error)}`);
}
}
private ensureAttached(): void {
if (!this.transport || !this.attached) {
throw new Error('No active DAP session. Attach first.');
}
}
private async request<A, B>(
command: string,
args?: A,
opts?: { timeoutMs?: number },
): Promise<B> {
const transport = this.transport;
if (!transport) {
throw new Error('DAP transport not initialized.');
}
return transport.sendRequest<A, B>(command, args, {
timeoutMs: opts?.timeoutMs ?? this.requestTimeoutMs,
});
}
private async resolveThread(threadIndex?: number): Promise<{ id: number; name?: string }> {
const body = await this.request<undefined, ThreadsResponseBody>('threads');
const threads = body.threads ?? [];
if (!threads.length) {
throw new Error('No threads available.');
}
if (typeof threadIndex === 'number') {
if (threadIndex < 0 || threadIndex >= threads.length) {
throw new Error(`Thread index ${threadIndex} is out of range.`);
}
return threads[threadIndex];
}
if (this.lastStoppedThreadId) {
const stopped = threads.find((thread) => thread.id === this.lastStoppedThreadId);
if (stopped) {
return stopped;
}
}
return threads[0];
}
private handleEvent(event: DapEvent): void {
if (this.logEvents) {
log('debug', `${LOG_PREFIX} event: ${JSON.stringify(event)}`);
}
if (event.event === 'stopped') {
const body = event.body as StoppedEventBody | undefined;
this.executionState = {
status: 'stopped',
reason: body?.reason,
description: body?.description,
threadId: body?.threadId,
};
if (body?.threadId) {
this.lastStoppedThreadId = body.threadId;
}
return;
}
if (event.event === 'continued') {
this.executionState = { status: 'running' };
this.lastStoppedThreadId = null;
return;
}
if (event.event === 'exited' || event.event === 'terminated') {
this.executionState = { status: 'terminated' };
this.lastStoppedThreadId = null;
}
}
private cleanupTransport(): void {
this.attached = false;
this.lastStoppedThreadId = null;
this.executionState = { status: 'unknown' };
this.unsubscribeEvents?.();
this.unsubscribeEvents = null;
if (this.transport) {
this.transport.dispose();
this.transport = null;
}
}
private async setFileBreakpoints(
file: string,
breakpoints: FileLineBreakpointRecord[],
): Promise<FileLineBreakpointRecord[]> {
const response = await this.request<
{ source: { path: string }; breakpoints: Array<{ line: number; condition?: string }> },
SetBreakpointsResponseBody
>('setBreakpoints', {
source: { path: file },
breakpoints: breakpoints.map((bp) => ({ line: bp.line, condition: bp.condition })),
});
const updated = breakpoints.map((bp, index) => ({
...bp,
id: resolveBreakpointId(response.breakpoints?.[index]?.id, () => this.nextSyntheticId--),
}));
this.replaceFileBreakpoints(file, updated);
return updated;
}
private replaceFileBreakpoints(file: string, breakpoints: FileLineBreakpointRecord[]): void {
const existing = this.fileLineBreakpointsByFile.get(file) ?? [];
for (const breakpoint of existing) {
if (breakpoint.id != null) {
this.breakpointsById.delete(breakpoint.id);
}
}
this.fileLineBreakpointsByFile.set(file, breakpoints);
for (const breakpoint of breakpoints) {
if (breakpoint.id != null) {
this.breakpointsById.set(breakpoint.id, {
spec: { kind: 'file-line', file, line: breakpoint.line },
condition: breakpoint.condition,
});
}
}
}
private async setFunctionBreakpoints(
breakpoints: FunctionBreakpointRecord[],
): Promise<FunctionBreakpointRecord[]> {
const response = await this.request<
{ breakpoints: Array<{ name: string; condition?: string }> },
SetBreakpointsResponseBody
>('setFunctionBreakpoints', {
breakpoints: breakpoints.map((bp) => ({ name: bp.name, condition: bp.condition })),
});
const updated = breakpoints.map((bp, index) => ({
...bp,
id: resolveBreakpointId(response.breakpoints?.[index]?.id, () => this.nextSyntheticId--),
}));
this.replaceFunctionBreakpoints(updated);
return updated;
}
private replaceFunctionBreakpoints(breakpoints: FunctionBreakpointRecord[]): void {
for (const breakpoint of this.functionBreakpoints) {
if (breakpoint.id != null) {
this.breakpointsById.delete(breakpoint.id);
}
}
this.functionBreakpoints = breakpoints;
for (const breakpoint of breakpoints) {
if (breakpoint.id != null) {
this.breakpointsById.set(breakpoint.id, {
spec: { kind: 'function', name: breakpoint.name },
condition: breakpoint.condition,
});
}
}
}
private enqueue<T>(work: () => Promise<T>): Promise<T> {
const next = this.queue.then(work, work) as Promise<T>;
this.queue = next.then(
() => undefined,
() => undefined,
);
return next;
}
}
function resolveBreakpointId(id: number | undefined, fallback: () => number): number {
if (typeof id === 'number' && Number.isFinite(id)) {
return id;
}
return fallback();
}
function formatEvaluateResult(body: EvaluateResponseBody): string {
const parts = [body.output, body.result].filter((value) => value && value.trim().length > 0);
return parts.join('\n');
}
function formatVariable(variable: { name: string; value: string; type?: string }): string {
const typeSuffix = variable.type ? ` (${variable.type})` : '';
return `${variable.name}${typeSuffix} = ${variable.value}`;
}
function parseRequestTimeoutMs(): number {
const raw = process.env.XCODEBUILDMCP_DAP_REQUEST_TIMEOUT_MS;
if (!raw) return DEFAULT_REQUEST_TIMEOUT_MS;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_REQUEST_TIMEOUT_MS;
}
return parsed;
}
function parseLogEvents(): boolean {
return process.env.XCODEBUILDMCP_DAP_LOG_EVENTS === 'true';
}
export async function createDapBackend(opts?: {
executor?: CommandExecutor;
spawner?: InteractiveSpawner;
requestTimeoutMs?: number;
}): Promise<DebuggerBackend> {
const executor = opts?.executor ?? getDefaultCommandExecutor();
const spawner = opts?.spawner ?? getDefaultInteractiveSpawner();
const requestTimeoutMs = opts?.requestTimeoutMs ?? parseRequestTimeoutMs();
const backend = new DapBackend({
executor,
spawner,
requestTimeoutMs,
logEvents: parseLogEvents(),
});
return backend;
}