mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* 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.
143 lines
4.7 KiB
TypeScript
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,
|
|
);
|
|
});
|
|
});
|