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:
roboomp
2026-07-18 11:56:42 +00:00
parent 3fdd85ab6c
commit 61fdd4dfbc
9 changed files with 994 additions and 260 deletions
+17 -16
View File
@@ -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.
+4
View File
@@ -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
+168 -1
View File
@@ -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;
+51 -1
View File
@@ -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
+6 -5
View File
@@ -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`)
+3
View File
@@ -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);
}
});
});