test(dap): made tcp transport tests deterministic

- Replaced the subprocess-based delayed-banner test with in-process
  tests of the exported waitForTcpServerListening gate (banner split
  across chunks, stdout end without banner).
- Dropped wall-clock elapsed assertions from the ghost-socket tests;
  the connection-closed rejection message already distinguishes the
  wake path from a request timeout.
This commit is contained in:
can1357
2026-07-23 01:22:24 +02:00
parent 1db0d71825
commit 2f5a67694a
2 changed files with 55 additions and 43 deletions
+3 -1
View File
@@ -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<Uint8Array>; exitCode: number | null },
port: number,
timeoutMs: number,
@@ -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<Uint8Array>;
const stdout = new ReadableStream<Uint8Array>({
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;
});
});
});