Files
open-design/apps/daemon/tests/mcp-fatal-errors.test.ts
lorenzozaneandGitHub 97e79f8a00 fix(daemon): report fatal MCP stdio process errors (#7340)
* fix(daemon): report fatal errors from the MCP stdio process

The `od mcp` stdio child could die without writing any diagnostic while
the main daemon stayed healthy, leaving clients with only a dropped
connection. Install guarded uncaughtException/unhandledRejection
handlers around the stdio lifetime so a fatal JS-level error is
reported to stderr and exits non-zero instead of disappearing silently;
the listeners are removed again whenever the stdio run ends, including
on startup failure.

* fix(daemon): exit the MCP process when the fatal report fails

reportAndExit wrote the stderr diagnostic before calling exit(1), so a
throwing write (String(reason), or ERR_STREAM_DESTROYED once the MCP client
has torn down the pipe) skipped the exit, and the follow-up exception was
swallowed by the exiting guard. Report inside a guarded try and leave through
a finally, draining stderr first so the diagnostic survives the pipe.
2026-09-22 06:08:17 +00:00

143 lines
4.7 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { _installMcpFatalErrorHandlers, runMcpStdio } from "../src/mcp.js";
describe("MCP stdio fatal error handlers", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("reports uncaught exceptions to stderr and exits unsuccessfully", () => {
const writeStderr = vi.fn();
const exit = vi.fn();
const dispose = _installMcpFatalErrorHandlers({ writeStderr, exit });
(
process as unknown as {
emit(name: string, ...values: unknown[]): boolean;
}
).emit("uncaughtException", new Error("stdio crashed"));
expect(writeStderr).toHaveBeenCalledWith(
expect.stringContaining("stdio crashed"),
);
expect(exit).toHaveBeenCalledWith(1);
dispose();
});
it("uses stderr for default reporting without writing protocol stdout", () => {
const stderrWrite = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
const stdoutWrite = vi
.spyOn(process.stdout, "write")
.mockImplementation(() => true);
const exit = vi.fn();
const dispose = _installMcpFatalErrorHandlers({ exit });
(
process as unknown as {
emit(name: string, ...values: unknown[]): boolean;
}
).emit("uncaughtException", new Error("default reporter crashed"));
expect(stderrWrite).toHaveBeenCalledWith(
expect.stringContaining("default reporter crashed"),
);
expect(stdoutWrite).not.toHaveBeenCalled();
expect(exit).toHaveBeenCalledWith(1);
dispose();
});
it("still exits when the stderr diagnostic write throws", () => {
const writeStderr = vi.fn(() => {
throw new Error("ERR_STREAM_DESTROYED");
});
const exit = vi.fn();
const dispose = _installMcpFatalErrorHandlers({ writeStderr, exit });
(
process as unknown as {
emit(name: string, ...values: unknown[]): boolean;
}
).emit("uncaughtException", new Error("stdio crashed"));
expect(writeStderr).toHaveBeenCalledTimes(1);
expect(exit).toHaveBeenCalledWith(1);
dispose();
});
it("still exits when the fatal reason cannot be stringified", () => {
const writeStderr = vi.fn();
const exit = vi.fn();
const dispose = _installMcpFatalErrorHandlers({ writeStderr, exit });
(
process as unknown as {
emit(name: string, ...values: unknown[]): boolean;
}
).emit("unhandledRejection", {
toString() {
throw new Error("unstringifiable reason");
},
});
expect(writeStderr).not.toHaveBeenCalled();
expect(exit).toHaveBeenCalledWith(1);
dispose();
});
it("reports non-Error unhandled rejections and removes both handlers", () => {
const writeStderr = vi.fn();
const exit = vi.fn();
const listenerCount = (event: string) =>
(process as unknown as { listeners(name: string): unknown[] }).listeners(
event,
).length;
const emitProcessEvent = (event: string, ...args: unknown[]) =>
(
process as unknown as {
emit(name: string, ...values: unknown[]): boolean;
}
).emit(event, ...args);
const exceptionListeners = listenerCount("uncaughtException");
const rejectionListeners = listenerCount("unhandledRejection");
const dispose = _installMcpFatalErrorHandlers({ writeStderr, exit });
expect(listenerCount("uncaughtException")).toBe(exceptionListeners + 1);
expect(listenerCount("unhandledRejection")).toBe(rejectionListeners + 1);
emitProcessEvent("unhandledRejection", "rejected value");
expect(writeStderr).toHaveBeenCalledWith(
expect.stringContaining("rejected value"),
);
expect(exit).toHaveBeenCalledWith(1);
dispose();
expect(listenerCount("uncaughtException")).toBe(exceptionListeners);
expect(listenerCount("unhandledRejection")).toBe(rejectionListeners);
});
it("cleans up handlers when startup initialization fails", async () => {
const exceptionListeners = process.listeners("uncaughtException").length;
const rejectionListeners = process.listeners("unhandledRejection").length;
vi.spyOn(globalThis, "setTimeout").mockImplementation(() => {
expect(process.listeners("uncaughtException").length).toBe(
exceptionListeners + 1,
);
expect(process.listeners("unhandledRejection").length).toBe(
rejectionListeners + 1,
);
throw new Error("idle controller initialization failed");
});
await expect(
runMcpStdio({ daemonUrl: "http://127.0.0.1:1234" }),
).rejects.toThrow("idle controller initialization failed");
expect(process.listeners("uncaughtException").length).toBe(
exceptionListeners,
);
expect(process.listeners("unhandledRejection").length).toBe(
rejectionListeners,
);
});
});