Skip to content

Latest commit

 

History

History
571 lines (436 loc) · 20.8 KB

File metadata and controls

571 lines (436 loc) · 20.8 KB

Goal & constraints (grounded in current code)

Implement a real lldb-dap Debug Adapter Protocol backend that plugs into the existing debugger architecture without changing MCP tool names/schemas. The DAP backend remains opt-in only via XCODEBUILDMCP_DEBUGGER_BACKEND=dap (current selection logic in src/utils/debugger/debugger-manager.ts).

Key integration points already in place:

  • Backend contract: src/utils/debugger/backends/DebuggerBackend.ts
  • Backend selection & session lifecycle: src/utils/debugger/debugger-manager.ts
  • MCP tool surface area: src/mcp/tools/debugging/* (attach, breakpoints, stack, variables, command, detach)
  • Subprocess patterns: src/utils/execution/interactive-process.ts (interactive, piped stdio, test-safe default spawner)
  • DI/test safety: defaults throw under Vitest (getDefaultCommandExecutor, getDefaultInteractiveSpawner)
  • Docs baseline: docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md, docs/DEBUGGING_ARCHITECTURE.md

Implementation status (current)

Implemented modules and behavior (as of this document):

  • DAP protocol and transport: src/utils/debugger/dap/types.ts, src/utils/debugger/dap/transport.ts
  • Adapter discovery: src/utils/debugger/dap/adapter-discovery.ts
  • Backend implementation: src/utils/debugger/backends/dap-backend.ts
  • Conditional breakpoints: backend-level support via DebuggerBackend.addBreakpoint(..., { condition })
  • Tool updates: src/mcp/tools/debugging/debug_breakpoint_add.ts passes conditions to backend
  • Health check: doctor now reports lldb-dap availability
  • Tests: DAP transport framing, backend mapping, and debugger manager selection tests

MCP tool → DAP request mapping (current)

MCP tool DebuggerManager call DAP requests
debug_attach_sim createSessionattach initializeattachconfigurationDone
debug_lldb_command runCommand evaluate (context: repl)
debug_stack getStack threadsstackTrace
debug_variables getVariables threadsstackTracescopesvariables
debug_breakpoint_add addBreakpoint setBreakpoints / setFunctionBreakpoints
debug_breakpoint_remove removeBreakpoint setBreakpoints / setFunctionBreakpoints
debug_detach detach disconnect

Breakpoint strategy (current)

  • Breakpoints are stateful: DAP removal re-applies setBreakpoints/setFunctionBreakpoints with the remaining list.
  • Conditions are passed as part of the breakpoint request in both backends:
    • DAP: breakpoints[].condition or functionBreakpoints[].condition
    • LLDB CLI: breakpoint modify -c "<condition>" <id>

Architectural decisions to make (explicit)

1) Spawn model: one lldb-dap process per debug session

Decision: Each DebuggerManager.createSession() creates a new backend instance, which owns a single lldb-dap subprocess for the lifetime of that session.

  • Aligns with current LLDB CLI backend (one long-lived interactive lldb per session).
  • Keeps multi-session support (DebuggerManager.sessions: Map) straightforward.

2) Transport abstraction: DAP framing + request correlation in a dedicated module

Decision: Build a dedicated DAP transport that:

  • implements Content-Length framing
  • correlates requests/responses by seq
  • emits DAP events

This keeps DapBackend focused on mapping MCP tool operations → DAP requests.

3) Breakpoint conditions support: move condition handling into the backend API

Decision: Extend internal debugger API to support conditional breakpoints without relying on “LLDB command follow-ups” (which are CLI-specific).

This avoids depending on DAP evaluate for breakpoint modification and keeps semantics consistent across backends.


Implementation plan (by component / file)

A) Add DAP protocol & transport layer

New files

1) src/utils/debugger/dap/types.ts

Define minimal DAP types used by the backend (not a full spec).

Example types (illustrative, not exhaustive):

export type DapRequest<C = unknown> = {
  seq: number;
  type: 'request';
  command: string;
  arguments?: C;
};

export type DapResponse<B = unknown> = {
  seq: number;
  type: 'response';
  request_seq: number;
  success: boolean;
  command: string;
  message?: string;
  body?: B;
};

export type DapEvent<B = unknown> = {
  seq: number;
  type: 'event';
  event: string;
  body?: B;
};

Also define bodies used in mapping:

  • InitializeResponseBody (capabilities)
  • ThreadsResponseBody
  • StackTraceResponseBody
  • ScopesResponseBody
  • VariablesResponseBody
  • SetBreakpointsResponseBody
  • EvaluateResponseBody
  • event bodies: StoppedEventBody, OutputEventBody, TerminatedEventBody

Side effects / impact: none outside debugger subsystem; ensures type safety inside DAP modules.


2) src/utils/debugger/dap/transport.ts

Implement DAP over stdio.

Dependencies / imports

  • node:events (EventEmitter) or a small typed emitter pattern
  • src/utils/execution/index.ts for InteractiveSpawner and InteractiveProcess types
  • src/utils/logging/index.ts for log
  • src/utils/CommandExecutor.ts type (for adapter discovery helper if kept here)

Core responsibilities

  • Spawn adapter process (or accept an already spawned InteractiveProcess)
  • Parse stdout stream into discrete DAP messages using Content-Length framing
  • Maintain:
    • nextSeq: number
    • pending: Map<number, { resolve, reject, timeout }> keyed by request seq
  • Expose:
    • sendRequest(command, args, opts?) => Promise<body>
    • event subscription: onEvent(handler) or on('event', ...)
    • lifecycle: dispose() (must not throw)

Key function signatures

export type DapTransportOptions = {
  spawner: InteractiveSpawner;
  adapterCommand: string[]; // e.g. ['xcrun', 'lldb-dap'] or [resolvedPath]
  env?: Record<string, string>;
  cwd?: string;
  logPrefix?: string;
};

export class DapTransport {
  constructor(opts: DapTransportOptions);

  sendRequest<A, B>(
    command: string,
    args?: A,
    opts?: { timeoutMs?: number },
  ): Promise<B>;

  onEvent(handler: (evt: DapEvent) => void): () => void;

  dispose(): void; // best-effort, never throw
}

Framing logic

  • Maintain an internal Buffer/string accumulator for stdout.
  • Repeatedly:
    • find \r\n\r\n
    • parse headers for Content-Length
    • wait until body bytes are available
    • JSON.parse body into { type: 'response' | 'event' | 'request' }

Process failure handling

  • On adapter exit/error, reject all pending requests with a clear error (and include exit detail).
  • Log stderr output at debug level; do not feed stderr into framing.

Concurrency

  • Transport supports multiple in-flight requests concurrently (DAP allows it).
  • Backend may still serialize higher-level operations if stateful.

Side effects

  • Add a long-lived child process per session.
  • Requires careful memory management in the framing buffer (ensure you slice consumed bytes).

B) Adapter discovery (xcrun --find lldb-dap)

New helper (recommended)

3) src/utils/debugger/dap/adapter-discovery.ts (new)

Purpose: centralize resolution and produce actionable errors when DAP is explicitly selected but unavailable.

Uses

  • CommandExecutor to run xcrun --find lldb-dap
  • log for diagnostics
  • throw a DependencyError (from src/utils/errors.ts) or plain Error with a consistent message

Example signature:

import type { CommandExecutor } from '../../execution/index.ts';

export async function resolveLldbDapCommand(opts: {
  executor: CommandExecutor;
}): Promise<string[]>;
// returns e.g. ['xcrun', 'lldb-dap'] OR [absolutePath]

Design choice

  • Returning ['xcrun','lldb-dap'] is simplest (no dependency on parsing).
  • Returning [absolutePath] provides a stronger “tool exists” guarantee.

Impact

  • Enables a clean error message early in session creation.
  • Keeps DapBackend simpler.

C) Implement DapBackend (current)

Modify file: src/utils/debugger/backends/dap-backend.ts

Implemented as a real backend that:

  • discovers adapter (resolveLldbDapCommand)
  • creates DapTransport
  • performs DAP handshake (initialize)
  • attaches by PID (attach)
  • maps backend interface methods to DAP requests

Dependencies

  • DapTransport
  • resolveLldbDapCommand
  • getDefaultCommandExecutor and getDefaultInteractiveSpawner (production defaults)
  • log
  • existing backend interface/types

Constructor / factory Update createDapBackend() to accept injectable deps, mirroring the CLI backend’s injection style.

export async function createDapBackend(opts?: {
  executor?: CommandExecutor;
  spawner?: InteractiveSpawner;
  requestTimeoutMs?: number;
}): Promise<DebuggerBackend>;

This is critical for tests because defaults throw under Vitest.

Session state to maintain inside DapBackend

  • transport: DapTransport | null
  • attached: boolean
  • lastStoppedThreadId: number | null
  • cachedThreads: { id: number; name?: string }[] | null (optional)
  • breakpoint registry:
    • breakpointsById: Map<number, BreakpointSpec & { condition?: string }>
    • for DAP “remove breakpoint”, you must re-issue setBreakpoints/setFunctionBreakpoints with the updated list, so also keep:
      • fileLineBreakpointsByFile: Map<string, Array<{ line: number; condition?: string; id?: number }>>
      • functionBreakpoints: Array<{ name: string; condition?: string; id?: number }>
  • optional cached stack frames from the last stackTrace call (for variables lookup)

Backend lifecycle mapping

  • attach():

    1. spawn lldb-dap
    2. initialize
    3. attach with pid (+ waitFor mapping)
    4. configurationDone if required by lldb-dap behavior (plan for it even if no-op)
    5. mark attached
  • detach()

    • send disconnect with terminateDebuggee: false (do not kill app)
    • dispose transport / kill process
  • dispose()

    • best-effort cleanup; must not throw (important because DebuggerManager.createSession calls dispose to clean up on attach failure)

Method mappings (MCP tools → DebuggerManager → DapBackend)

  1. runCommand(command: string, opts?)
  • Map to DAP evaluate with context: 'repl'
  • Return string output from EvaluateResponse.body.result and/or body.output
  • If adapter doesn’t support command-style repl evaluation, return a clear error message suggesting lldb-cli backend.
  1. getStack(opts?: { threadIndex?: number; maxFrames?: number })
  • DAP sequence:
    • threads
    • select thread:
      • if a stopped event has a threadId, prefer that when threadIndex is undefined
      • else map threadIndex to array index (document this)
    • stackTrace({ threadId, startFrame: 0, levels: maxFrames })
  • Format output as readable text (LLDB-like) to keep tool behavior familiar:
    • frame #<i>: <name> at <path>:<line>
  • If stackTrace fails due to running state, return a helpful error:
    • “Process is running; pause or hit a breakpoint to fetch stack.”
  1. getVariables(opts?: { frameIndex?: number })
  • DAP sequence:
    • resolve thread as above
    • stackTrace to get frames
    • choose frame by frameIndex (default 0)
    • scopes({ frameId })
    • for each scope: variables({ variablesReference })
  • Format output as text with sections per scope:
    • Locals:\n x = 1\n y = ...
  1. addBreakpoint(spec: BreakpointSpec, opts?: { condition?: string })
  • For file-line:
    • update fileLineBreakpointsByFile[file]
    • call setBreakpoints({ source: { path: file }, breakpoints: [{ line, condition }] })
    • parse returned breakpoints[] to find matching line and capture id
  • For function:
    • update functionBreakpoints
    • call setFunctionBreakpoints({ breakpoints: [{ name, condition }] })
  • Return BreakpointInfo:
    • id must be a number (from DAP breakpoint id; if missing, generate a synthetic id and store mapping, but prefer real id)
    • rawOutput can be a pretty JSON snippet or a short text summary
  1. removeBreakpoint(id: number)
  • Look up spec in breakpointsById
  • Remove it from the corresponding registry
  • Re-issue setBreakpoints or setFunctionBreakpoints with the remaining breakpoints
  • Return text confirmation

Important: DAP vs existing condition flow

  • Today debug_breakpoint_add sets condition by issuing an LLDB command after creation.
  • With the above, condition becomes part of breakpoint creation and removal logic, backend-agnostic.

D) Internal API adjustment for conditional breakpoints (recommended)

Modify: src/utils/debugger/backends/DebuggerBackend.ts

Update signature:

addBreakpoint(spec: BreakpointSpec, opts?: { condition?: string }): Promise<BreakpointInfo>;

Modify: src/utils/debugger/debugger-manager.ts

Update method:

async addBreakpoint(
  id: string | undefined,
  spec: BreakpointSpec,
  opts?: { condition?: string },
): Promise<BreakpointInfo>

Pass opts through to backend.addBreakpoint.

Impact

  • Requires updating both backends + the tool call site.
  • Improves cross-backend compatibility and avoids “DAP evaluate must support breakpoint modify”.

Modify: src/utils/debugger/backends/lldb-cli-backend.ts

Implement condition via LLDB command internally after breakpoint creation (current behavior, just moved):

  • after parsing breakpoint id:
    • if opts?.condition, run breakpoint modify -c "<escaped>" <id>

This keeps condition support identical for LLDB CLI users.


E) Update MCP tool logic to use new breakpoint API

Modify: src/mcp/tools/debugging/debug_breakpoint_add.ts

Change logic to pass condition into ctx.debugger.addBreakpoint(...) and remove the follow-up breakpoint modify ... command.

Before

  • call addBreakpoint()
  • if condition, call runCommand("breakpoint modify ...")

After

  • call addBreakpoint(sessionId, spec, { condition })
  • no extra runCommand required

Impact / side effects

  • Output remains the same shape, but the “rawOutput” content for DAP may differ (acceptable).
  • Improves backend portability.

F) Backend selection & opt-in behavior (already mostly correct)

Modify (optional but recommended): src/utils/debugger/debugger-manager.ts

Keep selection rules but improve failure clarity:

  • If backend kind is dap, and adapter discovery fails, throw an error like:
    • DAP backend selected but lldb-dap not found. Ensure Xcode is installed and xcrun can locate lldb-dap, or set XCODEBUILDMCP_DEBUGGER_BACKEND=lldb-cli.

Also ensure that dispose failures do not mask attach failures:

  • in createSession catch, wrap dispose() in its own try/catch (even if backend should not throw).

G) Diagnostics / “doctor” integration (validation surface)

Modify: src/mcp/tools/doctor/doctor.ts (not shown in provided contents)

Add a DAP capability line:

  • lldb-dap available: yes/no
  • if env selects dap, include a prominent warning/error section when missing

Implementation approach:

  • reuse CommandExecutor and call xcrun --find lldb-dap
  • do not fail doctor entirely if missing; just report

Side effects

  • Improves discoverability and reduces “mystery failures” when users opt into dap.

Concurrency & state management plan

Transport-level

  • Fully concurrent in-flight DAP requests supported via:
    • seq generation
    • pending map keyed by seq
  • Each request can set its own timeout (timeoutMs).

Backend-level

Use a serialized queue only where state mutation occurs, e.g.:

  • updating breakpoint registries
  • attach/detach transitions

Pattern (same as LLDB CLI backend):

private queue: Promise<unknown> = Promise.resolve();

private enqueue<T>(work: () => Promise<T>): Promise<T> { ... }

Reasoning

  • Prevent races such as:
    • addBreakpoint + removeBreakpoint in parallel, reissuing setBreakpoints inconsistently.

Error handling & logging strategy

Error taxonomy (pragmatic, consistent with current tools)

  • Backend throws Error with clear messages.
  • MCP tools already catch and wrap errors via createErrorResponse(...).

Where to log

  • DapTransport:
    • log('debug', ...) for raw events (optionally gated by env)
    • log('error', ...) on process exit while requests are pending
  • DapBackend:
    • minimal info logs on attach/detach
    • debug logs for request mapping (command names, not full payloads unless opted in)

New optional env flags (config plan)

Document these (no need to require them):

  • XCODEBUILDMCP_DAP_REQUEST_TIMEOUT_MS (default to 30_000)
  • XCODEBUILDMCP_DAP_LOG_EVENTS=true (default false)

Tests (architecture-aware, DI-compliant)

Even though this is “testing”, it directly impacts design because default spawners/executors throw under Vitest.

1) Add a first-class mock interactive spawner utility

Modify: src/test-utils/mock-executors.ts

Add:

export function createMockInteractiveSpawner(script: {
  // map writes -> stdout/stderr emissions, or a programmable fake
}): InteractiveSpawner;

This avoids ad-hoc manual mocks and matches the project’s “approved mocks live in test-utils” philosophy.

2) DAP framing tests

New: src/utils/debugger/dap/__tests__/transport-framing.test.ts

  • Feed partial header/body chunks into the transport parser using PassThrough streams behind a mock InteractiveProcess.
  • Assert:
    • correct parsing across chunk boundaries
    • multiple messages in one chunk
    • invalid Content-Length handling

3) Backend mapping tests (no real lldb-dap)

New: src/utils/debugger/backends/__tests__/dap-backend.test.ts

  • Use createMockExecutor() to fake adapter discovery.
  • Use createMockInteractiveSpawner() to simulate an adapter that returns scripted DAP responses:
    • initialize → success
    • attach → success
    • threads/stackTrace/scopes/variables → stable fixtures
  • Validate:
    • getStack() formatting
    • getVariables() formatting
    • breakpoint add/remove registry behavior
    • dispose() never throws

4) DebuggerManager selection test

New: src/utils/debugger/__tests__/debugger-manager-dap.test.ts

  • Inject a custom backendFactory that returns a fake backend (or the scripted DAP backend) and verify:
    • env selection
    • attach failure triggers dispose
    • current session behavior unchanged

Docs updates (grounded in existing docs)

1) Update docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md

Replace/extend the existing outline with the following:

  • finalized module list (dap/types.ts, dap/transport.ts, discovery helper)
  • breakpoint strategy (stateful re-issue setBreakpoints)
  • explicit mapping table per MCP tool

2) Update docs/DEBUGGING_ARCHITECTURE.md

Add a section “DAP Backend (lldb-dap)”:

  • how it’s selected (opt-in)
  • differences vs LLDB CLI (structured stack/variables, breakpoint reapplication)
  • note about process state (stack/variables usually require stopped context)
  • explain that conditional breakpoints are implemented backend-side

Configuration & validation steps (manual / operational)

Validation steps (local)

  1. Ensure lldb-dap is discoverable:
    • xcrun --find lldb-dap
  2. Run server with DAP enabled:
    • XCODEBUILDMCP_DEBUGGER_BACKEND=dap node build/cli.js mcp
  3. Use existing MCP tool flow:
    • debug_attach_sim (attach by PID or bundleId)
    • debug_breakpoint_add (with condition)
    • trigger breakpoint (or pause via debug_lldb_command if implemented via evaluate)
    • debug_stack, debug_variables
    • debug_detach

Expected behavioral constraints to document

  • If the target is running and no stop context exists, DAP stackTrace/variables may fail; return guidance in tool output (“pause or set breakpoint”).

Summary of files modified / added

Add

  • src/utils/debugger/dap/types.ts
  • src/utils/debugger/dap/transport.ts
  • src/utils/debugger/dap/adapter-discovery.ts (recommended)

Modify

  • src/utils/debugger/backends/dap-backend.ts (real implementation)
  • src/utils/debugger/backends/DebuggerBackend.ts (add breakpoint condition option)
  • src/utils/debugger/backends/lldb-cli-backend.ts (support condition via new opts)
  • src/utils/debugger/debugger-manager.ts (pass-through opts; optional improved error handling)
  • src/mcp/tools/debugging/debug_breakpoint_add.ts (use backend-level condition support)
  • src/mcp/tools/doctor/doctor.ts (report lldb-dap availability)
  • docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md
  • docs/DEBUGGING_ARCHITECTURE.md
  • src/test-utils/mock-executors.ts (add mock interactive spawner)

Critical “don’t miss” requirements

  • dispose() in DAP backend and transport must be best-effort and never throw because DebuggerManager.createSession() will call dispose on attach failure.
  • Avoid any use of default executors/spawners in tests; ensure createDapBackend() accepts injected executor + spawner.
  • Breakpoint removal requires stateful re-application with setBreakpoints / setFunctionBreakpoints; plan for breakpoint registries from day one.