diff --git a/packages/coding-agent/src/dap/client.ts b/packages/coding-agent/src/dap/client.ts index 8cb6f3864..476e91584 100644 --- a/packages/coding-agent/src/dap/client.ts +++ b/packages/coding-agent/src/dap/client.ts @@ -872,8 +872,10 @@ async function waitForTcpTransport( * an adapter that never prints a banner still proceeds (just without the gate). * Also drains stdout for the wait's duration: in tcp mode the DAP protocol * flows over the socket, so nothing else consumes the adapter's stdout. + * + * Exported so tests can drive the gate deterministically with a synthetic stdout. */ -async function waitForTcpServerListening( +export async function waitForTcpServerListening( proc: { stdout: ReadableStream; exitCode: number | null }, port: number, timeoutMs: number, diff --git a/packages/coding-agent/test/debug/dap-launch-failures.test.ts b/packages/coding-agent/test/debug/dap-launch-failures.test.ts index b2d6cb98f..b8c99974d 100644 --- a/packages/coding-agent/test/debug/dap-launch-failures.test.ts +++ b/packages/coding-agent/test/debug/dap-launch-failures.test.ts @@ -4,7 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import * as dapModule from "@oh-my-pi/pi-coding-agent/dap"; -import { DapClient } from "@oh-my-pi/pi-coding-agent/dap/client"; +import { DapClient, waitForTcpServerListening } from "@oh-my-pi/pi-coding-agent/dap/client"; import { DapSessionManager } from "@oh-my-pi/pi-coding-agent/dap/session"; import type { DapCapabilities, @@ -506,27 +506,6 @@ const server = Bun.listen({ hostname: "127.0.0.1", port, socket: { }}); console.log("Debug server listening at 127.0.0.1:" + port); await Bun.sleep(60_000); -`; - - // Adapter that binds only after a delay, prints the listening banner from - // inside its listen callback, then answers initialize over the socket. - const DELAYED_BANNER_ADAPTER = ` -const port = Number(process.argv[2]); -await Bun.sleep(150); -const server = Bun.listen({ hostname: "127.0.0.1", port, socket: { - open(){}, - data(s, data){ - const text = Buffer.from(data).toString(); - const m = /Content-Length: (\\d+)\\r\\n\\r\\n([\\s\\S]*)/.exec(text); - if (!m) return; - const req = JSON.parse(m[2].slice(0, Number(m[1]))); - const resp = JSON.stringify({ seq: 1, type: "response", request_seq: req.seq, success: true, command: req.command, body: { supportsConfigurationDoneRequest: true } }); - s.write(\`Content-Length: \${Buffer.byteLength(resp)}\\r\\n\\r\\n\${resp}\`); - }, - close(){}, error(){}, -}}); -console.log("Debug server listening at 127.0.0.1:" + port); -await Bun.sleep(60_000); `; async function withTcpAdapter( @@ -552,44 +531,75 @@ await Bun.sleep(60_000); await withTcpAdapter(GHOST_ADAPTER, async (adapter, cwd) => { const client = await DapClient.spawn({ adapter, cwd, socketReadyTimeoutMs: 5_000 }); try { - const start = Date.now(); - // The ghost socket ends the read stream cleanly. Before the fix the - // request sat pending until its own 5s timeout; now the reader wakes - // it with a descriptive connection-closed error well before then. - await expect(client.sendRequest("initialize", {}, undefined, 5_000)).rejects.toThrow( + // The ghost socket ends the read stream cleanly. The reader must wake + // the pending request with a connection-closed error; a wake regression + // rejects with `DAP request initialize timed out` instead. + await expect(client.sendRequest("initialize", {}, undefined, 60_000)).rejects.toThrow( /DAP connection closed/, ); - expect(Date.now() - start).toBeLessThan(2_000); } finally { await client.dispose(); } }); - }); + }, 20_000); it("wakes an event waiter when the transport closes instead of waiting out its timeout", async () => { await withTcpAdapter(GHOST_ADAPTER, async (adapter, cwd) => { const client = await DapClient.spawn({ adapter, cwd, socketReadyTimeoutMs: 5_000 }); try { - const start = Date.now(); - await expect(client.waitForEvent("stopped", undefined, undefined, 5_000)).rejects.toThrow( + // A close must wake the event waiter with the connection error rather + // than letting it wait out its own timeout. + await expect(client.waitForEvent("stopped", undefined, undefined, 60_000)).rejects.toThrow( /DAP connection closed/, ); - expect(Date.now() - start).toBeLessThan(2_000); } finally { await client.dispose(); } }); - }); + }, 20_000); - it("defers the first connect until the adapter announces its listening port", async () => { - await withTcpAdapter(DELAYED_BANNER_ADAPTER, async (adapter, cwd) => { - const client = await DapClient.spawn({ adapter, cwd, socketReadyTimeoutMs: 5_000 }); - try { - const caps = await client.initialize({ clientID: "omp", adapterID: "js-debug-adapter" }, undefined, 5_000); - expect(caps).toMatchObject({ supportsConfigurationDoneRequest: true }); - } finally { - await client.dispose(); - } + // Deterministic gate contract: the client must not open its first connect + // until the adapter's stdout mentions the reserved port. Driven with a + // synthetic stdout stream — no subprocess, no wall-clock dependence. + describe("waitForTcpServerListening", () => { + function fakeStdout() { + let controller!: ReadableStreamDefaultController; + const stdout = new ReadableStream({ + start(c) { + controller = c; + }, + }); + const encoder = new TextEncoder(); + return { + proc: { stdout, exitCode: null }, + push: (text: string) => controller.enqueue(encoder.encode(text)), + end: () => controller.close(), + }; + } + + it("holds the gate until stdout announces the port, even split across chunks", async () => { + const { proc, push, end } = fakeStdout(); + let open = false; + const gate = waitForTcpServerListening(proc, 43210, 60_000).then(() => { + open = true; + }); + push("Starting inspector...\n"); + push("Debug server listening at 127.0.0.1:43"); + // Single event-loop turn so the gate's reader consumes the queued + // chunks — a scheduling flush, not a tuned wall-clock delay. + await Bun.sleep(0); + expect(open).toBe(false); + push("210\n"); + await gate; + end(); + }); + + it("opens the gate when stdout ends without a banner so the connect loop surfaces the real failure", async () => { + const { proc, push, end } = fakeStdout(); + const gate = waitForTcpServerListening(proc, 43210, 60_000); + push("adapter crashed before binding\n"); + end(); + await gate; }); }); });