dap-mcp-server 0.1.9 → 0.1.11
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/README.md +5 -2
- package/SKILL.md +175 -28
- package/TODO.md +32 -0
- package/build/adapters/builtin.js +3 -0
- package/build/adapters/builtin.js.map +1 -1
- package/build/dap/dap-client.js +23 -0
- package/build/dap/dap-client.js.map +1 -1
- package/build/dap/types.d.ts +12 -7
- package/build/server.js +222 -51
- package/build/server.js.map +1 -1
- package/build/session/inspect.d.ts +64 -0
- package/build/session/inspect.js +103 -0
- package/build/session/inspect.js.map +1 -0
- package/build/session/run.d.ts +52 -0
- package/build/session/run.js +103 -0
- package/build/session/run.js.map +1 -0
- package/build/session/session.d.ts +8 -9
- package/build/session/session.js +57 -7
- package/build/session/session.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/builtin.ts +3 -0
- package/src/dap/dap-client.ts +22 -0
- package/src/dap/types.ts +13 -7
- package/src/integration/debug-lifecycle.test.ts +27 -3
- package/src/server.ts +245 -49
- package/src/session/inspect.test.ts +126 -0
- package/src/session/inspect.ts +147 -0
- package/src/session/run.test.ts +74 -0
- package/src/session/run.ts +136 -0
- package/src/session/session.test.ts +42 -0
- package/src/session/session.ts +58 -19
- package/src/test-fixtures/mock-adapter.js +23 -1
- package/src/test-fixtures/no-stop-adapter.js +77 -0
- package/.claude/settings.local.json +0 -31
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ivan Iraci <ivan.iraci@professioneit.com>
|
|
3
|
+
*
|
|
4
|
+
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
5
|
+
* of this software and associated documentation files (the "Software"), to deal
|
|
6
|
+
* in the Software without restriction, including without limitation the rights
|
|
7
|
+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
* copies of the Software, and to permit persons to whom the Software is
|
|
9
|
+
* furnished to do so, subject to the following conditions:
|
|
10
|
+
*
|
|
11
|
+
* The above copyright notice and this permission notice shall be included in
|
|
12
|
+
* all copies or substantial portions of the Software.
|
|
13
|
+
*
|
|
14
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
15
|
+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
16
|
+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
17
|
+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
18
|
+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
19
|
+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
20
|
+
* SOFTWARE.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { AdapterRegistry } from "../adapters/registry.js";
|
|
24
|
+
import { SessionManager } from "./session-manager.js";
|
|
25
|
+
import { DebugSession } from "./session.js";
|
|
26
|
+
import { AdapterConfig } from "../dap/types.js";
|
|
27
|
+
|
|
28
|
+
export interface InspectOptions {
|
|
29
|
+
adapter: string;
|
|
30
|
+
program: string;
|
|
31
|
+
args?: string[];
|
|
32
|
+
cwd?: string;
|
|
33
|
+
env?: Record<string, string>;
|
|
34
|
+
at: {
|
|
35
|
+
file: string;
|
|
36
|
+
line: number;
|
|
37
|
+
condition?: string;
|
|
38
|
+
hitCondition?: string;
|
|
39
|
+
};
|
|
40
|
+
expressions?: string[];
|
|
41
|
+
timeout?: number;
|
|
42
|
+
adapterConfig?: AdapterConfig;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface InspectValueResult {
|
|
46
|
+
result?: string;
|
|
47
|
+
type?: string;
|
|
48
|
+
error?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface InspectResult {
|
|
52
|
+
hit: boolean;
|
|
53
|
+
reason?: string;
|
|
54
|
+
location?: { file: string; line: number; functionName?: string; column?: number };
|
|
55
|
+
values?: Record<string, InspectValueResult>;
|
|
56
|
+
output?: string[];
|
|
57
|
+
status?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Launch a program, run until it hits the given breakpoint, evaluate a set of
|
|
62
|
+
* expressions in that frame, and disconnect. Collapses a multi-call workflow
|
|
63
|
+
* into a single operation suitable for "what is X at line N?" debugging.
|
|
64
|
+
*/
|
|
65
|
+
export async function runDebugInspect(
|
|
66
|
+
registry: AdapterRegistry,
|
|
67
|
+
sessions: SessionManager,
|
|
68
|
+
opts: InspectOptions
|
|
69
|
+
): Promise<InspectResult> {
|
|
70
|
+
const config = opts.adapterConfig ?? registry.get(opts.adapter);
|
|
71
|
+
if (!config) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Adapter '${opts.adapter}' not found. Use list_adapters to see available adapters or configure_adapter to register one.`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const id = sessions.generateId();
|
|
78
|
+
const session = new DebugSession(id, config);
|
|
79
|
+
sessions.add(session);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
await session.initialize();
|
|
83
|
+
|
|
84
|
+
const launchArgs: Record<string, unknown> = {
|
|
85
|
+
...config.launchDefaults,
|
|
86
|
+
program: opts.program,
|
|
87
|
+
stopOnEntry: false,
|
|
88
|
+
};
|
|
89
|
+
if (opts.args !== undefined) launchArgs.args = opts.args;
|
|
90
|
+
if (opts.cwd !== undefined) launchArgs.cwd = opts.cwd;
|
|
91
|
+
if (opts.env !== undefined) launchArgs.env = opts.env;
|
|
92
|
+
|
|
93
|
+
const launchPromise = session.launch(launchArgs);
|
|
94
|
+
|
|
95
|
+
await session.setBreakpoints(opts.at.file, [
|
|
96
|
+
{
|
|
97
|
+
line: opts.at.line,
|
|
98
|
+
condition: opts.at.condition,
|
|
99
|
+
hitCondition: opts.at.hitCondition,
|
|
100
|
+
},
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
await session.configurationDone();
|
|
104
|
+
await launchPromise;
|
|
105
|
+
|
|
106
|
+
const waitMs = opts.timeout ?? 30000;
|
|
107
|
+
try {
|
|
108
|
+
await session.waitForStopped(waitMs);
|
|
109
|
+
} catch {
|
|
110
|
+
const { output } = session.getOutput();
|
|
111
|
+
return {
|
|
112
|
+
hit: false,
|
|
113
|
+
status: session.state,
|
|
114
|
+
output: output.map((e) => e.text),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const state = await session.collectState();
|
|
119
|
+
const values: Record<string, InspectValueResult> = {};
|
|
120
|
+
if (opts.expressions && opts.expressions.length > 0) {
|
|
121
|
+
for (const expr of opts.expressions) {
|
|
122
|
+
try {
|
|
123
|
+
const r = await session.evaluate(expr);
|
|
124
|
+
values[expr] = { result: r.result, type: r.type };
|
|
125
|
+
} catch (err) {
|
|
126
|
+
values[expr] = { error: err instanceof Error ? err.message : String(err) };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
hit: true,
|
|
133
|
+
reason: state.reason,
|
|
134
|
+
location: state.location,
|
|
135
|
+
values: opts.expressions && opts.expressions.length > 0 ? values : undefined,
|
|
136
|
+
output: state.output,
|
|
137
|
+
status: "stopped",
|
|
138
|
+
};
|
|
139
|
+
} finally {
|
|
140
|
+
sessions.remove(session.id);
|
|
141
|
+
try {
|
|
142
|
+
await session.disconnect();
|
|
143
|
+
} catch {
|
|
144
|
+
/* best-effort cleanup */
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ivan Iraci <ivan.iraci@professioneit.com>
|
|
3
|
+
*
|
|
4
|
+
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
5
|
+
* of this software and associated documentation files (the "Software"), to deal
|
|
6
|
+
* in the Software without restriction, including without limitation the rights
|
|
7
|
+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
* copies of the Software, and to permit persons to whom the Software is
|
|
9
|
+
* furnished to do so, subject to the following conditions:
|
|
10
|
+
*
|
|
11
|
+
* The above copyright notice and this permission notice shall be included in
|
|
12
|
+
* all copies or substantial portions of the Software.
|
|
13
|
+
*
|
|
14
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
15
|
+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
16
|
+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
17
|
+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
18
|
+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
19
|
+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
20
|
+
* SOFTWARE.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, it, expect } from "vitest";
|
|
24
|
+
import { runDebugRun } from "./run.js";
|
|
25
|
+
import { AdapterRegistry } from "../adapters/registry.js";
|
|
26
|
+
import { SessionManager } from "./session-manager.js";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import os from "node:os";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
|
|
31
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const MOCK_ADAPTER = path.join(__dirname, "../test-fixtures/mock-adapter.js");
|
|
33
|
+
|
|
34
|
+
function freshSetup() {
|
|
35
|
+
return {
|
|
36
|
+
registry: new AdapterRegistry(
|
|
37
|
+
path.join(os.tmpdir(), `dap-test-${Date.now()}-${Math.random()}-global`),
|
|
38
|
+
path.join(os.tmpdir(), `dap-test-${Date.now()}-${Math.random()}-project`)
|
|
39
|
+
),
|
|
40
|
+
sessions: new SessionManager(),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("runDebugRun", () => {
|
|
45
|
+
it("runs a program to completion and returns its output", async () => {
|
|
46
|
+
const { registry, sessions } = freshSetup();
|
|
47
|
+
const result = await runDebugRun(registry, sessions, {
|
|
48
|
+
adapter: "mock",
|
|
49
|
+
program: "/test/app.py",
|
|
50
|
+
timeout: 5000,
|
|
51
|
+
adapterConfig: {
|
|
52
|
+
type: "mock",
|
|
53
|
+
runtime: "node",
|
|
54
|
+
program: MOCK_ADAPTER,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(result.terminated).toBe(true);
|
|
59
|
+
expect(result.status).toBe("terminated");
|
|
60
|
+
expect(result.output.some((s) => s.includes("Program started"))).toBe(true);
|
|
61
|
+
expect(result.output.some((s) => s.includes("Program completed"))).toBe(true);
|
|
62
|
+
expect(sessions.listAll()).toHaveLength(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("rejects when adapter is unknown and no inline config provided", async () => {
|
|
66
|
+
const { registry, sessions } = freshSetup();
|
|
67
|
+
await expect(
|
|
68
|
+
runDebugRun(registry, sessions, {
|
|
69
|
+
adapter: "nonexistent",
|
|
70
|
+
program: "/test/app.py",
|
|
71
|
+
})
|
|
72
|
+
).rejects.toThrow(/not found/);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ivan Iraci <ivan.iraci@professioneit.com>
|
|
3
|
+
*
|
|
4
|
+
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
5
|
+
* of this software and associated documentation files (the "Software"), to deal
|
|
6
|
+
* in the Software without restriction, including without limitation the rights
|
|
7
|
+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
* copies of the Software, and to permit persons to whom the Software is
|
|
9
|
+
* furnished to do so, subject to the following conditions:
|
|
10
|
+
*
|
|
11
|
+
* The above copyright notice and this permission notice shall be included in
|
|
12
|
+
* all copies or substantial portions of the Software.
|
|
13
|
+
*
|
|
14
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
15
|
+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
16
|
+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
17
|
+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
18
|
+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
19
|
+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
20
|
+
* SOFTWARE.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { AdapterRegistry } from "../adapters/registry.js";
|
|
24
|
+
import { SessionManager } from "./session-manager.js";
|
|
25
|
+
import { DebugSession } from "./session.js";
|
|
26
|
+
import { AdapterConfig } from "../dap/types.js";
|
|
27
|
+
|
|
28
|
+
export interface RunOptions {
|
|
29
|
+
adapter: string;
|
|
30
|
+
program: string;
|
|
31
|
+
args?: string[];
|
|
32
|
+
cwd?: string;
|
|
33
|
+
env?: Record<string, string>;
|
|
34
|
+
/** Optional logpoints — break with logMessage emits formatted output without pausing. */
|
|
35
|
+
logpoints?: Array<{ file: string; line: number; logMessage: string }>;
|
|
36
|
+
/** Total wall-clock timeout for the whole run (default: 60_000ms). */
|
|
37
|
+
timeout?: number;
|
|
38
|
+
adapterConfig?: AdapterConfig;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RunResult {
|
|
42
|
+
terminated: boolean;
|
|
43
|
+
output: string[];
|
|
44
|
+
status: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Launch a program with no stops, let it run to completion, and return the
|
|
49
|
+
* collected output. Use for "what does this script actually do / print / crash with?"
|
|
50
|
+
* cases where you don't need to inspect intermediate state. Pair with logpoints
|
|
51
|
+
* for printf-style debugging.
|
|
52
|
+
*/
|
|
53
|
+
export async function runDebugRun(
|
|
54
|
+
registry: AdapterRegistry,
|
|
55
|
+
sessions: SessionManager,
|
|
56
|
+
opts: RunOptions
|
|
57
|
+
): Promise<RunResult> {
|
|
58
|
+
const config = opts.adapterConfig ?? registry.get(opts.adapter);
|
|
59
|
+
if (!config) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Adapter '${opts.adapter}' not found. Use list_adapters to see available adapters or configure_adapter to register one.`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const id = sessions.generateId();
|
|
66
|
+
const session = new DebugSession(id, config);
|
|
67
|
+
sessions.add(session);
|
|
68
|
+
|
|
69
|
+
const totalTimeout = opts.timeout ?? 60_000;
|
|
70
|
+
const deadline = Date.now() + totalTimeout;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
await session.initialize();
|
|
74
|
+
|
|
75
|
+
const launchArgs: Record<string, unknown> = {
|
|
76
|
+
...config.launchDefaults,
|
|
77
|
+
program: opts.program,
|
|
78
|
+
stopOnEntry: false,
|
|
79
|
+
};
|
|
80
|
+
if (opts.args !== undefined) launchArgs.args = opts.args;
|
|
81
|
+
if (opts.cwd !== undefined) launchArgs.cwd = opts.cwd;
|
|
82
|
+
if (opts.env !== undefined) launchArgs.env = opts.env;
|
|
83
|
+
|
|
84
|
+
const launchPromise = session.launch(launchArgs);
|
|
85
|
+
|
|
86
|
+
if (opts.logpoints && opts.logpoints.length > 0) {
|
|
87
|
+
const byFile = new Map<string, Array<{ line: number; logMessage: string }>>();
|
|
88
|
+
for (const lp of opts.logpoints) {
|
|
89
|
+
const existing = byFile.get(lp.file) ?? [];
|
|
90
|
+
existing.push({ line: lp.line, logMessage: lp.logMessage });
|
|
91
|
+
byFile.set(lp.file, existing);
|
|
92
|
+
}
|
|
93
|
+
for (const [file, bps] of byFile) {
|
|
94
|
+
await session.setBreakpoints(file, bps);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await session.configurationDone();
|
|
99
|
+
await launchPromise;
|
|
100
|
+
|
|
101
|
+
// Loop: if the program stops unexpectedly (e.g. exception, surprise breakpoint),
|
|
102
|
+
// continue. Exit when the program terminates or the deadline passes.
|
|
103
|
+
while (session.state !== "terminated") {
|
|
104
|
+
const remaining = deadline - Date.now();
|
|
105
|
+
if (remaining <= 0) break;
|
|
106
|
+
|
|
107
|
+
if (session.state === "stopped") {
|
|
108
|
+
await session.continueExecution();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
await Promise.race([
|
|
113
|
+
session.waitForTerminated(remaining),
|
|
114
|
+
session.waitForStopped(remaining),
|
|
115
|
+
]);
|
|
116
|
+
} catch {
|
|
117
|
+
// Both rejected (timeout). Bail.
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const { output } = session.getOutput();
|
|
123
|
+
return {
|
|
124
|
+
terminated: session.state === "terminated",
|
|
125
|
+
output: output.map((e) => e.text),
|
|
126
|
+
status: session.state,
|
|
127
|
+
};
|
|
128
|
+
} finally {
|
|
129
|
+
sessions.remove(session.id);
|
|
130
|
+
try {
|
|
131
|
+
await session.disconnect();
|
|
132
|
+
} catch {
|
|
133
|
+
/* best-effort cleanup */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -69,6 +69,48 @@ describe("DebugSession", () => {
|
|
|
69
69
|
expect(output.output).toHaveLength(1);
|
|
70
70
|
expect(output.output[0].text).toBe("hello world");
|
|
71
71
|
expect(output.output[0].category).toBe("stdout");
|
|
72
|
+
expect(output.output[0].seq).toBe(1);
|
|
73
|
+
expect(output.nextSince).toBe(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns only new entries when called with since", async () => {
|
|
77
|
+
session = new DebugSession("test-since", {
|
|
78
|
+
type: "mock",
|
|
79
|
+
runtime: "node",
|
|
80
|
+
program: MOCK_ADAPTER,
|
|
81
|
+
});
|
|
82
|
+
session.addOutput("stdout", "line 1");
|
|
83
|
+
session.addOutput("stdout", "line 2");
|
|
84
|
+
const first = session.getOutput();
|
|
85
|
+
expect(first.output).toHaveLength(2);
|
|
86
|
+
expect(first.nextSince).toBe(2);
|
|
87
|
+
|
|
88
|
+
session.addOutput("stdout", "line 3");
|
|
89
|
+
session.addOutput("stderr", "err 1");
|
|
90
|
+
const incremental = session.getOutput(undefined, false, first.nextSince);
|
|
91
|
+
expect(incremental.output.map((e) => e.text)).toEqual(["line 3", "err 1"]);
|
|
92
|
+
expect(incremental.nextSince).toBe(4);
|
|
93
|
+
|
|
94
|
+
// No new entries since the latest nextSince
|
|
95
|
+
const empty = session.getOutput(undefined, false, incremental.nextSince);
|
|
96
|
+
expect(empty.output).toHaveLength(0);
|
|
97
|
+
expect(empty.nextSince).toBe(4);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("combines since with category filter", async () => {
|
|
101
|
+
session = new DebugSession("test-since-cat", {
|
|
102
|
+
type: "mock",
|
|
103
|
+
runtime: "node",
|
|
104
|
+
program: MOCK_ADAPTER,
|
|
105
|
+
});
|
|
106
|
+
session.addOutput("stdout", "out 1");
|
|
107
|
+
session.addOutput("stderr", "err 1");
|
|
108
|
+
session.addOutput("stdout", "out 2");
|
|
109
|
+
session.addOutput("stderr", "err 2");
|
|
110
|
+
|
|
111
|
+
const stderrOnly = session.getOutput("stderr", false, 1);
|
|
112
|
+
expect(stderrOnly.output.map((e) => e.text)).toEqual(["err 1", "err 2"]);
|
|
113
|
+
expect(stderrOnly.nextSince).toBe(4);
|
|
72
114
|
});
|
|
73
115
|
|
|
74
116
|
it("detects adapter crash", async () => {
|
package/src/session/session.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
OutputEntry,
|
|
30
30
|
BreakpointConfig,
|
|
31
31
|
BreakpointResult,
|
|
32
|
+
Variable,
|
|
32
33
|
} from "../dap/types.js";
|
|
33
34
|
import { DebugProtocol } from "@vscode/debugprotocol";
|
|
34
35
|
import path from "node:path";
|
|
@@ -42,6 +43,7 @@ export class DebugSession {
|
|
|
42
43
|
private adapterConfig: AdapterConfig;
|
|
43
44
|
private _state: SessionState = "idle";
|
|
44
45
|
private outputBuffer = new RingBuffer<OutputEntry>(MAX_OUTPUT_LINES);
|
|
46
|
+
private nextOutputSeq = 1;
|
|
45
47
|
private breakpointsMap = new Map<string, BreakpointConfig[]>();
|
|
46
48
|
private capabilities: DebugProtocol.Capabilities = {};
|
|
47
49
|
private stoppedThreadId?: number;
|
|
@@ -51,6 +53,7 @@ export class DebugSession {
|
|
|
51
53
|
private launchMode: "launch" | "attach" = "launch";
|
|
52
54
|
private stoppedResolvers: Array<(event: DebugProtocol.StoppedEvent) => void> = [];
|
|
53
55
|
private initializedResolvers: Array<() => void> = [];
|
|
56
|
+
private terminatedResolvers: Array<() => void> = [];
|
|
54
57
|
private _initializedReceived = false;
|
|
55
58
|
|
|
56
59
|
constructor(id: string, config: AdapterConfig) {
|
|
@@ -69,9 +72,16 @@ export class DebugSession {
|
|
|
69
72
|
if (this._state !== "terminated") {
|
|
70
73
|
this._state = "terminated";
|
|
71
74
|
}
|
|
75
|
+
this.resolveTerminatedWaiters();
|
|
72
76
|
});
|
|
73
77
|
}
|
|
74
78
|
|
|
79
|
+
private resolveTerminatedWaiters(): void {
|
|
80
|
+
const resolvers = this.terminatedResolvers;
|
|
81
|
+
this.terminatedResolvers = [];
|
|
82
|
+
for (const r of resolvers) r();
|
|
83
|
+
}
|
|
84
|
+
|
|
75
85
|
get state(): SessionState {
|
|
76
86
|
return this._state;
|
|
77
87
|
}
|
|
@@ -199,6 +209,29 @@ export class DebugSession {
|
|
|
199
209
|
await this.client.sendRequest("pause", { threadId: tid });
|
|
200
210
|
}
|
|
201
211
|
|
|
212
|
+
async waitForTerminated(timeout = DEFAULT_TIMEOUT): Promise<void> {
|
|
213
|
+
if (this._state === "terminated") return;
|
|
214
|
+
return new Promise<void>((resolve, reject) => {
|
|
215
|
+
let settled = false;
|
|
216
|
+
const wrapped = () => {
|
|
217
|
+
if (settled) return;
|
|
218
|
+
settled = true;
|
|
219
|
+
clearTimeout(timer);
|
|
220
|
+
const idx = this.terminatedResolvers.indexOf(wrapped);
|
|
221
|
+
if (idx !== -1) this.terminatedResolvers.splice(idx, 1);
|
|
222
|
+
resolve();
|
|
223
|
+
};
|
|
224
|
+
const timer = setTimeout(() => {
|
|
225
|
+
if (settled) return;
|
|
226
|
+
settled = true;
|
|
227
|
+
const idx = this.terminatedResolvers.indexOf(wrapped);
|
|
228
|
+
if (idx !== -1) this.terminatedResolvers.splice(idx, 1);
|
|
229
|
+
reject(new Error("timeout"));
|
|
230
|
+
}, timeout);
|
|
231
|
+
this.terminatedResolvers.push(wrapped);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
202
235
|
async waitForStopped(timeout = DEFAULT_TIMEOUT): Promise<DebugProtocol.StoppedEvent> {
|
|
203
236
|
if (this._state === "stopped") {
|
|
204
237
|
return {
|
|
@@ -251,17 +284,9 @@ export class DebugSession {
|
|
|
251
284
|
async getVariables(
|
|
252
285
|
variablesReference: number,
|
|
253
286
|
depth = 1
|
|
254
|
-
): Promise<
|
|
255
|
-
Array<{
|
|
256
|
-
name: string;
|
|
257
|
-
value: string;
|
|
258
|
-
type?: string;
|
|
259
|
-
hasChildren: boolean;
|
|
260
|
-
variablesReference: number;
|
|
261
|
-
}>
|
|
262
|
-
> {
|
|
287
|
+
): Promise<Variable[]> {
|
|
263
288
|
const response = await this.client.sendRequest("variables", { variablesReference });
|
|
264
|
-
const vars = ((response.body?.variables as DebugProtocol.Variable[]) || []).map((v) => ({
|
|
289
|
+
const vars: Variable[] = ((response.body?.variables as DebugProtocol.Variable[]) || []).map((v) => ({
|
|
265
290
|
name: v.name,
|
|
266
291
|
value: v.value,
|
|
267
292
|
type: v.type,
|
|
@@ -271,8 +296,7 @@ export class DebugSession {
|
|
|
271
296
|
if (depth > 1) {
|
|
272
297
|
for (const v of vars) {
|
|
273
298
|
if (v.hasChildren) {
|
|
274
|
-
|
|
275
|
-
v.value = `${v.value} { ${children.map((c) => `${c.name}: ${c.value}`).join(", ")} }`;
|
|
299
|
+
v.children = await this.getVariables(v.variablesReference, depth - 1);
|
|
276
300
|
}
|
|
277
301
|
}
|
|
278
302
|
}
|
|
@@ -345,16 +369,28 @@ export class DebugSession {
|
|
|
345
369
|
}
|
|
346
370
|
|
|
347
371
|
addOutput(category: string, text: string): void {
|
|
348
|
-
this.outputBuffer.push({
|
|
372
|
+
this.outputBuffer.push({
|
|
373
|
+
seq: this.nextOutputSeq++,
|
|
374
|
+
category,
|
|
375
|
+
text,
|
|
376
|
+
timestamp: new Date().toISOString(),
|
|
377
|
+
});
|
|
349
378
|
}
|
|
350
379
|
|
|
351
380
|
getOutput(
|
|
352
381
|
category?: string,
|
|
353
|
-
clear = false
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
382
|
+
clear = false,
|
|
383
|
+
since?: number
|
|
384
|
+
): { output: OutputEntry[]; truncated: boolean; nextSince: number } {
|
|
385
|
+
const matches = (e: OutputEntry): boolean => {
|
|
386
|
+
if (category && e.category !== category) return false;
|
|
387
|
+
if (since !== undefined && e.seq <= since) return false;
|
|
388
|
+
return true;
|
|
389
|
+
};
|
|
390
|
+
const entries =
|
|
391
|
+
category !== undefined || since !== undefined
|
|
392
|
+
? this.outputBuffer.getFiltered(matches)
|
|
393
|
+
: this.outputBuffer.getAll();
|
|
358
394
|
const truncated = this.outputBuffer.truncated;
|
|
359
395
|
if (clear) {
|
|
360
396
|
if (category) {
|
|
@@ -363,7 +399,9 @@ export class DebugSession {
|
|
|
363
399
|
this.outputBuffer.clear();
|
|
364
400
|
}
|
|
365
401
|
}
|
|
366
|
-
|
|
402
|
+
const lastInResult = entries.length > 0 ? entries[entries.length - 1].seq : undefined;
|
|
403
|
+
const nextSince = lastInResult ?? since ?? this.nextOutputSeq - 1;
|
|
404
|
+
return { output: entries, truncated, nextSince };
|
|
367
405
|
}
|
|
368
406
|
|
|
369
407
|
async disconnect(terminateDebuggee?: boolean): Promise<void> {
|
|
@@ -429,6 +467,7 @@ export class DebugSession {
|
|
|
429
467
|
case "terminated":
|
|
430
468
|
case "exited":
|
|
431
469
|
this._state = "terminated";
|
|
470
|
+
this.resolveTerminatedWaiters();
|
|
432
471
|
break;
|
|
433
472
|
}
|
|
434
473
|
}
|
|
@@ -52,6 +52,8 @@ function send(msg) {
|
|
|
52
52
|
|
|
53
53
|
let seq = 1;
|
|
54
54
|
let currentLine = 1;
|
|
55
|
+
let breakpointCount = 0;
|
|
56
|
+
let stopOnEntry = false;
|
|
55
57
|
|
|
56
58
|
function handleMessage(msg) {
|
|
57
59
|
if (msg.type !== "request") return;
|
|
@@ -62,6 +64,7 @@ function handleMessage(msg) {
|
|
|
62
64
|
send({ seq: seq++, type: "event", event: "initialized", body: {} });
|
|
63
65
|
break;
|
|
64
66
|
case "setBreakpoints":
|
|
67
|
+
breakpointCount += (msg.arguments.breakpoints || []).length;
|
|
65
68
|
send({ seq: seq++, type: "response", request_seq: msg.seq, command: "setBreakpoints", success: true,
|
|
66
69
|
body: { breakpoints: (msg.arguments.breakpoints || []).map((bp, i) => ({ id: i+1, verified: true, line: bp.line })) } });
|
|
67
70
|
break;
|
|
@@ -70,12 +73,24 @@ function handleMessage(msg) {
|
|
|
70
73
|
break;
|
|
71
74
|
case "configurationDone":
|
|
72
75
|
send({ seq: seq++, type: "response", request_seq: msg.seq, command: "configurationDone", success: true, body: {} });
|
|
76
|
+
// Real-adapter behavior: after configurationDone, if launch was non-stopOnEntry
|
|
77
|
+
// and breakpoints were set, the program runs and hits the first breakpoint.
|
|
78
|
+
if (!stopOnEntry && breakpointCount > 0) {
|
|
79
|
+
setTimeout(() => { send({ seq: seq++, type: "event", event: "stopped", body: { reason: "breakpoint", threadId: 1, allThreadsStopped: true } }); }, 50);
|
|
80
|
+
} else if (!stopOnEntry && breakpointCount === 0) {
|
|
81
|
+
// No breakpoints, no stop-on-entry: program runs to completion and terminates.
|
|
82
|
+
setTimeout(() => {
|
|
83
|
+
send({ seq: seq++, type: "event", event: "output", body: { category: "stdout", output: "Program completed\n" } });
|
|
84
|
+
send({ seq: seq++, type: "event", event: "terminated", body: {} });
|
|
85
|
+
}, 50);
|
|
86
|
+
}
|
|
73
87
|
break;
|
|
74
88
|
case "launch":
|
|
75
89
|
currentLine = 1;
|
|
90
|
+
stopOnEntry = !!msg.arguments.stopOnEntry;
|
|
76
91
|
send({ seq: seq++, type: "response", request_seq: msg.seq, command: "launch", success: true, body: {} });
|
|
77
92
|
send({ seq: seq++, type: "event", event: "output", body: { category: "stdout", output: "Program started\n" } });
|
|
78
|
-
if (
|
|
93
|
+
if (stopOnEntry) {
|
|
79
94
|
setTimeout(() => { send({ seq: seq++, type: "event", event: "stopped", body: { reason: "entry", threadId: 1, allThreadsStopped: true } }); }, 50);
|
|
80
95
|
}
|
|
81
96
|
break;
|
|
@@ -105,6 +120,13 @@ function handleMessage(msg) {
|
|
|
105
120
|
{ name: "name", value: "'hello'", type: "str", variablesReference: 0 },
|
|
106
121
|
{ name: "data", value: "[1, 2, 3]", type: "list", variablesReference: 300 },
|
|
107
122
|
] } });
|
|
123
|
+
} else if (msg.arguments.variablesReference === 300) {
|
|
124
|
+
send({ seq: seq++, type: "response", request_seq: msg.seq, command: "variables", success: true,
|
|
125
|
+
body: { variables: [
|
|
126
|
+
{ name: "0", value: "1", type: "int", variablesReference: 0 },
|
|
127
|
+
{ name: "1", value: "2", type: "int", variablesReference: 0 },
|
|
128
|
+
{ name: "2", value: "3", type: "int", variablesReference: 0 },
|
|
129
|
+
] } });
|
|
108
130
|
} else {
|
|
109
131
|
send({ seq: seq++, type: "response", request_seq: msg.seq, command: "variables", success: true, body: { variables: [] } });
|
|
110
132
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ivan Iraci <ivan.iraci@professioneit.com>
|
|
3
|
+
*
|
|
4
|
+
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
5
|
+
* of this software and associated documentation files (the "Software"), to deal
|
|
6
|
+
* in the Software without restriction, including without limitation the rights
|
|
7
|
+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
* copies of the Software, and to permit persons to whom the Software is
|
|
9
|
+
* furnished to do so, subject to the following conditions:
|
|
10
|
+
*
|
|
11
|
+
* The above copyright notice and this permission notice shall be included in
|
|
12
|
+
* all copies or substantial portions of the Software.
|
|
13
|
+
*
|
|
14
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
15
|
+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
16
|
+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
17
|
+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
18
|
+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
19
|
+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
20
|
+
* SOFTWARE.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// Mock adapter that completes the DAP handshake but never emits stopped events.
|
|
24
|
+
// Used to test client behavior when a breakpoint is never hit (e.g. timeout paths).
|
|
25
|
+
|
|
26
|
+
const TWO_CRLF = "\r\n\r\n";
|
|
27
|
+
let buffer = Buffer.alloc(0);
|
|
28
|
+
let contentLength = -1;
|
|
29
|
+
|
|
30
|
+
process.stdin.on("data", (chunk) => {
|
|
31
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
32
|
+
while (true) {
|
|
33
|
+
if (contentLength === -1) {
|
|
34
|
+
const idx = buffer.indexOf(TWO_CRLF);
|
|
35
|
+
if (idx === -1) return;
|
|
36
|
+
const header = buffer.subarray(0, idx).toString();
|
|
37
|
+
const match = header.match(/Content-Length:\s*(\d+)/i);
|
|
38
|
+
if (!match) return;
|
|
39
|
+
contentLength = parseInt(match[1], 10);
|
|
40
|
+
buffer = buffer.subarray(idx + TWO_CRLF.length);
|
|
41
|
+
}
|
|
42
|
+
if (buffer.length < contentLength) return;
|
|
43
|
+
const body = buffer.subarray(0, contentLength).toString();
|
|
44
|
+
buffer = buffer.subarray(contentLength);
|
|
45
|
+
contentLength = -1;
|
|
46
|
+
handleMessage(JSON.parse(body));
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function send(msg) {
|
|
51
|
+
const json = JSON.stringify(msg);
|
|
52
|
+
const header = `Content-Length: ${Buffer.byteLength(json)}${TWO_CRLF}`;
|
|
53
|
+
process.stdout.write(header + json);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let seq = 1;
|
|
57
|
+
|
|
58
|
+
function handleMessage(msg) {
|
|
59
|
+
if (msg.type !== "request") return;
|
|
60
|
+
switch (msg.command) {
|
|
61
|
+
case "initialize":
|
|
62
|
+
send({ seq: seq++, type: "response", request_seq: msg.seq, command: "initialize", success: true,
|
|
63
|
+
body: { supportsConfigurationDoneRequest: true } });
|
|
64
|
+
send({ seq: seq++, type: "event", event: "initialized", body: {} });
|
|
65
|
+
break;
|
|
66
|
+
case "setBreakpoints":
|
|
67
|
+
send({ seq: seq++, type: "response", request_seq: msg.seq, command: "setBreakpoints", success: true,
|
|
68
|
+
body: { breakpoints: (msg.arguments.breakpoints || []).map((bp, i) => ({ id: i+1, verified: true, line: bp.line })) } });
|
|
69
|
+
break;
|
|
70
|
+
case "disconnect":
|
|
71
|
+
send({ seq: seq++, type: "response", request_seq: msg.seq, command: "disconnect", success: true, body: {} });
|
|
72
|
+
setTimeout(() => process.exit(0), 20);
|
|
73
|
+
break;
|
|
74
|
+
default:
|
|
75
|
+
send({ seq: seq++, type: "response", request_seq: msg.seq, command: msg.command, success: true, body: {} });
|
|
76
|
+
}
|
|
77
|
+
}
|