diff --git a/packages/coding-agent/src/dap/client.ts b/packages/coding-agent/src/dap/client.ts index 476e91584..8730ef2db 100644 --- a/packages/coding-agent/src/dap/client.ts +++ b/packages/coding-agent/src/dap/client.ts @@ -260,7 +260,7 @@ export class DapClient { // socket connect fails, we must not leak the detached adapter process. try { await waitForCondition(() => isUnixSocketReady(socketPath), timeoutMs, proc); - const { readable, writeSink, socket } = await connectSocket({ unix: socketPath }); + const { readable, writeSink, socket } = await connectSocket({ unix: socketPath }, timeoutMs); const client = new DapClient(adapter, cwd, proc, { readable, writeSink, socket }); proc.exited.then(() => client.#handleProcessExit()); void client.#startMessageReader(); @@ -924,10 +924,20 @@ function socketToSink(socket: Bun.Socket): DapWriteSink { }; } -/** Connect to a unix domain socket and return DAP transport streams. */ -async function connectSocket(options: { unix: string }): Promise { - const { promise, resolve } = Promise.withResolvers(); +/** + * Connect to a unix domain socket and return DAP transport streams. + * + * Rejects (rather than hanging) when the connect fails — a stat-ready but dead + * socket returns ECONNREFUSED, a socket removed between the readiness stat and + * the connect returns ENOENT, a permission mismatch returns EACCES — and when + * neither `open` nor an error arrives within `timeoutMs` (e.g. a TOCTOU stall). + * `#spawnSocketUnix`'s catch then kills the detached adapter instead of leaking + * it. Exported so tests can drive the reject path deterministically. + */ +export async function connectSocket(options: { unix: string }, timeoutMs: number): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); let streamController: ReadableStreamDefaultController; + let opened = false; const readable = new ReadableStream({ start(controller) { @@ -935,10 +945,21 @@ async function connectSocket(options: { unix: string }): Promise { + reject(new Error(`Timed out connecting to unix socket ${options.unix} after ${timeoutMs}ms`)); + }, timeoutMs); + // A late socket callback after settle is a no-op; clearing the timer just + // stops it from keeping the event loop alive past the connect. + void promise.then( + () => clearTimeout(timer), + () => clearTimeout(timer), + ); + Bun.connect({ unix: options.unix, socket: { open(socket) { + opened = true; resolve({ readable, writeSink: socketToSink(socket), @@ -949,6 +970,9 @@ async function connectSocket(options: { unix: string }): Promise { + // Bun.connect rejects the returned promise on synchronous connect + // failures (e.g. ENOENT) without always firing the `error` handler. + if (!opened) { + reject(err); + } }); return promise; 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 b8c99974d..e48af3c5f 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, waitForTcpServerListening } from "@oh-my-pi/pi-coding-agent/dap/client"; +import { connectSocket, 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, @@ -487,6 +487,20 @@ describe("DAP launch failure handling", () => { }); }); +describe("connectSocket unix transport", () => { + it("rejects instead of hanging when the unix socket cannot be connected", async () => { + // A path that stat would report as a socket but that no one listens on + // yields ECONNREFUSED/ENOENT from Bun.connect. Before the fix the error + // handler only errored the stream and the returned promise never settled, + // so `await connectSocket(...)` hung the launch forever. + const deadSocket = path.join(os.tmpdir(), `omp-dap-dead-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`); + const start = Date.now(); + await expect(connectSocket({ unix: deadSocket }, 5_000)).rejects.toThrow(); + // Must settle on the connect error, not linger until the timeout bound. + expect(Date.now() - start).toBeLessThan(2_000); + }); +}); + describe("DAP TCP transport resilience", () => { const TCP_ADAPTER_BASE: DapResolvedAdapter = { ...TEST_ADAPTER,