fix(dap): close tcp connect race and wake pending requests on transport end
#spawnTcp reserved a port, released it, spawned the adapter, then immediately connected. On WSL2 with networkingMode=mirrored the Windows relay keeps accepting connections to the just-released reservation port for tens of ms, so the first connect bound a ghost socket instead of js-debug. Gate the connect on the adapter announcing its listening port on stdout (as vscode-js-debug does from inside its listen callback) so we only connect once the child owns the port; this also drains stdout, which nothing else consumes in tcp mode. Separately, the message reader only rejected pending requests from its catch, so a clean stream end (the ghost socket dropped after we wrote initialize) left requests and event waiters pending until their own timeout. Route both the reader end and adapter exit through a single #failConnection that rejects pending requests and wakes event waiters, turning every transport failure into an immediate "DAP connection closed" error instead of a silent 30s timeout. Fixes #6055
This commit is contained in:
@@ -487,6 +487,112 @@ describe("DAP launch failure handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("DAP TCP transport resilience", () => {
|
||||
const TCP_ADAPTER_BASE: DapResolvedAdapter = {
|
||||
...TEST_ADAPTER,
|
||||
name: "js-debug-adapter",
|
||||
command: process.execPath,
|
||||
resolvedCommand: process.execPath,
|
||||
connectMode: "tcp",
|
||||
};
|
||||
|
||||
// Adapter that binds the reserved port, accepts the first connection, then
|
||||
// drops it after 30ms without answering — the WSL2-mirrored ghost socket.
|
||||
const GHOST_ADAPTER = `
|
||||
const port = Number(process.argv[2]);
|
||||
const server = Bun.listen({ hostname: "127.0.0.1", port, socket: {
|
||||
open(s){ setTimeout(() => { try { s.end(); } catch {} }, 30); },
|
||||
data(){}, close(){}, error(){},
|
||||
}});
|
||||
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(
|
||||
source: string,
|
||||
run: (adapter: DapResolvedAdapter, cwd: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-tcp-"));
|
||||
const adapterPath = path.join(cwd, "tcp-adapter.mjs");
|
||||
await fs.writeFile(adapterPath, source);
|
||||
const adapter: DapResolvedAdapter = {
|
||||
...TCP_ADAPTER_BASE,
|
||||
args: [adapterPath, "${port}", "127.0.0.1"],
|
||||
};
|
||||
try {
|
||||
await run(adapter, cwd);
|
||||
} finally {
|
||||
await removeWithRetries(cwd);
|
||||
}
|
||||
}
|
||||
|
||||
it("rejects a pending request fast when the transport closes cleanly without answering", async () => {
|
||||
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(
|
||||
/DAP connection closed/,
|
||||
);
|
||||
expect(Date.now() - start).toBeLessThan(2_000);
|
||||
} finally {
|
||||
await client.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
/DAP connection closed/,
|
||||
);
|
||||
expect(Date.now() - start).toBeLessThan(2_000);
|
||||
} finally {
|
||||
await client.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DebugTool launch validation", () => {
|
||||
it("rejects directory programs when the selected adapter cannot debug a directory", async () => {
|
||||
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue({
|
||||
|
||||
Reference in New Issue
Block a user