fix(coding-agent/dap): bound writeMessage flush and clean up leaked adapter processes

- Replace the module-level writeMessage with a private DapClient.#writeMessage
  that races sink.flush() against a 30 s cap and this.proc.exited, disposes the
  client, and rethrows on either failure. A wedged adapter stdin previously
  blocked writeMessage forever.
- sendRequest fires the write in the background and attaches a passive
  promise.catch guard so the request timer's rejection cannot become an
  unhandled promise before the caller's await subscribes. This also lets the
  caller's timeoutMs win over a hung flush instead of waiting the full 30 s
  write cap.
- #spawnSocketUnix and #spawnSocketClientAddr wrap the readiness / connect
  race in try/catch that kills proc before rethrowing. Add a private
  socketReadyTimeoutMs hook on DapSpawnOptions so tests do not have to wait
  the full 10 s socket cap.

Regression tests cover all three failure modes: a wedged stdin flush must
reject sendRequest at the caller timeout with no unhandledRejection, and the
Unix + TCP client-addr spawn paths must terminate the detached adapter
process when the socket never appears.

Fixes #4233
This commit is contained in:
roboomp
2026-07-02 08:43:26 +00:00
parent 0ea6ea630b
commit 23cbeb49ff
3 changed files with 245 additions and 36 deletions
@@ -364,6 +364,129 @@ describe("DAP launch failure handling", () => {
await removeWithRetries(cwd);
}
});
it("times out promptly and does not emit an unhandled rejection when the stdin flush is wedged", async () => {
const procExited = Promise.withResolvers<number>();
const proc = {
exited: procExited.promise,
exitCode: null,
stdin: { write: () => 0, flush: () => undefined },
stdout: new ReadableStream<Uint8Array>(),
stderr: new ReadableStream<Uint8Array>(),
peekStderr: () => "",
kill: () => {
procExited.resolve(-1);
return true;
},
} as unknown as DapClientState["proc"];
// flush() returns a promise that never resolves — models an adapter whose
// stdin has stopped draining (the failure mode in issue #4233).
const writeSink = {
write: (_data: string | Uint8Array) => 0,
flush: () => new Promise<number>(() => {}),
};
const readable = new ReadableStream<Uint8Array>();
const client = new DapClient(TEST_ADAPTER, process.cwd(), proc, { readable, writeSink });
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => unhandled.push(reason);
process.on("unhandledRejection", onUnhandled);
try {
const start = Date.now();
await expect(client.sendRequest("initialize", {}, undefined, 50)).rejects.toThrow(/timed out/i);
// Must respect the caller's timeoutMs, not the internal 30 s write cap.
expect(Date.now() - start).toBeLessThan(500);
// Let any queued unhandled-rejection microtask fire.
await Bun.sleep(50);
expect(unhandled).toEqual([]);
} finally {
process.off("unhandledRejection", onUnhandled);
// Let writeMessage's exit-guard resolve so no promise leaks past the test.
await client.dispose();
await Bun.sleep(20);
}
});
it("kills the detached adapter process when the Unix socket never appears (Linux)", async () => {
if (process.platform !== "linux") return;
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-unix-leak-"));
try {
const adapterPath = path.join(cwd, "wedged-unix-adapter.mjs");
const pidFilePath = path.join(cwd, "adapter.pid");
// Adapter records its pid and stays alive without ever creating the
// socket, forcing #spawnSocketUnix's readiness wait to time out.
await fs.writeFile(
adapterPath,
`await Bun.write(${JSON.stringify(pidFilePath)}, String(process.pid));\nawait Bun.sleep(60_000);\n`,
);
const adapter: DapResolvedAdapter = {
...TEST_ADAPTER,
name: "wedged-unix-adapter",
command: process.execPath,
args: [adapterPath],
resolvedCommand: process.execPath,
connectMode: "socket",
};
await expect(DapClient.spawn({ adapter, cwd, socketReadyTimeoutMs: 300 })).rejects.toThrow(
/Socket not ready/,
);
// Wait for the kill signal to propagate to the detached adapter.
await Bun.sleep(500);
const adapterPid = Number(await Bun.file(pidFilePath).text());
expect(Number.isFinite(adapterPid)).toBe(true);
let alive = true;
try {
process.kill(adapterPid, 0);
} catch {
alive = false;
}
expect(alive).toBe(false);
} finally {
await removeWithRetries(cwd);
}
});
it("kills the detached adapter process when it never dials back on the TCP client-addr path", async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
try {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-tcp-leak-"));
try {
const adapterPath = path.join(cwd, "wedged-tcp-adapter.mjs");
const pidFilePath = path.join(cwd, "adapter.pid");
await fs.writeFile(
adapterPath,
`await Bun.write(${JSON.stringify(pidFilePath)}, String(process.pid));\nawait Bun.sleep(60_000);\n`,
);
const adapter: DapResolvedAdapter = {
...TEST_ADAPTER,
name: "wedged-tcp-adapter",
command: process.execPath,
args: [adapterPath],
resolvedCommand: process.execPath,
connectMode: "socket",
};
await expect(DapClient.spawn({ adapter, cwd, socketReadyTimeoutMs: 300 })).rejects.toThrow(
/did not connect within/,
);
await Bun.sleep(500);
const adapterPid = Number(await Bun.file(pidFilePath).text());
expect(Number.isFinite(adapterPid)).toBe(true);
let alive = true;
try {
process.kill(adapterPid, 0);
} catch {
alive = false;
}
expect(alive).toBe(false);
} finally {
await removeWithRetries(cwd);
}
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
}
});
});
describe("DebugTool launch validation", () => {