diff --git a/packages/lsp-daemon/src/proxy.ts b/packages/lsp-daemon/src/proxy.ts index 6ae01d978be..49bdde4eefc 100644 --- a/packages/lsp-daemon/src/proxy.ts +++ b/packages/lsp-daemon/src/proxy.ts @@ -32,16 +32,35 @@ export async function runMcpStdioProxy(options: ProxyOptions = {}): Promise { - process.stderr.write(`[lsp-daemon] proxy error: ${error instanceof Error ? error.message : String(error)}\n`); - }, - }); + // Prevent orphan: force-exit when stdin closes (parent died) or the server + // loop returns, since dangling daemon sockets can keep the event loop alive. + const isRealStdin = input === process.stdin; + if (isRealStdin) { + input.on("close", () => { + process.exitCode = 0; + setTimeout(() => process.exit(0), 500).unref(); + }); + } + + try { + await runJsonRpcStdioServer({ + input, + output, + idleTimeoutMs: 0, + handler: handleProxyRequest, + handlerOptions: callOptions, + onHandlerError: (error: unknown) => { + process.stderr.write(`[lsp-daemon] proxy error: ${error instanceof Error ? error.message : String(error)}\n`); + }, + }); + } catch (error: unknown) { + // Stream destroyed (parent killed) → ERR_STREAM_PREMATURE_CLOSE; expected. + if (!isPrematureClose(error)) throw error; + } + + if (isRealStdin) { + process.exit(0); + } } async function handleProxyRequest(parsed: unknown, callOptions: CallToolOptions): Promise { @@ -52,6 +71,10 @@ async function handleProxyRequest(parsed: unknown, callOptions: CallToolOptions) return successResponse(toolCall.id, { content: result.content, isError: result.isError ?? false, details: result.details }); } +function isPrematureClose(error: unknown): boolean { + return error instanceof Error && (error as NodeJS.ErrnoException).code === "ERR_STREAM_PREMATURE_CLOSE"; +} + function asToolCall(parsed: unknown): ToolCall | null { if (!isPlainRecord(parsed) || parsed["method"] !== "tools/call") return null; const params = parsed["params"]; diff --git a/packages/lsp-daemon/test/proxy.test.ts b/packages/lsp-daemon/test/proxy.test.ts index 89d9b6aedab..041d3a03f10 100644 --- a/packages/lsp-daemon/test/proxy.test.ts +++ b/packages/lsp-daemon/test/proxy.test.ts @@ -128,6 +128,26 @@ describe("mcp stdio proxy", () => { } }); + it("#given a live proxy #when stdin is destroyed mid-session #then the proxy resolves instead of hanging", async () => { + const paths = tempPaths(); + const input = new PassThrough(); + const out: string[] = []; + const proxy = runMcpStdioProxy({ + input, + output: collectingWritable(out), + paths, + ensure: noSpawn, + }); + + input.write(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} })}\n`); + await new Promise((r) => setTimeout(r, 50)); + input.destroy(); + + await proxy; + const responses = parseResponses(out); + expect((responses[0]?.["result"] as { serverInfo?: unknown }).serverInfo).toBeDefined(); + }); + it("#given an unreachable daemon #when several tools are proxied #then each gets a structured error and the proxy keeps serving", async () => { const paths = tempPaths(); const out: string[] = [];