agent-dbg 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +29 -1
- package/.claude/skills/agent-dbg/SKILL.md +2 -0
- package/.claude/skills/agent-dbg/references/commands.md +2 -1
- package/TODO.md +299 -0
- package/demo/DEMO.md +71 -0
- package/demo/order-processor.js +35 -0
- package/dist/main.js +1480 -256
- package/package.json +3 -1
- package/src/commands/attach.ts +6 -5
- package/src/commands/break-fn.ts +41 -0
- package/src/commands/launch.ts +5 -6
- package/src/commands/logs.ts +58 -16
- package/src/daemon/client.ts +27 -5
- package/src/daemon/entry.ts +94 -46
- package/src/daemon/logger.ts +51 -0
- package/src/daemon/paths.ts +4 -0
- package/src/daemon/server.ts +76 -35
- package/src/daemon/session-breakpoints.ts +2 -1
- package/src/daemon/session-mutation.ts +1 -0
- package/src/daemon/session.ts +50 -10
- package/src/daemon/spawn.ts +47 -8
- package/src/dap/client.ts +252 -0
- package/src/dap/session.ts +1151 -0
- package/src/formatter/logs.ts +15 -0
- package/src/main.ts +1 -0
- package/src/protocol/messages.ts +12 -0
- package/tests/fixtures/dap/hello +0 -0
- package/tests/fixtures/dap/hello.c +8 -0
- package/tests/fixtures/dap/hello.dSYM/Contents/Info.plist +20 -0
- package/tests/fixtures/dap/hello.dSYM/Contents/Resources/DWARF/hello +0 -0
- package/tests/fixtures/dap/hello.dSYM/Contents/Resources/Relocations/aarch64/hello.yml +5 -0
- package/tests/fixtures/hotpatch-active-fn.js +7 -0
- package/tests/integration/daemon-logging.test.ts +155 -0
- package/tests/integration/mutation.test.ts +33 -0
- package/tests/unit/daemon-logger.test.ts +117 -0
- package/tests/unit/daemon.test.ts +60 -0
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
import type { DebugProtocol } from "@vscode/debugprotocol";
|
|
2
|
+
import type {
|
|
3
|
+
ConsoleMessage,
|
|
4
|
+
ExceptionEntry,
|
|
5
|
+
LaunchResult,
|
|
6
|
+
PauseInfo,
|
|
7
|
+
SessionStatus,
|
|
8
|
+
StateOptions,
|
|
9
|
+
StateSnapshot,
|
|
10
|
+
} from "../daemon/session.ts";
|
|
11
|
+
import { RefTable } from "../refs/ref-table.ts";
|
|
12
|
+
import { DapClient } from "./client.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolves the path to a DAP adapter binary for a given runtime.
|
|
16
|
+
* Returns the command array to spawn (e.g. ["lldb-dap"] or ["/opt/homebrew/opt/llvm/bin/lldb-dap"]).
|
|
17
|
+
*/
|
|
18
|
+
function resolveAdapterCommand(runtime: string): string[] {
|
|
19
|
+
switch (runtime) {
|
|
20
|
+
case "lldb":
|
|
21
|
+
case "lldb-dap": {
|
|
22
|
+
// Try homebrew LLVM path first, fall back to PATH
|
|
23
|
+
const brewPath = "/opt/homebrew/opt/llvm/bin/lldb-dap";
|
|
24
|
+
return [brewPath];
|
|
25
|
+
}
|
|
26
|
+
case "codelldb":
|
|
27
|
+
return ["codelldb", "--port", "0"];
|
|
28
|
+
default:
|
|
29
|
+
// Assume the runtime string is the adapter binary name or path
|
|
30
|
+
return [runtime];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface DapBreakpointEntry {
|
|
35
|
+
ref: string;
|
|
36
|
+
dapId?: number;
|
|
37
|
+
file: string;
|
|
38
|
+
line: number;
|
|
39
|
+
condition?: string;
|
|
40
|
+
hitCondition?: string;
|
|
41
|
+
verified: boolean;
|
|
42
|
+
actualLine?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface DapFunctionBreakpointEntry {
|
|
46
|
+
ref: string;
|
|
47
|
+
name: string;
|
|
48
|
+
condition?: string;
|
|
49
|
+
hitCondition?: string;
|
|
50
|
+
verified: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface DapStackFrame {
|
|
54
|
+
id: number;
|
|
55
|
+
name: string;
|
|
56
|
+
file?: string;
|
|
57
|
+
line: number;
|
|
58
|
+
column: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* DapSession implements the same public interface as DebugSession, but communicates
|
|
63
|
+
* with a DAP debug adapter (e.g. lldb-dap) instead of CDP/V8. This allows agent-dbg
|
|
64
|
+
* to debug native code (C/C++/Rust via LLDB), Python, Ruby, etc.
|
|
65
|
+
*/
|
|
66
|
+
export class DapSession {
|
|
67
|
+
private dap: DapClient | null = null;
|
|
68
|
+
private refs: RefTable = new RefTable();
|
|
69
|
+
private _state: "idle" | "running" | "paused" = "idle";
|
|
70
|
+
private _pauseInfo: PauseInfo | null = null;
|
|
71
|
+
private _session: string;
|
|
72
|
+
private _runtime: string;
|
|
73
|
+
private _startTime: number = Date.now();
|
|
74
|
+
private _threadId = 1; // Most adapters use thread 1; updated on "stopped" event
|
|
75
|
+
private _stackFrames: DapStackFrame[] = [];
|
|
76
|
+
private _consoleMessages: ConsoleMessage[] = [];
|
|
77
|
+
private _exceptionEntries: ExceptionEntry[] = [];
|
|
78
|
+
private capabilities: DebugProtocol.Capabilities = {};
|
|
79
|
+
|
|
80
|
+
// Breakpoints: DAP requires sending ALL breakpoints per file at once
|
|
81
|
+
private breakpoints = new Map<string, DapBreakpointEntry[]>();
|
|
82
|
+
private allBreakpoints: DapBreakpointEntry[] = [];
|
|
83
|
+
private functionBreakpoints: DapFunctionBreakpointEntry[] = [];
|
|
84
|
+
|
|
85
|
+
// Promise that resolves when the adapter stops (for step/continue/pause)
|
|
86
|
+
private stoppedWaiter: { resolve: () => void; reject: (e: Error) => void } | null = null;
|
|
87
|
+
// Deduplicates concurrent fetchStackTrace calls
|
|
88
|
+
private _stackFetchPromise: Promise<void> | null = null;
|
|
89
|
+
|
|
90
|
+
constructor(session: string, runtime: string) {
|
|
91
|
+
this._session = session;
|
|
92
|
+
this._runtime = runtime;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Lifecycle ─────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
async launch(
|
|
98
|
+
command: string[],
|
|
99
|
+
options: { brk?: boolean; port?: number; program?: string; args?: string[] } = {},
|
|
100
|
+
): Promise<LaunchResult> {
|
|
101
|
+
if (this._state !== "idle") {
|
|
102
|
+
throw new Error("Session already has an active debug target");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const adapterCmd = resolveAdapterCommand(this._runtime);
|
|
106
|
+
this.dap = DapClient.spawn(adapterCmd);
|
|
107
|
+
|
|
108
|
+
this.setupEventHandlers();
|
|
109
|
+
await this.initializeAdapter();
|
|
110
|
+
|
|
111
|
+
// Build launch arguments. The exact schema depends on the adapter.
|
|
112
|
+
// For lldb-dap: { program, args, stopOnEntry, ... }
|
|
113
|
+
const program = options.program ?? command[0];
|
|
114
|
+
const programArgs = options.args ?? command.slice(1);
|
|
115
|
+
const launchArgs: Record<string, unknown> = {
|
|
116
|
+
program,
|
|
117
|
+
args: programArgs,
|
|
118
|
+
stopOnEntry: options.brk ?? true,
|
|
119
|
+
cwd: process.cwd(),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
await this.dap.send("launch", launchArgs);
|
|
123
|
+
await this.dap.send("configurationDone");
|
|
124
|
+
|
|
125
|
+
// Wait briefly for a stopped event if stopOnEntry
|
|
126
|
+
if (options.brk !== false) {
|
|
127
|
+
await this.waitForStop(5_000);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const result: LaunchResult = {
|
|
131
|
+
pid: this.dap.pid,
|
|
132
|
+
wsUrl: `dap://${this._runtime}`,
|
|
133
|
+
paused: this.isPaused(),
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
if (this._pauseInfo) {
|
|
137
|
+
result.pauseInfo = this._pauseInfo;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async attach(target: string): Promise<{ wsUrl: string }> {
|
|
144
|
+
if (this._state !== "idle") {
|
|
145
|
+
throw new Error("Session already has an active debug target");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const adapterCmd = resolveAdapterCommand(this._runtime);
|
|
149
|
+
this.dap = DapClient.spawn(adapterCmd);
|
|
150
|
+
|
|
151
|
+
this.setupEventHandlers();
|
|
152
|
+
await this.initializeAdapter();
|
|
153
|
+
|
|
154
|
+
// Parse target: could be a PID or a process name
|
|
155
|
+
const pid = parseInt(target, 10);
|
|
156
|
+
const attachArgs: Record<string, unknown> = Number.isNaN(pid)
|
|
157
|
+
? { program: target, waitFor: true }
|
|
158
|
+
: { pid };
|
|
159
|
+
|
|
160
|
+
await this.dap.send("attach", attachArgs);
|
|
161
|
+
await this.dap.send("configurationDone");
|
|
162
|
+
|
|
163
|
+
// Wait briefly for initial stop
|
|
164
|
+
await this.waitForStop(5_000).catch(() => {
|
|
165
|
+
// Some adapters don't stop immediately on attach
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// If we're not paused after waiting, the target is running
|
|
169
|
+
if (this._state === "idle") {
|
|
170
|
+
this._state = "running";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { wsUrl: `dap://${this._runtime}/${target}` };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
getStatus(): SessionStatus {
|
|
177
|
+
return {
|
|
178
|
+
session: this._session,
|
|
179
|
+
state: this._state,
|
|
180
|
+
pid: this.dap?.pid,
|
|
181
|
+
wsUrl: this.dap ? `dap://${this._runtime}` : undefined,
|
|
182
|
+
pauseInfo: this._pauseInfo ?? undefined,
|
|
183
|
+
uptime: Math.floor((Date.now() - this._startTime) / 1000),
|
|
184
|
+
scriptCount: 0,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async stop(): Promise<void> {
|
|
189
|
+
if (this.dap) {
|
|
190
|
+
try {
|
|
191
|
+
await this.dap.send("disconnect", { terminateDebuggee: true });
|
|
192
|
+
} catch {
|
|
193
|
+
// Adapter may already be dead
|
|
194
|
+
}
|
|
195
|
+
this.dap.disconnect();
|
|
196
|
+
this.dap = null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
this._state = "idle";
|
|
200
|
+
this._pauseInfo = null;
|
|
201
|
+
this._stackFrames = [];
|
|
202
|
+
this.refs.clearAll();
|
|
203
|
+
this.breakpoints.clear();
|
|
204
|
+
this.allBreakpoints = [];
|
|
205
|
+
this.functionBreakpoints = [];
|
|
206
|
+
this._consoleMessages = [];
|
|
207
|
+
this._exceptionEntries = [];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Execution control ─────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
async continue(): Promise<void> {
|
|
213
|
+
this.requireConnected();
|
|
214
|
+
this.requirePaused();
|
|
215
|
+
|
|
216
|
+
const waiter = this.createStoppedWaiter(30_000);
|
|
217
|
+
await this.getDap().send("continue", { threadId: this._threadId });
|
|
218
|
+
this._state = "running";
|
|
219
|
+
this._pauseInfo = null;
|
|
220
|
+
this._stackFrames = [];
|
|
221
|
+
this.refs.clearVolatile();
|
|
222
|
+
await waiter;
|
|
223
|
+
if (this.isPaused()) await this.fetchStackTrace();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async step(mode: "over" | "into" | "out"): Promise<void> {
|
|
227
|
+
this.requireConnected();
|
|
228
|
+
this.requirePaused();
|
|
229
|
+
|
|
230
|
+
const waiter = this.createStoppedWaiter(30_000);
|
|
231
|
+
|
|
232
|
+
const command = mode === "into" ? "stepIn" : mode === "out" ? "stepOut" : "next";
|
|
233
|
+
await this.getDap().send(command, { threadId: this._threadId });
|
|
234
|
+
this._state = "running";
|
|
235
|
+
this._pauseInfo = null;
|
|
236
|
+
this.refs.clearVolatile();
|
|
237
|
+
await waiter;
|
|
238
|
+
if (this.isPaused()) await this.fetchStackTrace();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async pause(): Promise<void> {
|
|
242
|
+
this.requireConnected();
|
|
243
|
+
if (this._state !== "running") {
|
|
244
|
+
throw new Error("Cannot pause: target is not running");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const waiter = this.createStoppedWaiter(5_000);
|
|
248
|
+
await this.getDap().send("pause", { threadId: this._threadId });
|
|
249
|
+
await waiter;
|
|
250
|
+
if (this.isPaused()) await this.fetchStackTrace();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Breakpoints ───────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
async setBreakpoint(
|
|
256
|
+
file: string,
|
|
257
|
+
line: number,
|
|
258
|
+
options?: { condition?: string; hitCount?: number; urlRegex?: string; column?: number },
|
|
259
|
+
): Promise<{ ref: string; location: { url: string; line: number; column?: number } }> {
|
|
260
|
+
this.requireConnected();
|
|
261
|
+
|
|
262
|
+
const entry: DapBreakpointEntry = {
|
|
263
|
+
ref: "", // will be set by RefTable
|
|
264
|
+
file,
|
|
265
|
+
line,
|
|
266
|
+
condition: options?.condition,
|
|
267
|
+
hitCondition: options?.hitCount ? String(options.hitCount) : undefined,
|
|
268
|
+
verified: false,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// Add to per-file tracking
|
|
272
|
+
let fileBreakpoints = this.breakpoints.get(file);
|
|
273
|
+
if (!fileBreakpoints) {
|
|
274
|
+
fileBreakpoints = [];
|
|
275
|
+
this.breakpoints.set(file, fileBreakpoints);
|
|
276
|
+
}
|
|
277
|
+
fileBreakpoints.push(entry);
|
|
278
|
+
this.allBreakpoints.push(entry);
|
|
279
|
+
|
|
280
|
+
// Register ref
|
|
281
|
+
const ref = this.refs.addBreakpoint(`dap-bp:${file}:${line}`, {
|
|
282
|
+
file,
|
|
283
|
+
line,
|
|
284
|
+
});
|
|
285
|
+
entry.ref = ref;
|
|
286
|
+
|
|
287
|
+
// Send full set of breakpoints for this file to adapter
|
|
288
|
+
await this.syncFileBreakpoints(file);
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
ref,
|
|
292
|
+
location: { url: file, line: entry.actualLine ?? line },
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async removeBreakpoint(ref: string): Promise<void> {
|
|
297
|
+
this.requireConnected();
|
|
298
|
+
|
|
299
|
+
const entry = this.allBreakpoints.find((bp) => bp.ref === ref);
|
|
300
|
+
if (!entry) {
|
|
301
|
+
throw new Error(`Unknown breakpoint ref: ${ref}`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Remove from per-file tracking
|
|
305
|
+
const fileBreakpoints = this.breakpoints.get(entry.file);
|
|
306
|
+
if (fileBreakpoints) {
|
|
307
|
+
const idx = fileBreakpoints.indexOf(entry);
|
|
308
|
+
if (idx !== -1) fileBreakpoints.splice(idx, 1);
|
|
309
|
+
if (fileBreakpoints.length === 0) {
|
|
310
|
+
this.breakpoints.delete(entry.file);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Remove from all-breakpoints list
|
|
315
|
+
const allIdx = this.allBreakpoints.indexOf(entry);
|
|
316
|
+
if (allIdx !== -1) this.allBreakpoints.splice(allIdx, 1);
|
|
317
|
+
|
|
318
|
+
// Remove from ref table
|
|
319
|
+
this.refs.remove(ref);
|
|
320
|
+
|
|
321
|
+
// Re-sync file breakpoints (or clear them if none left)
|
|
322
|
+
await this.syncFileBreakpoints(entry.file);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async removeAllBreakpoints(): Promise<void> {
|
|
326
|
+
this.requireConnected();
|
|
327
|
+
|
|
328
|
+
// Clear all files
|
|
329
|
+
const files = [...this.breakpoints.keys()];
|
|
330
|
+
this.breakpoints.clear();
|
|
331
|
+
this.allBreakpoints = [];
|
|
332
|
+
this.functionBreakpoints = [];
|
|
333
|
+
|
|
334
|
+
// Remove all BP refs
|
|
335
|
+
for (const entry of this.refs.list("BP")) {
|
|
336
|
+
this.refs.remove(entry.ref);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Send empty breakpoints for each file
|
|
340
|
+
for (const file of files) {
|
|
341
|
+
await this.getDap().send("setBreakpoints", {
|
|
342
|
+
source: { path: file },
|
|
343
|
+
breakpoints: [],
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Clear function breakpoints
|
|
348
|
+
await this.getDap().send("setFunctionBreakpoints", { breakpoints: [] });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
listBreakpoints(): Array<{
|
|
352
|
+
ref: string;
|
|
353
|
+
type: "BP" | "LP";
|
|
354
|
+
url: string;
|
|
355
|
+
line: number;
|
|
356
|
+
condition?: string;
|
|
357
|
+
}> {
|
|
358
|
+
const fileBps = this.allBreakpoints.map((bp) => ({
|
|
359
|
+
ref: bp.ref,
|
|
360
|
+
type: "BP" as const,
|
|
361
|
+
url: bp.file,
|
|
362
|
+
line: bp.actualLine ?? bp.line,
|
|
363
|
+
condition: bp.condition,
|
|
364
|
+
}));
|
|
365
|
+
const fnBps = this.functionBreakpoints.map((bp) => ({
|
|
366
|
+
ref: bp.ref,
|
|
367
|
+
type: "BP" as const,
|
|
368
|
+
url: bp.name,
|
|
369
|
+
line: 0,
|
|
370
|
+
condition: bp.condition,
|
|
371
|
+
}));
|
|
372
|
+
return [...fileBps, ...fnBps];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Set a breakpoint on a function by name (e.g. "__assert_rtn", "yoga::Style::operator==").
|
|
377
|
+
* DAP's setFunctionBreakpoints replaces the full set, so we track and re-send all.
|
|
378
|
+
*/
|
|
379
|
+
async setFunctionBreakpoint(
|
|
380
|
+
name: string,
|
|
381
|
+
options?: { condition?: string; hitCount?: number },
|
|
382
|
+
): Promise<{ ref: string }> {
|
|
383
|
+
this.requireConnected();
|
|
384
|
+
|
|
385
|
+
const entry: DapFunctionBreakpointEntry = {
|
|
386
|
+
ref: "",
|
|
387
|
+
name,
|
|
388
|
+
condition: options?.condition,
|
|
389
|
+
hitCondition: options?.hitCount ? String(options.hitCount) : undefined,
|
|
390
|
+
verified: false,
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
this.functionBreakpoints.push(entry);
|
|
394
|
+
|
|
395
|
+
const ref = this.refs.addBreakpoint(`dap-fn:${name}`, {
|
|
396
|
+
file: name,
|
|
397
|
+
line: 0,
|
|
398
|
+
});
|
|
399
|
+
entry.ref = ref;
|
|
400
|
+
|
|
401
|
+
await this.syncFunctionBreakpoints();
|
|
402
|
+
return { ref };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async removeFunctionBreakpoint(ref: string): Promise<void> {
|
|
406
|
+
this.requireConnected();
|
|
407
|
+
|
|
408
|
+
const idx = this.functionBreakpoints.findIndex((bp) => bp.ref === ref);
|
|
409
|
+
if (idx === -1) {
|
|
410
|
+
throw new Error(`Unknown function breakpoint ref: ${ref}`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
this.functionBreakpoints.splice(idx, 1);
|
|
414
|
+
this.refs.remove(ref);
|
|
415
|
+
await this.syncFunctionBreakpoints();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private async syncFunctionBreakpoints(): Promise<void> {
|
|
419
|
+
const dapBps = this.functionBreakpoints.map((bp) => ({
|
|
420
|
+
name: bp.name,
|
|
421
|
+
condition: bp.condition,
|
|
422
|
+
hitCondition: bp.hitCondition,
|
|
423
|
+
}));
|
|
424
|
+
|
|
425
|
+
const response = await this.getDap().send("setFunctionBreakpoints", {
|
|
426
|
+
breakpoints: dapBps,
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const body = response.body as
|
|
430
|
+
| { breakpoints?: Array<{ id?: number; verified?: boolean }> }
|
|
431
|
+
| undefined;
|
|
432
|
+
const resultBps = body?.breakpoints ?? [];
|
|
433
|
+
for (let i = 0; i < this.functionBreakpoints.length; i++) {
|
|
434
|
+
const entry = this.functionBreakpoints[i];
|
|
435
|
+
const result = resultBps[i];
|
|
436
|
+
if (entry && result) {
|
|
437
|
+
entry.verified = result.verified ?? false;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ── Inspection ────────────────────────────────────────────────────
|
|
443
|
+
|
|
444
|
+
async eval(
|
|
445
|
+
expression: string,
|
|
446
|
+
options: { frame?: string } = {},
|
|
447
|
+
): Promise<{ ref: string; type: string; value: string; objectId?: string }> {
|
|
448
|
+
this.requireConnected();
|
|
449
|
+
this.requirePaused();
|
|
450
|
+
await this.ensureStack();
|
|
451
|
+
|
|
452
|
+
const frameId = this.resolveFrameId(options.frame);
|
|
453
|
+
|
|
454
|
+
const response = await this.getDap().send("evaluate", {
|
|
455
|
+
expression,
|
|
456
|
+
frameId,
|
|
457
|
+
context: "repl",
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const body = response.body as {
|
|
461
|
+
result: string;
|
|
462
|
+
type?: string;
|
|
463
|
+
variablesReference: number;
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const remoteId =
|
|
467
|
+
body.variablesReference > 0 ? String(body.variablesReference) : `eval:${Date.now()}`;
|
|
468
|
+
const ref = this.refs.addVar(remoteId, expression);
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
ref,
|
|
472
|
+
type: body.type ?? "unknown",
|
|
473
|
+
value: body.result,
|
|
474
|
+
objectId: body.variablesReference > 0 ? String(body.variablesReference) : undefined,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async getVars(
|
|
479
|
+
options: { frame?: string; names?: string[]; allScopes?: boolean } = {},
|
|
480
|
+
): Promise<Array<{ ref: string; name: string; type: string; value: string }>> {
|
|
481
|
+
this.requireConnected();
|
|
482
|
+
this.requirePaused();
|
|
483
|
+
await this.ensureStack();
|
|
484
|
+
|
|
485
|
+
const frameId = this.resolveFrameId(options.frame);
|
|
486
|
+
|
|
487
|
+
// Get scopes for the frame
|
|
488
|
+
const scopesResponse = await this.getDap().send("scopes", { frameId });
|
|
489
|
+
const scopes = (
|
|
490
|
+
scopesResponse.body as {
|
|
491
|
+
scopes: Array<{ name: string; variablesReference: number; expensive: boolean }>;
|
|
492
|
+
}
|
|
493
|
+
).scopes;
|
|
494
|
+
|
|
495
|
+
const result: Array<{ ref: string; name: string; type: string; value: string }> = [];
|
|
496
|
+
|
|
497
|
+
// Fetch variables from each non-expensive scope (or all if allScopes)
|
|
498
|
+
const scopesToFetch = options.allScopes
|
|
499
|
+
? scopes
|
|
500
|
+
: scopes.filter((s) => !s.expensive).slice(0, 2); // locals + args typically
|
|
501
|
+
|
|
502
|
+
for (const scope of scopesToFetch) {
|
|
503
|
+
const varsResponse = await this.getDap().send("variables", {
|
|
504
|
+
variablesReference: scope.variablesReference,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
const variables = (
|
|
508
|
+
varsResponse.body as {
|
|
509
|
+
variables: Array<{
|
|
510
|
+
name: string;
|
|
511
|
+
value: string;
|
|
512
|
+
type?: string;
|
|
513
|
+
variablesReference: number;
|
|
514
|
+
}>;
|
|
515
|
+
}
|
|
516
|
+
).variables;
|
|
517
|
+
|
|
518
|
+
for (const v of variables) {
|
|
519
|
+
if (options.names && !options.names.includes(v.name)) continue;
|
|
520
|
+
|
|
521
|
+
const remoteId =
|
|
522
|
+
v.variablesReference > 0 ? String(v.variablesReference) : `var:${v.name}:${Date.now()}`;
|
|
523
|
+
const ref = this.refs.addVar(remoteId, v.name);
|
|
524
|
+
result.push({
|
|
525
|
+
ref,
|
|
526
|
+
name: v.name,
|
|
527
|
+
type: v.type ?? "unknown",
|
|
528
|
+
value: v.value,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return result;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async getProps(
|
|
537
|
+
ref: string,
|
|
538
|
+
_options: { own?: boolean; internal?: boolean; depth?: number } = {},
|
|
539
|
+
): Promise<
|
|
540
|
+
Array<{
|
|
541
|
+
ref?: string;
|
|
542
|
+
name: string;
|
|
543
|
+
type: string;
|
|
544
|
+
value: string;
|
|
545
|
+
isOwn?: boolean;
|
|
546
|
+
}>
|
|
547
|
+
> {
|
|
548
|
+
this.requireConnected();
|
|
549
|
+
|
|
550
|
+
const remoteId = this.refs.resolveId(ref);
|
|
551
|
+
if (!remoteId) {
|
|
552
|
+
throw new Error(`Unknown ref: ${ref}`);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const variablesReference = parseInt(remoteId, 10);
|
|
556
|
+
if (Number.isNaN(variablesReference) || variablesReference <= 0) {
|
|
557
|
+
return [];
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const response = await this.getDap().send("variables", { variablesReference });
|
|
561
|
+
const variables = (
|
|
562
|
+
response.body as {
|
|
563
|
+
variables: Array<{
|
|
564
|
+
name: string;
|
|
565
|
+
value: string;
|
|
566
|
+
type?: string;
|
|
567
|
+
variablesReference: number;
|
|
568
|
+
}>;
|
|
569
|
+
}
|
|
570
|
+
).variables;
|
|
571
|
+
|
|
572
|
+
return variables.map((v) => {
|
|
573
|
+
const childRemoteId =
|
|
574
|
+
v.variablesReference > 0 ? String(v.variablesReference) : `prop:${v.name}:${Date.now()}`;
|
|
575
|
+
const childRef =
|
|
576
|
+
v.variablesReference > 0 ? this.refs.addVar(childRemoteId, v.name) : undefined;
|
|
577
|
+
return {
|
|
578
|
+
ref: childRef,
|
|
579
|
+
name: v.name,
|
|
580
|
+
type: v.type ?? "unknown",
|
|
581
|
+
value: v.value,
|
|
582
|
+
isOwn: true,
|
|
583
|
+
};
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
getStack(_options: { asyncDepth?: number; generated?: boolean } = {}): Array<{
|
|
588
|
+
ref: string;
|
|
589
|
+
functionName: string;
|
|
590
|
+
file: string;
|
|
591
|
+
line: number;
|
|
592
|
+
column?: number;
|
|
593
|
+
}> {
|
|
594
|
+
// Return cached stack frames from last stopped event
|
|
595
|
+
return this._stackFrames.map((frame) => {
|
|
596
|
+
const ref = this.refs.addFrame(String(frame.id), frame.name);
|
|
597
|
+
return {
|
|
598
|
+
ref,
|
|
599
|
+
functionName: frame.name,
|
|
600
|
+
file: frame.file ?? "<unknown>",
|
|
601
|
+
line: frame.line,
|
|
602
|
+
column: frame.column > 0 ? frame.column : undefined,
|
|
603
|
+
};
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async getSource(options: { file?: string; lines?: number; all?: boolean } = {}): Promise<{
|
|
608
|
+
url: string;
|
|
609
|
+
lines: Array<{ line: number; text: string; current?: boolean }>;
|
|
610
|
+
}> {
|
|
611
|
+
// For native debuggers, read source from the filesystem
|
|
612
|
+
const file = options.file ?? this._pauseInfo?.url;
|
|
613
|
+
if (!file) {
|
|
614
|
+
throw new Error("No source file available. Specify a file path.");
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
let content: string;
|
|
618
|
+
try {
|
|
619
|
+
content = await Bun.file(file).text();
|
|
620
|
+
} catch {
|
|
621
|
+
throw new Error(`Cannot read source file: ${file}`);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const allLines = content.split("\n");
|
|
625
|
+
const currentLine = this._pauseInfo?.line;
|
|
626
|
+
const windowSize = options.lines ?? 10;
|
|
627
|
+
|
|
628
|
+
let startLine: number;
|
|
629
|
+
let endLine: number;
|
|
630
|
+
|
|
631
|
+
if (options.all) {
|
|
632
|
+
startLine = 1;
|
|
633
|
+
endLine = allLines.length;
|
|
634
|
+
} else if (currentLine !== undefined) {
|
|
635
|
+
startLine = Math.max(1, currentLine - windowSize);
|
|
636
|
+
endLine = Math.min(allLines.length, currentLine + windowSize);
|
|
637
|
+
} else {
|
|
638
|
+
startLine = 1;
|
|
639
|
+
endLine = Math.min(allLines.length, windowSize * 2);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const lines: Array<{ line: number; text: string; current?: boolean }> = [];
|
|
643
|
+
for (let i = startLine; i <= endLine; i++) {
|
|
644
|
+
const lineObj: { line: number; text: string; current?: boolean } = {
|
|
645
|
+
line: i,
|
|
646
|
+
text: allLines[i - 1] ?? "",
|
|
647
|
+
};
|
|
648
|
+
if (currentLine !== undefined && i === currentLine) {
|
|
649
|
+
lineObj.current = true;
|
|
650
|
+
}
|
|
651
|
+
lines.push(lineObj);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return { url: file, lines };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async buildState(options: StateOptions = {}): Promise<StateSnapshot> {
|
|
658
|
+
if (this.isPaused()) await this.ensureStack();
|
|
659
|
+
|
|
660
|
+
const snapshot: StateSnapshot = {
|
|
661
|
+
status: this._state,
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
if (this._state === "paused" && this._pauseInfo) {
|
|
665
|
+
snapshot.reason = this._pauseInfo.reason;
|
|
666
|
+
if (this._pauseInfo.url && this._pauseInfo.line !== undefined) {
|
|
667
|
+
snapshot.location = {
|
|
668
|
+
url: this._pauseInfo.url,
|
|
669
|
+
line: this._pauseInfo.line,
|
|
670
|
+
column: this._pauseInfo.column,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Include source code if paused and code not explicitly disabled
|
|
676
|
+
if (this._state === "paused" && options.code !== false) {
|
|
677
|
+
try {
|
|
678
|
+
const source = await this.getSource({ lines: options.lines });
|
|
679
|
+
snapshot.source = source;
|
|
680
|
+
} catch {
|
|
681
|
+
// Source may not be available
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Include variables if requested or if not compact
|
|
686
|
+
if (this._state === "paused" && (options.vars !== false || !options.compact)) {
|
|
687
|
+
try {
|
|
688
|
+
const vars = await this.getVars({ frame: options.frame, allScopes: options.allScopes });
|
|
689
|
+
snapshot.vars = vars.map((v) => ({
|
|
690
|
+
ref: v.ref,
|
|
691
|
+
name: v.name,
|
|
692
|
+
value: v.value,
|
|
693
|
+
scope: "local",
|
|
694
|
+
}));
|
|
695
|
+
} catch {
|
|
696
|
+
// Variables may not be available
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Include stack if requested
|
|
701
|
+
if (this._state === "paused" && options.stack !== false) {
|
|
702
|
+
try {
|
|
703
|
+
snapshot.stack = this.getStack();
|
|
704
|
+
} catch {
|
|
705
|
+
// Stack may not be available
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (options.breakpoints !== false) {
|
|
710
|
+
snapshot.breakpointCount = this.allBreakpoints.length;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return snapshot;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Console & exceptions
|
|
717
|
+
getConsoleMessages(
|
|
718
|
+
options: { level?: string; since?: number; clear?: boolean } = {},
|
|
719
|
+
): ConsoleMessage[] {
|
|
720
|
+
let msgs = this._consoleMessages;
|
|
721
|
+
if (options.level) {
|
|
722
|
+
msgs = msgs.filter((m) => m.level === options.level);
|
|
723
|
+
}
|
|
724
|
+
if (options.since !== undefined) {
|
|
725
|
+
const since = options.since;
|
|
726
|
+
msgs = msgs.filter((m) => m.timestamp >= since);
|
|
727
|
+
}
|
|
728
|
+
if (options.clear) {
|
|
729
|
+
this._consoleMessages = [];
|
|
730
|
+
}
|
|
731
|
+
return msgs;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
getExceptions(options: { since?: number } = {}): ExceptionEntry[] {
|
|
735
|
+
let entries = this._exceptionEntries;
|
|
736
|
+
if (options.since !== undefined) {
|
|
737
|
+
const since = options.since;
|
|
738
|
+
entries = entries.filter((e) => e.timestamp >= since);
|
|
739
|
+
}
|
|
740
|
+
return entries;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ── Unsupported methods (throw descriptive errors) ────────────────
|
|
744
|
+
|
|
745
|
+
async setLogpoint(
|
|
746
|
+
_file: string,
|
|
747
|
+
_line: number,
|
|
748
|
+
_template: string,
|
|
749
|
+
_options?: { condition?: string; maxEmissions?: number },
|
|
750
|
+
): Promise<never> {
|
|
751
|
+
throw new Error(
|
|
752
|
+
"Logpoints are not supported in DAP mode. Use breakpoints with conditions instead.",
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async setExceptionPause(mode: "all" | "uncaught" | "caught" | "none"): Promise<void> {
|
|
757
|
+
this.requireConnected();
|
|
758
|
+
// DAP supports exception breakpoints through setExceptionBreakpoints.
|
|
759
|
+
// Use the adapter's declared exception breakpoint filters if available.
|
|
760
|
+
const available = this.capabilities.exceptionBreakpointFilters ?? [];
|
|
761
|
+
const filterIds = available.map((f) => f.filter);
|
|
762
|
+
let filters: string[];
|
|
763
|
+
if (mode === "none") {
|
|
764
|
+
filters = [];
|
|
765
|
+
} else if (mode === "all") {
|
|
766
|
+
filters = filterIds; // enable all supported filters
|
|
767
|
+
} else {
|
|
768
|
+
// Best-effort: look for filters containing the mode keyword
|
|
769
|
+
filters = filterIds.filter((id) => id.includes(mode));
|
|
770
|
+
if (filters.length === 0) filters = filterIds; // fallback to all
|
|
771
|
+
}
|
|
772
|
+
await this.getDap().send("setExceptionBreakpoints", { filters });
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
async toggleBreakpoint(_ref: string): Promise<never> {
|
|
776
|
+
throw new Error(
|
|
777
|
+
"Breakpoint toggling is not yet supported in DAP mode. Use break-rm and break.",
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async getBreakableLocations(_file: string, _startLine: number, _endLine: number): Promise<never> {
|
|
782
|
+
throw new Error("Breakable locations are not supported in DAP mode.");
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
async hotpatch(_file: string, _source: string, _options?: { dryRun?: boolean }): Promise<never> {
|
|
786
|
+
throw new Error("Hot-patching is not supported in DAP mode.");
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async searchInScripts(_query: string, _options?: Record<string, unknown>): Promise<never> {
|
|
790
|
+
throw new Error(
|
|
791
|
+
"Script search is not supported in DAP mode. Use your shell to search source files.",
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async setVariable(
|
|
796
|
+
varName: string,
|
|
797
|
+
value: string,
|
|
798
|
+
options: { frame?: string } = {},
|
|
799
|
+
): Promise<{ name: string; newValue: string; type: string }> {
|
|
800
|
+
this.requireConnected();
|
|
801
|
+
this.requirePaused();
|
|
802
|
+
await this.ensureStack();
|
|
803
|
+
|
|
804
|
+
const frameId = this.resolveFrameId(options.frame);
|
|
805
|
+
// Get the scopes to find the variable
|
|
806
|
+
const scopesResponse = await this.getDap().send("scopes", { frameId });
|
|
807
|
+
const scopes = (scopesResponse.body as { scopes: Array<{ variablesReference: number }> })
|
|
808
|
+
.scopes;
|
|
809
|
+
|
|
810
|
+
// Try setting in each scope
|
|
811
|
+
for (const scope of scopes) {
|
|
812
|
+
try {
|
|
813
|
+
const response = await this.getDap().send("setVariable", {
|
|
814
|
+
variablesReference: scope.variablesReference,
|
|
815
|
+
name: varName,
|
|
816
|
+
value,
|
|
817
|
+
});
|
|
818
|
+
const body = response.body as { value: string; type?: string };
|
|
819
|
+
return { name: varName, newValue: body.value, type: body.type ?? "unknown" };
|
|
820
|
+
} catch {
|
|
821
|
+
// Variable not in this scope, try next
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
throw new Error(`Variable "${varName}" not found in any scope`);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async setReturnValue(_value: string): Promise<never> {
|
|
829
|
+
throw new Error("Setting return values is not supported in DAP mode.");
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
async restartFrame(_frameRef?: string): Promise<never> {
|
|
833
|
+
throw new Error("Frame restart is not supported in DAP mode.");
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
async runTo(_file: string, _line: number): Promise<never> {
|
|
837
|
+
throw new Error(
|
|
838
|
+
"Run-to-location is not yet supported in DAP mode. Set a breakpoint and continue.",
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
getScripts(_filter?: string): Array<{ scriptId: string; url: string }> {
|
|
843
|
+
// DAP doesn't have a script list concept like CDP
|
|
844
|
+
return [];
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
async addBlackbox(_patterns: string[]): Promise<never> {
|
|
848
|
+
throw new Error("Blackboxing is not supported in DAP mode.");
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
listBlackbox(): string[] {
|
|
852
|
+
return [];
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
async removeBlackbox(_patterns: string[]): Promise<never> {
|
|
856
|
+
throw new Error("Blackboxing is not supported in DAP mode.");
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async restart(): Promise<never> {
|
|
860
|
+
throw new Error("Restart is not yet supported in DAP mode. Use stop + launch.");
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Expose a no-op sourceMapResolver-like object so entry.ts doesn't crash
|
|
864
|
+
get sourceMapResolver(): {
|
|
865
|
+
findScriptForSource: (_: string) => null;
|
|
866
|
+
getInfo: (_: string) => null;
|
|
867
|
+
getAllInfos: () => [];
|
|
868
|
+
setDisabled: (_: boolean) => void;
|
|
869
|
+
} {
|
|
870
|
+
return {
|
|
871
|
+
findScriptForSource: () => null,
|
|
872
|
+
getInfo: () => null,
|
|
873
|
+
getAllInfos: () => [],
|
|
874
|
+
setDisabled: () => {},
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// ── Private helpers ───────────────────────────────────────────────
|
|
879
|
+
|
|
880
|
+
private isPaused(): boolean {
|
|
881
|
+
return this._state === "paused";
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** Ensure stack frames are loaded if we're paused. */
|
|
885
|
+
private async ensureStack(): Promise<void> {
|
|
886
|
+
if (this.isPaused() && this._stackFrames.length === 0) {
|
|
887
|
+
await this.fetchStackTrace();
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** Returns the DAP client, throwing if not connected. Call after requireConnected(). */
|
|
892
|
+
private getDap(): DapClient {
|
|
893
|
+
if (!this.dap || !this.dap.connected) {
|
|
894
|
+
throw new Error("Not connected to a debug adapter. Use launch or attach first.");
|
|
895
|
+
}
|
|
896
|
+
return this.dap;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
private requireConnected(): void {
|
|
900
|
+
this.getDap();
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
private requirePaused(): void {
|
|
904
|
+
if (!this.isPaused()) {
|
|
905
|
+
throw new Error("Target is not paused. Use pause or wait for a breakpoint.");
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
private async initializeAdapter(): Promise<void> {
|
|
910
|
+
const response = await this.getDap().send("initialize", {
|
|
911
|
+
adapterID: this._runtime,
|
|
912
|
+
clientID: "agent-dbg",
|
|
913
|
+
clientName: "agent-dbg",
|
|
914
|
+
linesStartAt1: true,
|
|
915
|
+
columnsStartAt1: true,
|
|
916
|
+
pathFormat: "path",
|
|
917
|
+
supportsVariableType: true,
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
this.capabilities = (response.body ?? {}) as DebugProtocol.Capabilities;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
private setupEventHandlers(): void {
|
|
924
|
+
const dap = this.getDap();
|
|
925
|
+
|
|
926
|
+
dap.on("stopped", (body: unknown) => {
|
|
927
|
+
const event = body as {
|
|
928
|
+
reason: string;
|
|
929
|
+
threadId?: number;
|
|
930
|
+
description?: string;
|
|
931
|
+
text?: string;
|
|
932
|
+
allThreadsStopped?: boolean;
|
|
933
|
+
};
|
|
934
|
+
|
|
935
|
+
this._state = "paused";
|
|
936
|
+
if (event.threadId !== undefined) {
|
|
937
|
+
this._threadId = event.threadId;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
this._pauseInfo = {
|
|
941
|
+
reason: event.reason,
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
if (this.stoppedWaiter) {
|
|
945
|
+
// Waiter exists: caller (continue/step/pause) will fetch stack after resolve
|
|
946
|
+
this.stoppedWaiter.resolve();
|
|
947
|
+
this.stoppedWaiter = null;
|
|
948
|
+
} else {
|
|
949
|
+
// No waiter: external polling will see paused state, eagerly fetch stack
|
|
950
|
+
this.fetchStackTrace().catch(() => {});
|
|
951
|
+
}
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
dap.on("continued", (_body: unknown) => {
|
|
955
|
+
this._state = "running";
|
|
956
|
+
this._pauseInfo = null;
|
|
957
|
+
this._stackFrames = [];
|
|
958
|
+
this.refs.clearVolatile();
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
dap.on("terminated", (_body: unknown) => {
|
|
962
|
+
this._state = "idle";
|
|
963
|
+
this._pauseInfo = null;
|
|
964
|
+
this._stackFrames = [];
|
|
965
|
+
|
|
966
|
+
// Resolve any waiting promise
|
|
967
|
+
this.stoppedWaiter?.resolve();
|
|
968
|
+
this.stoppedWaiter = null;
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
dap.on("exited", (_body: unknown) => {
|
|
972
|
+
this._state = "idle";
|
|
973
|
+
this._pauseInfo = null;
|
|
974
|
+
|
|
975
|
+
this.stoppedWaiter?.resolve();
|
|
976
|
+
this.stoppedWaiter = null;
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
dap.on("output", (body: unknown) => {
|
|
980
|
+
const event = body as {
|
|
981
|
+
category?: string;
|
|
982
|
+
output: string;
|
|
983
|
+
source?: { path?: string };
|
|
984
|
+
line?: number;
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
const category = event.category ?? "console";
|
|
988
|
+
if (category === "stdout" || category === "console") {
|
|
989
|
+
this._consoleMessages.push({
|
|
990
|
+
timestamp: Date.now(),
|
|
991
|
+
level: "log",
|
|
992
|
+
text: event.output.trimEnd(),
|
|
993
|
+
url: event.source?.path,
|
|
994
|
+
line: event.line,
|
|
995
|
+
});
|
|
996
|
+
} else if (category === "stderr") {
|
|
997
|
+
this._consoleMessages.push({
|
|
998
|
+
timestamp: Date.now(),
|
|
999
|
+
level: "error",
|
|
1000
|
+
text: event.output.trimEnd(),
|
|
1001
|
+
url: event.source?.path,
|
|
1002
|
+
line: event.line,
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
if (this._consoleMessages.length > 1000) {
|
|
1007
|
+
this._consoleMessages.shift();
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
private async fetchStackTrace(): Promise<void> {
|
|
1013
|
+
// Deduplicate: if a fetch is already in progress, just await it
|
|
1014
|
+
if (this._stackFetchPromise) {
|
|
1015
|
+
await this._stackFetchPromise;
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
this._stackFetchPromise = this._fetchStackTraceImpl();
|
|
1019
|
+
try {
|
|
1020
|
+
await this._stackFetchPromise;
|
|
1021
|
+
} finally {
|
|
1022
|
+
this._stackFetchPromise = null;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
private async _fetchStackTraceImpl(): Promise<void> {
|
|
1027
|
+
if (!this.dap || this._state !== "paused") return;
|
|
1028
|
+
|
|
1029
|
+
try {
|
|
1030
|
+
const response = await this.dap.send("stackTrace", {
|
|
1031
|
+
threadId: this._threadId,
|
|
1032
|
+
startFrame: 0,
|
|
1033
|
+
levels: 50,
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
const body = response.body as {
|
|
1037
|
+
stackFrames: Array<{
|
|
1038
|
+
id: number;
|
|
1039
|
+
name: string;
|
|
1040
|
+
source?: { path?: string; name?: string };
|
|
1041
|
+
line: number;
|
|
1042
|
+
column: number;
|
|
1043
|
+
}>;
|
|
1044
|
+
};
|
|
1045
|
+
|
|
1046
|
+
this._stackFrames = body.stackFrames.map((f) => ({
|
|
1047
|
+
id: f.id,
|
|
1048
|
+
name: f.name,
|
|
1049
|
+
file: f.source?.path ?? f.source?.name,
|
|
1050
|
+
line: f.line,
|
|
1051
|
+
column: f.column,
|
|
1052
|
+
}));
|
|
1053
|
+
|
|
1054
|
+
// Update pauseInfo with top-of-stack location
|
|
1055
|
+
const topFrame = this._stackFrames[0];
|
|
1056
|
+
if (topFrame && this._pauseInfo) {
|
|
1057
|
+
this._pauseInfo.url = topFrame.file;
|
|
1058
|
+
this._pauseInfo.line = topFrame.line;
|
|
1059
|
+
this._pauseInfo.column = topFrame.column > 0 ? topFrame.column : undefined;
|
|
1060
|
+
this._pauseInfo.callFrameCount = this._stackFrames.length;
|
|
1061
|
+
}
|
|
1062
|
+
} catch {
|
|
1063
|
+
// Stack trace may not be available
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
private resolveFrameId(frameRef?: string): number {
|
|
1068
|
+
if (!frameRef) {
|
|
1069
|
+
// Default to top frame
|
|
1070
|
+
const topFrame = this._stackFrames[0];
|
|
1071
|
+
if (!topFrame) {
|
|
1072
|
+
throw new Error("No stack frames available");
|
|
1073
|
+
}
|
|
1074
|
+
return topFrame.id;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const remoteId = this.refs.resolveId(frameRef);
|
|
1078
|
+
if (!remoteId) {
|
|
1079
|
+
throw new Error(`Unknown frame ref: ${frameRef}`);
|
|
1080
|
+
}
|
|
1081
|
+
return parseInt(remoteId, 10);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
private async syncFileBreakpoints(file: string): Promise<void> {
|
|
1085
|
+
const entries = this.breakpoints.get(file) ?? [];
|
|
1086
|
+
|
|
1087
|
+
const dapBreakpoints = entries.map((bp) => {
|
|
1088
|
+
const sbp: Record<string, unknown> = { line: bp.line };
|
|
1089
|
+
if (bp.condition) sbp.condition = bp.condition;
|
|
1090
|
+
if (bp.hitCondition) sbp.hitCondition = bp.hitCondition;
|
|
1091
|
+
return sbp;
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
const response = await this.getDap().send("setBreakpoints", {
|
|
1095
|
+
source: { path: file },
|
|
1096
|
+
breakpoints: dapBreakpoints,
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
// Update entries with actual verified locations
|
|
1100
|
+
const body = response.body as {
|
|
1101
|
+
breakpoints: Array<{
|
|
1102
|
+
id?: number;
|
|
1103
|
+
verified: boolean;
|
|
1104
|
+
line?: number;
|
|
1105
|
+
}>;
|
|
1106
|
+
};
|
|
1107
|
+
|
|
1108
|
+
for (let i = 0; i < entries.length && i < body.breakpoints.length; i++) {
|
|
1109
|
+
const bp = body.breakpoints[i];
|
|
1110
|
+
const entry = entries[i];
|
|
1111
|
+
if (bp && entry) {
|
|
1112
|
+
entry.dapId = bp.id;
|
|
1113
|
+
entry.verified = bp.verified;
|
|
1114
|
+
entry.actualLine = bp.line ?? entry.line;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
private createStoppedWaiter(timeoutMs: number): Promise<void> {
|
|
1120
|
+
return new Promise<void>((resolve, reject) => {
|
|
1121
|
+
const timer = setTimeout(() => {
|
|
1122
|
+
this.stoppedWaiter = null;
|
|
1123
|
+
// Don't reject — the process is still running, just resolve
|
|
1124
|
+
resolve();
|
|
1125
|
+
}, timeoutMs);
|
|
1126
|
+
|
|
1127
|
+
this.stoppedWaiter = {
|
|
1128
|
+
resolve: () => {
|
|
1129
|
+
clearTimeout(timer);
|
|
1130
|
+
this.stoppedWaiter = null;
|
|
1131
|
+
resolve();
|
|
1132
|
+
},
|
|
1133
|
+
reject: (e: Error) => {
|
|
1134
|
+
clearTimeout(timer);
|
|
1135
|
+
this.stoppedWaiter = null;
|
|
1136
|
+
reject(e);
|
|
1137
|
+
},
|
|
1138
|
+
};
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
private async waitForStop(timeoutMs: number): Promise<void> {
|
|
1143
|
+
if (!this.isPaused()) {
|
|
1144
|
+
await this.createStoppedWaiter(timeoutMs);
|
|
1145
|
+
}
|
|
1146
|
+
// Fetch the stack trace if paused and not yet loaded
|
|
1147
|
+
if (this.isPaused() && this._stackFrames.length === 0) {
|
|
1148
|
+
await this.fetchStackTrace();
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|