fix(coding-agent): enabled js-debug child sessions
Added TCP server transport for vscode-js-debug and recursively handled startDebugging requests, breakpoint synchronization, active child routing, and tree cleanup. Fixes #5984
This commit is contained in:
+17
-16
@@ -122,19 +122,19 @@ Side-channel artifacts outside the model tool result:
|
||||
1. Tool registration is conditional: `DebugTool.createIf()` in `packages/coding-agent/src/tools/debug.ts` returns `null` unless `session.settings.get("debug.enabled")` is true. `packages/coding-agent/src/tools/index.ts` wires the factory and rechecks the same setting in tool filtering.
|
||||
2. `DebugTool.execute()` clamps `params.timeout` through `clampTimeout("debug", params.timeout)` and composes the caller `AbortSignal` with `AbortSignal.timeout(...)`.
|
||||
3. `launch` and `attach` resolve cwd/program paths, select an adapter in `packages/coding-agent/src/dap/config.ts`, then delegate to `dapSessionManager.launch()` / `.attach()`.
|
||||
4. `DapSessionManager.launch()` / `.attach()` enforce the single-session rule with `#ensureLaunchSlot()`, spawn the adapter through `DapClient.spawn()`, register listeners, send `initialize`, cache capabilities, start listening for an initial stop event before sending `launch`/`attach`, then complete the `initialized` → `configurationDone` handshake in `#completeConfigurationHandshake()`.
|
||||
5. `DapClient.spawn()` starts the adapter detached with `NON_INTERACTIVE_ENV`. Most adapters use stdio; socket-mode adapters (`dlv`) use `#spawnSocketUnix()` on Linux or `#spawnSocketClientAddr()` on macOS/other.
|
||||
4. `DapSessionManager.launch()` / `.attach()` enforce one root session, spawn the adapter through `DapClient.spawn()`, register listeners, send `initialize`, cache capabilities, subscribe for tree-wide stop events, send `launch`/`attach`, then complete the `initialized` → `configurationDone` handshake.
|
||||
5. `DapClient.spawn()` starts adapters detached with `NON_INTERACTIVE_ENV`. Most adapters use stdio; socket-mode adapters (`dlv`) use an adapter-specific Unix/TCP transport, while TCP server adapters start with `${port}` substituted in their args. Child sessions reuse the root TCP server through `DapClient.connect()`.
|
||||
6. `#registerSession()` in `packages/coding-agent/src/dap/session.ts` installs reverse-request handlers:
|
||||
- `runInTerminal`: spawns the requested debuggee command detached via `ptree.spawn()` and returns `{ processId }`
|
||||
- `startDebugging`: logs the child-session request and returns `{}`; it does not create nested sessions
|
||||
- events: `output`, `initialized`, `stopped`, `continued`, `exited`, `terminated` update cached session state
|
||||
7. Operational actions (`set_breakpoint`, `evaluate`, `threads`, `read_memory`, `custom_request`, and similar) call `dapSessionManager` methods. Most flow through `#sendRequestWithConfig()`, which first sends `configurationDone` when required, then sends the DAP request, then updates `lastUsedAt`.
|
||||
8. Breakpoint actions maintain local cached breakpoint sets in `DapSessionManager` and remap adapter responses back onto those cached records.
|
||||
9. `continue` and the three step actions clear cached stop state, subscribe for `stopped`/`terminated`/`exited` before sending the DAP request, then `#awaitStopOutcome()` either returns the new stopped location or reports that the program is still running after timeout.
|
||||
- `startDebugging`: connects a child DAP client to the root TCP server, forwards the requested `launch`/`attach` configuration, binds root breakpoints before `configurationDone`, and recursively installs the same handlers
|
||||
- events: `output`, `initialized`, `stopped`, `continued`, `exited`, and `terminated` update cached session state; stopped children become the active target
|
||||
7. Operational actions (`set_breakpoint`, `evaluate`, `threads`, `read_memory`, `custom_request`, and similar) call `dapSessionManager` methods. Most flow through `#sendRequestWithConfig()`, which first sends `configurationDone` when required, then sends the DAP request and refreshes the active session plus its ancestors.
|
||||
8. Breakpoint actions synchronize desired breakpoint sets across the live root/child tree. New children receive those sets before their `configurationDone` request.
|
||||
9. `continue` and the three step actions clear cached stop state, subscribe for a stop/termination event anywhere in the session tree before sending the DAP request, then `#awaitStopOutcome()` returns the active child’s stopped location or reports that the target remains running after timeout.
|
||||
10. `pause` sends DAP `pause`, waits for a stopped event if needed, and reuses cached stop state if the program was already stopped.
|
||||
11. `stack_trace`, `scopes`, `variables`, and `evaluate` default to the current stopped thread/frame when the caller omits ids and cached state is available.
|
||||
12. `output` reads the in-memory output ring from `DapSessionManager.getOutput()`. `terminate` sends `terminate` when supported, always attempts `disconnect`, marks the session terminated, and disposes the client.
|
||||
13. `sessions` reads the manager’s current map and formats all summaries. Although the manager stores a map, only one active session can exist because new launch/attach calls are blocked until the active one is terminated or cleaned up.
|
||||
11. `stack_trace`, `scopes`, `variables`, and `evaluate` default to the current stopped child/thread/frame when the caller omits ids and cached state is available.
|
||||
12. `output` reads the in-memory output ring from the active `DapSession`. `terminate` walks from the root through every child, sends best-effort `terminate`/`disconnect`, and disposes the complete tree even when an adapter times out.
|
||||
13. `sessions` reads the manager’s current map and formats root and child summaries. Only one root tree can exist; recursive adapter-requested children are tracked with `parentSessionId` / `childSessionIds`.
|
||||
14. The interactive selector in `packages/coding-agent/src/debug/index.ts` builds a `SelectList` of fixed values and dispatches each to a handler:
|
||||
- `performance`: `startCpuProfile()`, wait for Enter/Escape, stop profiling, read a 30-second work profile with `getWorkProfile(30)`, then bundle via `createReportBundle()`
|
||||
- `work`: read `getWorkProfile(30)`, write a temp SVG, open it externally
|
||||
@@ -320,12 +320,13 @@ Example `.omp/dap.json`:
|
||||
- `collectSystemInfo()` is best-effort for CPU probing; failure there falls back to `Unknown CPU`.
|
||||
|
||||
## Notes
|
||||
- `packages/coding-agent/src/prompts/tools/debug.md` tells the model only one active session is supported; that is not advisory, it is enforced in code.
|
||||
- `configurationDone` is sent automatically both during launch/attach handshake and lazily before later requests if the adapter required it and the initial handshake did not complete.
|
||||
- `startDebugging` reverse requests are acknowledged but not implemented; child debug sessions are not spawned.
|
||||
- `output` exposes the merged `output` event stream only; the tool does not distinguish stdout, stderr, and console categories.
|
||||
- Session summaries expose `needsConfigurationDone`; this is derived from adapter capabilities and whether `configurationDone` has been sent.
|
||||
- Source breakpoint file paths are normalized with `path.resolve()` before caching and sending to the adapter.
|
||||
- `packages/coding-agent/src/prompts/tools/debug.md` tells the model only one active root session is supported. Adapter-requested child sessions belong to that root tree.
|
||||
- The default JavaScript/TypeScript adapter runs vscode-js-debug’s `dapDebugServer.js` over TCP. Install it with Mason or set `JS_DEBUG_DAP_SERVER` to a release-tarball server path.
|
||||
- `configurationDone` is sent automatically during root and child launch/attach handshakes and lazily before later requests if the initial handshake did not complete.
|
||||
- `startDebugging` reverse requests create recursive child sessions on the same TCP server; a stopped child becomes the target for thread-level actions.
|
||||
- `output` exposes the active session’s merged `output` event stream only; the tool does not distinguish stdout, stderr, and console categories.
|
||||
- Session summaries expose `needsConfigurationDone`, `parentSessionId`, and `childSessionIds`.
|
||||
- Source breakpoint file paths are normalized with `path.resolve()` before caching and synchronizing across the tree.
|
||||
- `evaluate` defaults to `repl`, so the tool can forward raw debugger commands when the adapter supports them.
|
||||
- `disassemble` resolves its target from `memory_reference` first, then the current stopped session's `instructionPointerReference`; it throws if neither is present.
|
||||
- `RawSseDebugBuffer.recordEvent()` increments `totalEvents` before bounded retention. A snapshot can therefore show fewer retained records than total observed events.
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed JavaScript/TypeScript debugging by launching vscode-js-debug over TCP, handling recursive `startDebugging` child sessions, synchronizing breakpoints across the session tree, and terminating every child connection ([#5984](https://github.com/can1357/oh-my-pi/issues/5984)).
|
||||
|
||||
## [17.0.4] - 2026-07-18
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -55,6 +55,8 @@ export class DapClient {
|
||||
readonly adapter: DapResolvedAdapter;
|
||||
readonly cwd: string;
|
||||
readonly proc: DapClientState["proc"];
|
||||
/** TCP server port reused by child DAP sessions. */
|
||||
readonly port?: number;
|
||||
/** ReadableStream of DAP bytes — from proc.stdout (stdio) or a socket (socket mode). */
|
||||
readonly #readable: ReadableStream<Uint8Array>;
|
||||
/** Write sink — proc.stdin (stdio) or a socket (socket mode). */
|
||||
@@ -78,7 +80,12 @@ export class DapClient {
|
||||
adapter: DapResolvedAdapter,
|
||||
cwd: string,
|
||||
proc: DapClientState["proc"],
|
||||
options?: { readable?: ReadableStream<Uint8Array>; writeSink?: DapWriteSink; socket?: { end(): void } },
|
||||
options?: {
|
||||
readable?: ReadableStream<Uint8Array>;
|
||||
writeSink?: DapWriteSink;
|
||||
socket?: { end(): void };
|
||||
port?: number;
|
||||
},
|
||||
) {
|
||||
this.adapter = adapter;
|
||||
this.cwd = cwd;
|
||||
@@ -86,6 +93,7 @@ export class DapClient {
|
||||
this.#readable = options?.readable ?? (proc.stdout as ReadableStream<Uint8Array>);
|
||||
this.#writeSink = options?.writeSink ?? proc.stdin;
|
||||
this.#socket = options?.socket;
|
||||
this.port = options?.port;
|
||||
this.proc.exited.then(
|
||||
() => this.#rejectPendingWritesForExit(),
|
||||
() => this.#rejectPendingWritesForExit(),
|
||||
@@ -96,6 +104,9 @@ export class DapClient {
|
||||
if (adapter.connectMode === "socket") {
|
||||
return DapClient.#spawnSocket({ adapter, cwd, socketReadyTimeoutMs });
|
||||
}
|
||||
if (adapter.connectMode === "tcp") {
|
||||
return DapClient.#spawnTcp({ adapter, cwd, socketReadyTimeoutMs });
|
||||
}
|
||||
// Merge non-interactive env and start in a new session (detached → setsid)
|
||||
// so the adapter process tree has no controlling terminal. Without this,
|
||||
// debuggee children can reach /dev/tty and trigger SIGTTIN, suspending
|
||||
@@ -118,6 +129,85 @@ export class DapClient {
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Connect to another session on an existing TCP DAP server. */
|
||||
static async connect({
|
||||
adapter,
|
||||
cwd,
|
||||
host,
|
||||
port,
|
||||
}: {
|
||||
adapter: DapResolvedAdapter;
|
||||
cwd: string;
|
||||
host: string;
|
||||
port: number;
|
||||
}): Promise<DapClient> {
|
||||
const exited = Promise.withResolvers<void>();
|
||||
const { readable, writeSink, socket } = await connectTcpSocket(host, port, () => exited.resolve());
|
||||
const proc = {
|
||||
exited: exited.promise,
|
||||
exitCode: null,
|
||||
stdin: { write: () => 0, flush: () => undefined },
|
||||
stdout: new ReadableStream<Uint8Array>(),
|
||||
stderr: new ReadableStream<Uint8Array>(),
|
||||
peekStderr: () => "",
|
||||
kill: () => {
|
||||
exited.resolve();
|
||||
return true;
|
||||
},
|
||||
} as unknown as DapClientState["proc"];
|
||||
const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket, port });
|
||||
exited.promise.then(() => client.#handleProcessExit());
|
||||
void client.#startMessageReader();
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Spawn an adapter that listens on a caller-selected TCP port. */
|
||||
static async #spawnTcp({ adapter, cwd, socketReadyTimeoutMs }: DapSpawnOptions): Promise<DapClient> {
|
||||
const host = "127.0.0.1";
|
||||
const reservation = Bun.listen({
|
||||
hostname: host,
|
||||
port: 0,
|
||||
socket: {
|
||||
open() {},
|
||||
data() {},
|
||||
close() {},
|
||||
error() {},
|
||||
},
|
||||
});
|
||||
const port = reservation.port;
|
||||
reservation.stop(true);
|
||||
const args = adapter.args.map(arg => arg.replaceAll("$" + "{port}", String(port)));
|
||||
const proc = ptree.spawn([adapter.resolvedCommand, ...args], {
|
||||
cwd,
|
||||
stdin: "pipe",
|
||||
env: {
|
||||
...Bun.env,
|
||||
...NON_INTERACTIVE_ENV,
|
||||
},
|
||||
detached: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const { readable, writeSink, socket } = await waitForTcpTransport(
|
||||
host,
|
||||
port,
|
||||
socketReadyTimeoutMs ?? SOCKET_READY_TIMEOUT_MS,
|
||||
proc,
|
||||
);
|
||||
const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket, port });
|
||||
proc.exited.then(() => client.#handleProcessExit());
|
||||
void client.#startMessageReader();
|
||||
return client;
|
||||
} catch (error) {
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* proc may already be dead */
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a socket-mode adapter (e.g. dlv).
|
||||
* Linux: connect to a unix domain socket via --listen=unix:<path>
|
||||
@@ -659,6 +749,83 @@ async function waitForCondition(
|
||||
throw new Error(`Socket not ready after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
/** Connect once to a TCP DAP server. */
|
||||
async function connectTcpSocket(host: string, port: number, onClose?: () => void): Promise<SocketTransport> {
|
||||
const { promise, resolve, reject } = Promise.withResolvers<SocketTransport>();
|
||||
let streamController: ReadableStreamDefaultController<Uint8Array>;
|
||||
let opened = false;
|
||||
const readable = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
streamController = controller;
|
||||
},
|
||||
});
|
||||
|
||||
void Bun.connect({
|
||||
hostname: host,
|
||||
port,
|
||||
socket: {
|
||||
open(socket) {
|
||||
opened = true;
|
||||
resolve({
|
||||
readable,
|
||||
writeSink: socketToSink(socket),
|
||||
socket,
|
||||
});
|
||||
},
|
||||
data(_socket, data) {
|
||||
streamController.enqueue(new Uint8Array(data));
|
||||
},
|
||||
close() {
|
||||
onClose?.();
|
||||
if (!opened) {
|
||||
reject(new Error(`Connection to TCP port ${host}:${port} closed before opening`));
|
||||
}
|
||||
try {
|
||||
streamController.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
},
|
||||
error(_socket, error) {
|
||||
onClose?.();
|
||||
if (!opened) {
|
||||
reject(error);
|
||||
}
|
||||
try {
|
||||
streamController.error(error);
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
},
|
||||
},
|
||||
}).catch(error => {
|
||||
onClose?.();
|
||||
reject(error);
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Wait for a TCP DAP server and retain the first successful connection. */
|
||||
async function waitForTcpTransport(
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
proc: { exitCode: number | null },
|
||||
): Promise<SocketTransport> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (proc.exitCode !== null) {
|
||||
throw new Error(`Adapter process exited before TCP port ${host}:${port} was ready`);
|
||||
}
|
||||
try {
|
||||
return await connectTcpSocket(host, port);
|
||||
} catch {
|
||||
await Bun.sleep(50);
|
||||
}
|
||||
}
|
||||
throw new Error(`TCP port ${host}:${port} was not ready after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
interface SocketTransport {
|
||||
readable: ReadableStream<Uint8Array>;
|
||||
writeSink: DapWriteSink;
|
||||
|
||||
@@ -10,6 +10,8 @@ import DEFAULTS from "./defaults.json" with { type: "json" };
|
||||
import type { DapAdapterConfig, DapResolvedAdapter } from "./types";
|
||||
|
||||
const EXTENSIONLESS_DEBUGGER_ORDER: readonly string[] = ["gdb", "lldb-dap"];
|
||||
const JS_DEBUG_SERVER_ENV = "JS_DEBUG_DAP_SERVER";
|
||||
const DAP_PORT_ARGUMENT = "$" + "{port}";
|
||||
|
||||
interface NormalizedConfig {
|
||||
adapters: Record<string, unknown>;
|
||||
@@ -45,7 +47,7 @@ function normalizeObject(value: unknown): Record<string, unknown> {
|
||||
function normalizeAdapterConfig(config: unknown): DapAdapterConfig | null {
|
||||
if (!isRecord(config)) return null;
|
||||
if (typeof config.command !== "string" || config.command.length === 0) return null;
|
||||
const connectMode = config.connectMode === "socket" ? ("socket" as const) : undefined;
|
||||
const connectMode = config.connectMode === "socket" || config.connectMode === "tcp" ? config.connectMode : undefined;
|
||||
return {
|
||||
command: config.command,
|
||||
args: normalizeStringArray(config.args),
|
||||
@@ -184,6 +186,52 @@ function normalizeCommandForCwd(command: string, cwd: string): string {
|
||||
return command;
|
||||
}
|
||||
|
||||
function resolveJsDebugServerPath(cwd: string): string | null {
|
||||
const configured = process.env[JS_DEBUG_SERVER_ENV];
|
||||
const dataHome = process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share");
|
||||
const candidates = [
|
||||
...(configured ? [path.resolve(cwd, configured)] : []),
|
||||
path.join(dataHome, "nvim", "mason", "packages", "js-debug-adapter", "js-debug", "src", "dapDebugServer.js"),
|
||||
path.join(os.homedir(), ".local", "opt", "js-debug", "src", "dapDebugServer.js"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveDefaultJsDebugAdapter(
|
||||
adapterName: string,
|
||||
config: DapAdapterConfig,
|
||||
cwd: string,
|
||||
localRoots?: readonly string[],
|
||||
): DapResolvedAdapter | null | undefined {
|
||||
if (adapterName !== "js-debug-adapter" || config.command !== "js-debug-adapter") {
|
||||
return undefined;
|
||||
}
|
||||
const serverPath = resolveJsDebugServerPath(cwd);
|
||||
if (!serverPath) return null;
|
||||
const nodeCommand = resolveCommand("node", cwd, {
|
||||
cache: WhichCachePolicy.Fresh,
|
||||
PATH: process.env.PATH,
|
||||
localRoots,
|
||||
});
|
||||
const resolvedCommand = nodeCommand ?? process.execPath;
|
||||
return {
|
||||
name: adapterName,
|
||||
command: nodeCommand ? "node" : "bun",
|
||||
args: [serverPath, DAP_PORT_ARGUMENT, "127.0.0.1"],
|
||||
resolvedCommand,
|
||||
languages: config.languages ?? [],
|
||||
fileTypes: config.fileTypes ?? [],
|
||||
rootMarkers: config.rootMarkers ?? [],
|
||||
launchDefaults: config.launchDefaults ?? {},
|
||||
attachDefaults: config.attachDefaults ?? {},
|
||||
connectMode: "tcp",
|
||||
acceptsDirectoryProgram: config.acceptsDirectoryProgram === true,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAdapterFromConfig(
|
||||
adapterName: string,
|
||||
configs: Record<string, DapAdapterConfig>,
|
||||
@@ -192,6 +240,8 @@ function resolveAdapterFromConfig(
|
||||
): DapResolvedAdapter | null {
|
||||
const config = configs[adapterName];
|
||||
if (!config) return null;
|
||||
const jsDebugAdapter = resolveDefaultJsDebugAdapter(adapterName, config, cwd, localRoots);
|
||||
if (jsDebugAdapter !== undefined) return jsDebugAdapter;
|
||||
const normalizedCommand = normalizeCommandForCwd(config.command, cwd);
|
||||
const commandIsBare =
|
||||
!path.isAbsolute(config.command) && !config.command.includes("/") && !config.command.includes("\\");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -484,10 +484,9 @@ export interface DapAdapterConfig {
|
||||
launchDefaults?: Record<string, unknown>;
|
||||
attachDefaults?: Record<string, unknown>;
|
||||
/** "stdio" (default): communicate via stdin/stdout pipes.
|
||||
* "socket": adapter uses a network socket instead of stdio.
|
||||
* On Linux, connects via a unix domain socket.
|
||||
* On macOS, the adapter dials into a local TCP listener (--client-addr). */
|
||||
connectMode?: "stdio" | "socket";
|
||||
* "socket": adapter-specific socket launch (currently Delve).
|
||||
* "tcp": spawn a DAP server with `${port}` substituted in `args`, then connect to it. */
|
||||
connectMode?: "stdio" | "socket" | "tcp";
|
||||
/** When true, the adapter accepts a directory as the launch `program`
|
||||
* (e.g. dlv treats it as a Go package path). When false/undefined, the
|
||||
* debug tool rejects directory programs upfront. */
|
||||
@@ -504,7 +503,7 @@ export interface DapResolvedAdapter {
|
||||
rootMarkers: string[];
|
||||
launchDefaults: Record<string, unknown>;
|
||||
attachDefaults: Record<string, unknown>;
|
||||
connectMode: "stdio" | "socket";
|
||||
connectMode: "stdio" | "socket" | "tcp";
|
||||
acceptsDirectoryProgram: boolean;
|
||||
}
|
||||
|
||||
@@ -581,6 +580,8 @@ export interface DapSessionSummary {
|
||||
outputTruncated: boolean;
|
||||
exitCode?: number;
|
||||
needsConfigurationDone: boolean;
|
||||
parentSessionId?: string;
|
||||
childSessionIds?: string[];
|
||||
}
|
||||
|
||||
export interface DapContinueOutcome {
|
||||
|
||||
@@ -4,5 +4,6 @@ Only one active session at a time. `program` is a target path, not a shell comma
|
||||
|
||||
Adapters:
|
||||
- Python: `debugpy` (`pip install debugpy`)
|
||||
- JavaScript/TypeScript: vscode-js-debug via Mason, or set `JS_DEBUG_DAP_SERVER` to its `dapDebugServer.js`
|
||||
- Go: Delve (`go install github.com/go-delve/delve/cmd/dlv@latest`)
|
||||
- Ruby: `rdbg` (`gem install debug`)
|
||||
|
||||
@@ -504,12 +504,15 @@ const ADAPTER_UNAVAILABLE_MESSAGES: Readonly<Record<string, string>> = {
|
||||
debugpy: "adapter 'debugpy' is not available: python not found in PATH",
|
||||
dlv: "adapter 'dlv' is not available: install with 'go install github.com/go-delve/delve/cmd/dlv@latest'",
|
||||
rdbg: "adapter 'rdbg' is not available: install with 'gem install debug'",
|
||||
"js-debug-adapter":
|
||||
"adapter 'js-debug-adapter' is not available: install vscode-js-debug with Mason or set JS_DEBUG_DAP_SERVER to dapDebugServer.js",
|
||||
};
|
||||
|
||||
const ADAPTER_CANONICAL_COMMANDS: Readonly<Record<string, string>> = {
|
||||
debugpy: "python",
|
||||
dlv: "dlv",
|
||||
rdbg: "rdbg",
|
||||
"js-debug-adapter": "js-debug-adapter",
|
||||
};
|
||||
|
||||
function formatAdapterUnavailable(adapterName: string, command: string, cwd: string): string {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { afterEach, describe, expect, it, spyOn, vi } from "bun:test";
|
||||
import { DapClient } from "@oh-my-pi/pi-coding-agent/dap/client";
|
||||
import { DapSessionManager } from "@oh-my-pi/pi-coding-agent/dap/session";
|
||||
import type {
|
||||
DapCapabilities,
|
||||
DapClientState,
|
||||
DapEventMessage,
|
||||
DapResolvedAdapter,
|
||||
} from "@oh-my-pi/pi-coding-agent/dap/types";
|
||||
|
||||
const TEST_ADAPTER: DapResolvedAdapter = {
|
||||
name: "js-debug-adapter",
|
||||
command: "node",
|
||||
args: ["dapDebugServer.js", "$" + "{port}", "127.0.0.1"],
|
||||
resolvedCommand: "node",
|
||||
languages: ["javascript", "typescript"],
|
||||
fileTypes: [".js", ".ts"],
|
||||
rootMarkers: ["package.json"],
|
||||
launchDefaults: { request: "launch", type: "pwa-node", stopOnEntry: true },
|
||||
attachDefaults: { request: "attach", type: "pwa-node" },
|
||||
connectMode: "tcp",
|
||||
acceptsDirectoryProgram: false,
|
||||
};
|
||||
|
||||
type EventHandler = (body: unknown, event: DapEventMessage) => void | Promise<void>;
|
||||
type ReverseHandler = (args: unknown) => unknown | Promise<unknown>;
|
||||
|
||||
class FakeDapClient {
|
||||
readonly proc: DapClientState["proc"];
|
||||
readonly port = 8123;
|
||||
readonly requests: Array<{ command: string; args: unknown }> = [];
|
||||
readonly #events = new Map<string, Set<EventHandler>>();
|
||||
readonly #reverseHandlers = new Map<string, ReverseHandler>();
|
||||
readonly #exited = Promise.withResolvers<void>();
|
||||
#alive = true;
|
||||
disposed = false;
|
||||
|
||||
constructor(readonly childConfiguration?: Record<string, unknown>) {
|
||||
this.proc = {
|
||||
exited: this.#exited.promise,
|
||||
exitCode: null,
|
||||
stdin: { write: () => 0, flush: () => undefined },
|
||||
stdout: new ReadableStream<Uint8Array>(),
|
||||
stderr: new ReadableStream<Uint8Array>(),
|
||||
peekStderr: () => "",
|
||||
kill: () => {
|
||||
this.#alive = false;
|
||||
this.#exited.resolve();
|
||||
return true;
|
||||
},
|
||||
} as unknown as DapClientState["proc"];
|
||||
}
|
||||
|
||||
async initialize(): Promise<DapCapabilities> {
|
||||
queueMicrotask(() => this.#emit("initialized", {}));
|
||||
return { supportsConfigurationDoneRequest: true };
|
||||
}
|
||||
|
||||
async sendRequest(command: string, args?: unknown): Promise<unknown> {
|
||||
this.requests.push({ command, args });
|
||||
if (command === "launch") {
|
||||
if (this.childConfiguration) {
|
||||
queueMicrotask(() => {
|
||||
void this.#emitReverse("startDebugging", {
|
||||
request: "launch",
|
||||
configuration: this.childConfiguration,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
queueMicrotask(() => this.#emit("stopped", { reason: "entry", threadId: 7 }));
|
||||
}
|
||||
}
|
||||
if (command === "threads") return { threads: [{ id: 7, name: "target.js" }] };
|
||||
if (command === "stackTrace") {
|
||||
return {
|
||||
stackFrames: [{ id: 70, name: "main", line: 2, column: 1, source: { path: "/tmp/target.js" } }],
|
||||
};
|
||||
}
|
||||
if (command.endsWith("Breakpoints")) {
|
||||
const breakpointArgs = args as { breakpoints?: unknown[] } | undefined;
|
||||
return { breakpoints: (breakpointArgs?.breakpoints ?? []).map((_, id) => ({ id, verified: true })) };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
waitForEvent(event: string): Promise<unknown> {
|
||||
const { promise, resolve } = Promise.withResolvers<unknown>();
|
||||
const unsubscribe = this.onEvent(event, body => {
|
||||
unsubscribe();
|
||||
resolve(body);
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
onEvent(event: string, handler: EventHandler): () => void {
|
||||
const handlers = this.#events.get(event) ?? new Set<EventHandler>();
|
||||
handlers.add(handler);
|
||||
this.#events.set(event, handlers);
|
||||
return () => handlers.delete(handler);
|
||||
}
|
||||
|
||||
onReverseRequest(command: string, handler: ReverseHandler): () => void {
|
||||
this.#reverseHandlers.set(command, handler);
|
||||
return () => this.#reverseHandlers.delete(command);
|
||||
}
|
||||
|
||||
isAlive(): boolean {
|
||||
return this.#alive;
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true;
|
||||
this.#alive = false;
|
||||
this.#exited.resolve();
|
||||
}
|
||||
|
||||
#emit(event: string, body: unknown): void {
|
||||
const message: DapEventMessage = { seq: 1, type: "event", event, body };
|
||||
for (const handler of this.#events.get(event) ?? []) void handler(body, message);
|
||||
}
|
||||
|
||||
async #emitReverse(command: string, args: unknown): Promise<void> {
|
||||
const handler = this.#reverseHandlers.get(command);
|
||||
if (!handler) throw new Error(`Missing reverse handler for ${command}`);
|
||||
await handler(args);
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("DAP multi-session debugging", () => {
|
||||
it("routes recursive js-debug children, breakpoints, and termination through one session tree", async () => {
|
||||
const root = new FakeDapClient({
|
||||
name: "target.js",
|
||||
type: "pwa-node",
|
||||
__pendingTargetId: "child",
|
||||
program: "/tmp/target.js",
|
||||
});
|
||||
const child = new FakeDapClient({
|
||||
name: "[worker 1]",
|
||||
type: "pwa-node",
|
||||
__pendingTargetId: "grandchild",
|
||||
});
|
||||
const grandchild = new FakeDapClient();
|
||||
const children = [child, grandchild];
|
||||
spyOn(DapClient, "spawn").mockResolvedValue(root as unknown as DapClient);
|
||||
spyOn(DapClient, "connect").mockImplementation(async () => {
|
||||
const next = children.shift();
|
||||
if (!next) throw new Error("Unexpected child DAP connection");
|
||||
return next as unknown as DapClient;
|
||||
});
|
||||
const manager = new DapSessionManager();
|
||||
|
||||
const launched = await manager.launch(
|
||||
{ adapter: TEST_ADAPTER, program: "/tmp/target.js", cwd: "/tmp" },
|
||||
undefined,
|
||||
1_000,
|
||||
);
|
||||
|
||||
expect(launched.status).toBe("stopped");
|
||||
expect(launched.parentSessionId).toBeDefined();
|
||||
expect(launched.line).toBe(2);
|
||||
expect(manager.listSessions()).toHaveLength(3);
|
||||
|
||||
const breakpoint = await manager.setBreakpoint("/tmp/target.js", 2, undefined, undefined, 1_000);
|
||||
expect(breakpoint.breakpoints).toEqual([
|
||||
{ line: 2, condition: undefined, id: 0, verified: true, message: undefined },
|
||||
]);
|
||||
for (const client of [root, child, grandchild]) {
|
||||
expect(client.requests.filter(request => request.command === "setBreakpoints")).toHaveLength(1);
|
||||
}
|
||||
|
||||
await manager.terminate(undefined, 1_000);
|
||||
expect(manager.listSessions()).toEqual([]);
|
||||
for (const client of [root, child, grandchild]) {
|
||||
expect(client.requests.some(request => request.command === "disconnect")).toBe(true);
|
||||
expect(client.disposed).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user