Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 33 additions & 10 deletions packages/lsp-daemon/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,35 @@ export async function runMcpStdioProxy(options: ProxyOptions = {}): Promise<void
const context = options.context ?? currentRequestContext();
const callOptions: CallToolOptions = { paths, context, ...(options.ensure ? { ensure: options.ensure } : {}) };

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`);
},
});
// 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<JsonRpcResponse | undefined> {
Expand All @@ -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"];
Expand Down
20 changes: 20 additions & 0 deletions packages/lsp-daemon/test/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
Loading