fix(server): handle codex spawn errors to prevent daemon crash
Spawning a missing/broken codex binary emits an async "error" event on
the child process. Without a listener, Node.js crashes the daemon with
no log entry. Add child.on("error") in CodexAppServerClient and global
uncaughtException/unhandledRejection handlers that log via pino before
exiting.
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
|
||||
describe("CodexAppServerAgentClient spawn error handling", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
test("listModels rejects gracefully when the codex binary does not exist", async () => {
|
||||
const client = new CodexAppServerAgentClient(logger, {
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: ["/nonexistent/codex-binary-that-does-not-exist"],
|
||||
},
|
||||
});
|
||||
|
||||
const uncaughtErrors: unknown[] = [];
|
||||
const onUncaught = (err: unknown) => {
|
||||
uncaughtErrors.push(err);
|
||||
};
|
||||
process.on("uncaughtException", onUncaught);
|
||||
|
||||
try {
|
||||
await expect(client.listModels()).rejects.toThrow();
|
||||
// Drain microtask queue to ensure no deferred uncaught errors
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(uncaughtErrors).toHaveLength(0);
|
||||
} finally {
|
||||
process.off("uncaughtException", onUncaught);
|
||||
}
|
||||
});
|
||||
|
||||
test("listPersistedAgents rejects gracefully when the codex binary does not exist", async () => {
|
||||
const client = new CodexAppServerAgentClient(logger, {
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: ["/nonexistent/codex-binary-that-does-not-exist"],
|
||||
},
|
||||
});
|
||||
|
||||
const uncaughtErrors: unknown[] = [];
|
||||
const onUncaught = (err: unknown) => {
|
||||
uncaughtErrors.push(err);
|
||||
};
|
||||
process.on("uncaughtException", onUncaught);
|
||||
|
||||
try {
|
||||
await expect(client.listPersistedAgents()).rejects.toThrow();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(uncaughtErrors).toHaveLength(0);
|
||||
} finally {
|
||||
process.off("uncaughtException", onUncaught);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -511,6 +511,18 @@ class CodexAppServerClient {
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
this.logger.error({ err }, "Codex app-server child process error");
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(err);
|
||||
}
|
||||
this.pending.clear();
|
||||
this.disposed = true;
|
||||
this.resolveExitPromise?.();
|
||||
this.resolveExitPromise = null;
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
const message =
|
||||
code === 0 && !signal
|
||||
|
||||
@@ -158,6 +158,16 @@ async function main() {
|
||||
|
||||
process.on("SIGTERM", () => beginShutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => beginShutdown("SIGINT"));
|
||||
|
||||
process.on("uncaughtException", (err) => {
|
||||
logger.fatal({ err }, "Uncaught exception — daemon crashing");
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
logger.fatal({ err: reason }, "Unhandled promise rejection — daemon crashing");
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
Reference in New Issue
Block a user