|
| 1 | +# Debugging Architecture |
| 2 | + |
| 3 | +This document describes how the simulator debugging tools are wired, how sessions are managed, |
| 4 | +and how external tools (simctl, Simulator, LLDB, xcodebuild) are invoked. |
| 5 | + |
| 6 | +## Scope |
| 7 | + |
| 8 | +- Tools: `src/mcp/tools/debugging/*` |
| 9 | +- Debugger subsystem: `src/utils/debugger/*` |
| 10 | +- Execution and tool wiring: `src/utils/typed-tool-factory.ts`, `src/utils/execution/*` |
| 11 | +- External invocation: `xcrun simctl`, `xcrun lldb`, `xcodebuild` |
| 12 | + |
| 13 | +## Registration and Wiring |
| 14 | + |
| 15 | +- Workflow discovery is automatic: `src/core/plugin-registry.ts` loads debugging tools via the |
| 16 | + generated workflow loaders (`src/core/generated-plugins.ts`). |
| 17 | +- Tool handlers are created with the typed tool factory: |
| 18 | + - `createTypedToolWithContext` for standard tools (Zod validation + dependency injection). |
| 19 | + - `createSessionAwareToolWithContext` for session-aware tools (merges session defaults and |
| 20 | + validates requirements). |
| 21 | +- Debugging tools inject a `DebuggerToolContext` that provides: |
| 22 | + - `executor`: a `CommandExecutor` used for simctl and other command execution. |
| 23 | + - `debugger`: a shared `DebuggerManager` instance. |
| 24 | + |
| 25 | +## Session Defaults and Validation |
| 26 | + |
| 27 | +- Session defaults live in `src/utils/session-store.ts` and are merged with user args by the |
| 28 | + session-aware tool factory. |
| 29 | +- `debug_attach_sim` is session-aware; it can omit `simulatorId`/`simulatorName` in the public |
| 30 | + schema and rely on session defaults. |
| 31 | +- The `XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS` env flag exposes legacy schemas that include all |
| 32 | + parameters (no session default hiding). |
| 33 | + |
| 34 | +## Debug Session Lifecycle |
| 35 | + |
| 36 | +`DebuggerManager` owns lifecycle, state, and backend routing: |
| 37 | + |
| 38 | +Backend selection happens inside `DebuggerManager.createSession`: |
| 39 | + |
| 40 | +- Selection order: explicit `backend` argument -> `XCODEBUILDMCP_DEBUGGER_BACKEND` -> default `lldb-cli`. |
| 41 | +- Env values: `lldb-cli`/`lldb` -> `lldb-cli`, `dap` -> `dap`, anything else throws. |
| 42 | +- Backend factory: `defaultBackendFactory` maps `lldb-cli` to `createLldbCliBackend` and `dap` to |
| 43 | + `createDapBackend`. A custom factory can be injected for tests or extensions. |
| 44 | + |
| 45 | +1. `debug_attach_sim` resolves simulator UUID and PID, then calls |
| 46 | + `DebuggerManager.createSession`. |
| 47 | +2. `DebuggerManager` creates a backend (default `lldb-cli`), attaches to the process, and stores |
| 48 | + session metadata (id, simulatorId, pid, timestamps). |
| 49 | +3. Debugging tools (`debug_lldb_command`, `debug_stack`, `debug_variables`, |
| 50 | + `debug_breakpoint_add/remove`) look up the session (explicit id or current) and route commands |
| 51 | + to the backend. |
| 52 | +4. `debug_detach` calls `DebuggerManager.detachSession` to detach and dispose the backend. |
| 53 | + |
| 54 | +## Debug Session + Command Execution Flow |
| 55 | + |
| 56 | +Session lifecycle flow (text): |
| 57 | + |
| 58 | +1. Client calls `debug_attach_sim`. |
| 59 | +2. `debug_attach_sim` resolves simulator UUID and PID, then calls `DebuggerManager.createSession`. |
| 60 | +3. `DebuggerManager.createSession` resolves backend kind (explicit/env/default), instantiates the |
| 61 | + backend, and calls `backend.attach`. |
| 62 | +4. Command tools (`debug_lldb_command`, `debug_stack`, `debug_variables`) call |
| 63 | + `DebuggerManager.runCommand`/`getStack`/`getVariables`, which route to the backend. |
| 64 | +5. `debug_detach` calls `DebuggerManager.detachSession`, which invokes `backend.detach` and |
| 65 | + `backend.dispose`. |
| 66 | + |
| 67 | +`LldbCliBackend.runCommand()` flow (text): |
| 68 | + |
| 69 | +1. Enqueue the command to serialize LLDB access. |
| 70 | +2. Await backend readiness (`initialize` completed). |
| 71 | +3. Write the command to the interactive process. |
| 72 | +4. Write `script print("__XCODEBUILDMCP_DONE__")` to emit the sentinel marker. |
| 73 | +5. Buffer stdout/stderr until the sentinel is detected. |
| 74 | +6. Trim the buffer to the next prompt, sanitize output, and return the result. |
| 75 | + |
| 76 | +<details> |
| 77 | +<summary>Sequence diagrams (Mermaid)</summary> |
| 78 | + |
| 79 | +```mermaid |
| 80 | +sequenceDiagram |
| 81 | + participant U as User/Client |
| 82 | + participant A as debug_attach_sim |
| 83 | + participant M as DebuggerManager |
| 84 | + participant F as backendFactory |
| 85 | + participant B as DebuggerBackend (lldb-cli|dap) |
| 86 | + participant L as LldbCliBackend |
| 87 | + participant P as InteractiveProcess (xcrun lldb) |
| 88 | +
|
| 89 | + U->>A: debug_attach_sim(simulator*, bundleId|pid) |
| 90 | + A->>A: determineSimulatorUuid(...) |
| 91 | + A->>A: resolveSimulatorAppPid(...) (if bundleId) |
| 92 | + A->>M: createSession({simulatorId, pid, waitFor}) |
| 93 | + M->>M: resolveBackendKind(explicit/env/default) |
| 94 | + M->>F: create backend(kind) |
| 95 | + F-->>M: backend instance |
| 96 | + M->>B: attach({pid, simulatorId, waitFor}) |
| 97 | + alt kind == lldb-cli |
| 98 | + B-->>L: (is LldbCliBackend) |
| 99 | + L->>P: spawn xcrun lldb + initialize prompt/sentinel |
| 100 | + else kind == dap |
| 101 | + B-->>M: throws DAP_ERROR_MESSAGE |
| 102 | + end |
| 103 | + M-->>A: DebugSessionInfo {id, backend, ...} |
| 104 | + A->>M: setCurrentSession(id) (optional) |
| 105 | + U->>M: runCommand(id?, "thread backtrace") |
| 106 | + M->>B: runCommand(...) |
| 107 | + U->>M: detachSession(id?) |
| 108 | + M->>B: detach() |
| 109 | + M->>B: dispose() |
| 110 | +``` |
| 111 | + |
| 112 | +```mermaid |
| 113 | +sequenceDiagram |
| 114 | + participant T as debug_lldb_command |
| 115 | + participant M as DebuggerManager |
| 116 | + participant L as LldbCliBackend |
| 117 | + participant P as InteractiveProcess |
| 118 | + participant S as stdout/stderr buffer |
| 119 | +
|
| 120 | + T->>M: runCommand(sessionId?, command, {timeoutMs?}) |
| 121 | + M->>L: runCommand(command) |
| 122 | + L->>L: enqueue(work) |
| 123 | + L->>L: await ready (initialize()) |
| 124 | + L->>P: write(command + "\n") |
| 125 | + L->>P: write('script print("__XCODEBUILDMCP_DONE__")\n') |
| 126 | + P-->>S: stdout/stderr chunks |
| 127 | + S-->>L: handleData() appends to buffer |
| 128 | + L->>L: checkPending() finds sentinel |
| 129 | + L->>L: slice output up to sentinel |
| 130 | + L->>L: trim buffer to next prompt (if present) |
| 131 | + L->>L: sanitizeOutput() + trimEnd() |
| 132 | + L-->>M: output string |
| 133 | + M-->>T: output string |
| 134 | +``` |
| 135 | + |
| 136 | +</details> |
| 137 | + |
| 138 | +## LLDB CLI Backend (Default) |
| 139 | + |
| 140 | +- Backend implementation: `src/utils/debugger/backends/lldb-cli-backend.ts`. |
| 141 | +- Uses `InteractiveSpawner` from `src/utils/execution/interactive-process.ts` to keep a single |
| 142 | + long-lived `xcrun lldb` process alive. |
| 143 | +- Keeps LLDB state (breakpoints, selected frames, target) across tool calls without reattaching. |
| 144 | + |
| 145 | +### Internals: interactive process model |
| 146 | + |
| 147 | +- The backend spawns `xcrun lldb --no-lldbinit -o "settings set prompt <prompt>"`. |
| 148 | +- `InteractiveProcess.write()` is used to send commands; stdout and stderr are merged into a single |
| 149 | + parse buffer. |
| 150 | +- `InteractiveProcess.dispose()` closes stdin, removes listeners, and kills the process. |
| 151 | + |
| 152 | +### Prompt and sentinel protocol |
| 153 | + |
| 154 | +The backend uses a prompt + sentinel protocol to detect command completion reliably: |
| 155 | + |
| 156 | +- `LLDB_PROMPT = "XCODEBUILDMCP_LLDB> "` |
| 157 | +- `COMMAND_SENTINEL = "__XCODEBUILDMCP_DONE__"` |
| 158 | + |
| 159 | +Definitions: |
| 160 | + |
| 161 | +- Prompt: the LLDB REPL prompt string that indicates LLDB is ready to accept the next command. |
| 162 | +- Sentinel: a unique marker explicitly printed after each command to mark the end of that |
| 163 | + command's output. |
| 164 | + |
| 165 | +Protocol flow: |
| 166 | + |
| 167 | +1. Startup: write `script print("__XCODEBUILDMCP_DONE__")` to prime the prompt parser. |
| 168 | +2. For each command: |
| 169 | + - Write the command. |
| 170 | + - Write `script print("__XCODEBUILDMCP_DONE__")`. |
| 171 | + - Read until the sentinel is observed, then trim up to the next prompt. |
| 172 | + |
| 173 | +The sentinel marks command completion, while the prompt indicates the REPL is ready for the next |
| 174 | +command. |
| 175 | + |
| 176 | +Why both a prompt and a sentinel? |
| 177 | + |
| 178 | +- The sentinel is the explicit end-of-output marker; LLDB does not provide a reliable boundary for |
| 179 | + arbitrary command output otherwise. |
| 180 | +- The prompt is used to cleanly align the buffer for the next command after the sentinel is seen. |
| 181 | + |
| 182 | +Annotated example (simplified): |
| 183 | + |
| 184 | +1. Backend writes: |
| 185 | + - `thread backtrace` |
| 186 | + - `script print("__XCODEBUILDMCP_DONE__")` |
| 187 | +2. LLDB emits (illustrative): |
| 188 | + - `... thread backtrace output ...` |
| 189 | + - `__XCODEBUILDMCP_DONE__` |
| 190 | + - `XCODEBUILDMCP_LLDB> ` |
| 191 | +3. Parser behavior: |
| 192 | + - Sentinel marks the end of the command output payload. |
| 193 | + - Prompt is used to trim the buffer so the next command starts cleanly. |
| 194 | + |
| 195 | +### Output parsing and sanitization |
| 196 | + |
| 197 | +- `handleData()` appends to an internal buffer, and `checkPending()` scans for the sentinel regex |
| 198 | + `/(^|\\r?\\n)__XCODEBUILDMCP_DONE__(\\r?\\n)/`. |
| 199 | +- Output is the buffer up to the sentinel. The remainder is trimmed to the next prompt if present. |
| 200 | +- `sanitizeOutput()` removes prompt echoes, sentinel lines, the `script print(...)` lines, and empty |
| 201 | + lines, then `runCommand()` returns `trimEnd()` output. |
| 202 | + |
| 203 | +### Concurrency model (queueing) |
| 204 | + |
| 205 | +- Commands are serialized through a promise queue to avoid interleaved output. |
| 206 | +- `waitForSentinel()` rejects if a pending command exists, acting as a safety check. |
| 207 | + |
| 208 | +### Timeouts, errors, and disposal |
| 209 | + |
| 210 | +- Startup timeout: `DEFAULT_STARTUP_TIMEOUT_MS = 10_000`. |
| 211 | +- Per-command timeout: `DEFAULT_COMMAND_TIMEOUT_MS = 30_000` (override via `runCommand` opts). |
| 212 | +- Timeout failure clears the pending command and rejects the promise. |
| 213 | +- `assertNoLldbError()` throws if `/error:/i` appears in output (simple heuristic). |
| 214 | +- Process exit triggers `failPending(new Error(...))` so in-flight calls fail promptly. |
| 215 | +- `runCommand()` rejects immediately if the backend is already disposed. |
| 216 | + |
| 217 | +### Testing and injection |
| 218 | + |
| 219 | +`getDefaultInteractiveSpawner()` throws in test environments to prevent spawning real interactive |
| 220 | +processes. Tests should inject a mock `InteractiveSpawner` into `createLldbCliBackend()` or a custom |
| 221 | +`DebuggerManager` backend factory. |
| 222 | + |
| 223 | +## DAP Backend (Stub / Not Implemented) |
| 224 | + |
| 225 | +- Implementation: `src/utils/debugger/backends/dap-backend.ts`. |
| 226 | +- Selected via backend selection (explicit `backend`, `XCODEBUILDMCP_DEBUGGER_BACKEND=dap`). |
| 227 | +- Current status: not implemented. All `DebuggerBackend` methods throw `DAP_ERROR_MESSAGE`, |
| 228 | + including `dispose()`. |
| 229 | + |
| 230 | +Practical effect: |
| 231 | + |
| 232 | +- Setting `XCODEBUILDMCP_DEBUGGER_BACKEND=dap` causes `debug_attach_sim` to fail during |
| 233 | + session creation because `backend.attach()` throws. |
| 234 | +- `DebuggerManager.createSession` attempts to call `dispose()` on failure, but the stub `dispose()` |
| 235 | + also throws (same message). This is expected until a real DAP backend exists. |
| 236 | + |
| 237 | +Use `lldb-cli` (default) for actual debugging. |
| 238 | + |
| 239 | +## External Tool Invocation |
| 240 | + |
| 241 | +### simctl and Simulator |
| 242 | + |
| 243 | +- Simulator UUID resolution uses `xcrun simctl list devices available -j` |
| 244 | + (`determineSimulatorUuid` in `src/utils/simulator-utils.ts`). |
| 245 | +- PID lookup uses `xcrun simctl spawn <simulatorId> launchctl list` |
| 246 | + (`resolveSimulatorAppPid` in `src/utils/debugger/simctl.ts`). |
| 247 | + |
| 248 | +### LLDB |
| 249 | + |
| 250 | +- Attachment uses `xcrun lldb --no-lldbinit` in the interactive backend. |
| 251 | +- Breakpoint conditions are applied by issuing an additional LLDB command: |
| 252 | + `breakpoint modify -c "<condition>" <id>`. |
| 253 | + |
| 254 | +### xcodebuild (Build/Launch Context) |
| 255 | + |
| 256 | +- Debugging assumes a running simulator app. |
| 257 | +- The typical flow is build and launch via simulator tools (for example `build_sim`), |
| 258 | + which uses `executeXcodeBuildCommand` to invoke `xcodebuild` (or `xcodemake` when enabled). |
| 259 | +- After launch, `debug_attach_sim` attaches LLDB to the simulator process. |
| 260 | + |
| 261 | +## Typical Tool Flow |
| 262 | + |
| 263 | +1. Build and launch the app on a simulator (`build_sim`, `launch_app_sim`). |
| 264 | +2. Attach LLDB (`debug_attach_sim`) using session defaults or explicit simulator + bundle ID. |
| 265 | +3. Set breakpoints (`debug_breakpoint_add`), inspect stack/variables (`debug_stack`, |
| 266 | + `debug_variables`), and issue arbitrary LLDB commands (`debug_lldb_command`). |
| 267 | +4. Detach when done (`debug_detach`). |
| 268 | + |
| 269 | +## Integration Points Summary |
| 270 | + |
| 271 | +- Tool entrypoints: `src/mcp/tools/debugging/*` |
| 272 | +- Session defaults: `src/utils/session-store.ts` |
| 273 | +- Debug session manager: `src/utils/debugger/debugger-manager.ts` |
| 274 | +- Backends: `src/utils/debugger/backends/lldb-cli-backend.ts` (default), |
| 275 | + `src/utils/debugger/backends/dap-backend.ts` (stub) |
| 276 | +- Interactive execution: `src/utils/execution/interactive-process.ts` (used by LLDB CLI backend) |
| 277 | +- External commands: `xcrun simctl`, `xcrun lldb`, `xcodebuild` |
0 commit comments