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:
roboomp
2026-07-21 20:57:14 +00:00
parent 9fd6e97113
commit 07019e6c61
3 changed files with 194 additions and 7 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed JS/TS `debug` launches timing out on WSL2 with `networkingMode=mirrored` by waiting for the adapter's listening banner before connecting (avoiding the ghost-accept window on a just-released reservation port) and rejecting pending DAP requests and event waiters the moment the transport closes, so any transport failure surfaces as an immediate `DAP connection closed` error instead of a silent 30s timeout ([#6055](https://github.com/can1357/oh-my-pi/issues/6055)).
## [17.0.5] - 2026-07-18
### Added
+84 -7
View File
@@ -75,6 +75,10 @@ export class DapClient {
#reverseRequestHandlers = new Map<string, DapReverseRequestHandler>();
#adapterExited = false;
#pendingWriteExitRejectors = new Set<() => void>();
/** Rejectors for in-flight {@link waitForEvent} calls, woken when the
* transport closes so an event that can never arrive fails fast instead of
* waiting out its own timeout. */
#eventWaiterRejectors = new Set<(error: Error) => void>();
constructor(
adapter: DapResolvedAdapter,
@@ -188,12 +192,17 @@ export class DapClient {
});
try {
const { readable, writeSink, socket } = await waitForTcpTransport(
host,
port,
socketReadyTimeoutMs ?? SOCKET_READY_TIMEOUT_MS,
proc,
);
// Wait for the adapter to announce it is listening on `port` before
// connecting. Without this gate the first connect can land in the
// window where a just-released reservation port still accepts
// connections (WSL2 mirrored networking, issue #6055): the transport
// then binds to a ghost of the reservation listener instead of the
// adapter. Draining stdout here also avoids a pipe-buffer deadlock —
// in tcp mode the DAP protocol flows over the socket, so nothing else
// consumes the adapter's stdout.
const readyTimeoutMs = socketReadyTimeoutMs ?? SOCKET_READY_TIMEOUT_MS;
await waitForTcpServerListening(proc, port, readyTimeoutMs);
const { readable, writeSink, socket } = await waitForTcpTransport(host, port, readyTimeoutMs, proc);
const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket, port });
proc.exited.then(() => client.#handleProcessExit());
void client.#startMessageReader();
@@ -389,6 +398,7 @@ export class DapClient {
let timeout: NodeJS.Timeout | undefined;
const cleanup = () => {
unsubscribe();
this.#eventWaiterRejectors.delete(closeHandler);
if (timeout) clearTimeout(timeout);
if (signal) {
signal.removeEventListener("abort", abortHandler);
@@ -398,6 +408,10 @@ export class DapClient {
cleanup();
reject(signal?.reason instanceof Error ? signal.reason : new ToolAbortError());
};
const closeHandler = (error: Error) => {
cleanup();
reject(error);
};
const unsubscribe = this.onEvent(event, body => {
const typedBody = body as TBody;
if (predicate && !predicate(typedBody)) {
@@ -406,6 +420,7 @@ export class DapClient {
cleanup();
resolve(typedBody);
});
this.#eventWaiterRejectors.add(closeHandler);
if (signal) {
signal.addEventListener("abort", abortHandler, { once: true });
}
@@ -580,6 +595,7 @@ export class DapClient {
const framer = new MessageFramer(this.#messageBuffer);
let closeError: Error | undefined;
try {
while (true) {
const { done, value } = await reader.read();
@@ -619,13 +635,19 @@ export class DapClient {
}
}
} catch (error) {
this.#rejectPendingRequests(new Error(`DAP connection closed: ${toErrorMessage(error)}`));
closeError = new Error(`DAP connection closed: ${toErrorMessage(error)}`);
} finally {
// Persist any unparsed remainder so a restarted reader resumes mid-message.
this.#messageBuffer = framer.remainder();
reader.releaseLock();
this.#isReading = false;
}
// The transport is gone once the reader loop exits — on a thrown error
// or a clean stream end (a socket the peer dropped after we wrote, e.g.
// the WSL2-mirrored ghost-accept race in issue #6055). Fail every
// in-flight request and event waiter so callers see an immediate error
// instead of waiting out their own timeout.
this.#failConnection(closeError ?? new Error(`DAP connection closed: ${this.adapter.name} transport ended`));
}
#handleResponse(message: DapResponseMessage): void {
@@ -712,7 +734,19 @@ export class DapClient {
? `DAP adapter exited (code ${exitCode}): ${stderr}`
: `DAP adapter exited unexpectedly (code ${exitCode})`,
);
this.#failConnection(error);
}
/** Reject every in-flight request and wake every event waiter with `error`.
* Called when the transport dies (reader end, socket close, adapter exit)
* so nothing sits pending until its own timeout. */
#failConnection(error: Error): void {
this.#rejectPendingRequests(error);
const waiters = Array.from(this.#eventWaiterRejectors);
this.#eventWaiterRejectors.clear();
for (const reject of waiters) {
reject(error);
}
}
#rejectPendingRequests(error: Error): void {
@@ -826,6 +860,49 @@ async function waitForTcpTransport(
throw new Error(`TCP port ${host}:${port} was not ready after ${timeoutMs}ms`);
}
/**
* Give the adapter a chance to announce it is listening on `port` before the
* first connect. vscode-js-debug prints `Debug server listening at HOST:PORT`
* to stdout from inside its `listen()` callback; waiting for the port to appear
* there means we only connect once the child genuinely owns the reserved port,
* which closes the WSL2-mirrored ghost-accept window (issue #6055) at its root.
*
* Best-effort: resolves on the banner, on process exit, or on timeout — the
* subsequent connect loop and `proc.exitCode` checks surface real failures, so
* 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.
*/
async function waitForTcpServerListening(
proc: { stdout: ReadableStream<Uint8Array>; exitCode: number | null },
port: number,
timeoutMs: number,
): Promise<void> {
const ready = Promise.withResolvers<void>();
const portText = String(port);
void (async () => {
try {
const decoder = new TextDecoder();
let buffered = "";
for await (const chunk of proc.stdout) {
buffered += decoder.decode(chunk, { stream: true });
if (buffered.includes(portText)) {
ready.resolve();
}
// Keep only the tail relevant for banner matching so a chatty
// adapter cannot grow this buffer without bound.
if (buffered.length > 4096) {
buffered = buffered.slice(-1024);
}
}
} catch {
/* stdout errored — the connect loop surfaces the real failure */
}
ready.resolve();
})();
await Promise.race([ready.promise, Bun.sleep(timeoutMs)]);
}
interface SocketTransport {
readable: ReadableStream<Uint8Array>;
writeSink: DapWriteSink;
@@ -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({