Files
Moltbot/src/logging/console-capture.test.ts
Keshav Rao 2ef4ac08cf fix(gateway): handle async EPIPE on stdout/stderr during shutdown (#13414)
* fix(gateway): handle async EPIPE on stdout/stderr during shutdown

The console capture forward() wrapper catches synchronous EPIPE errors,
but when the receiving pipe closes during shutdown Node emits the error
asynchronously on the stream. Without a listener this becomes an
uncaught exception that crashes the gateway, causing macOS launchd to
permanently unload the service.

Add error listeners on process.stdout and process.stderr inside
enableConsoleCapture() that silently swallow EPIPE/EIO (matching the
existing isEpipeError helper) and re-throw anything else.

Closes #13367

* guard stream error listeners against repeated enableConsoleCapture() calls

Use a separate streamErrorHandlersInstalled flag in loggingState so that
test resets of consolePatched don't cause listener accumulation on
process.stdout/stderr.
2026-02-12 07:45:36 -06:00

159 lines
4.8 KiB
TypeScript

import crypto from "node:crypto";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
enableConsoleCapture,
resetLogger,
routeLogsToStderr,
setConsoleTimestampPrefix,
setLoggerOverride,
} from "../logging.js";
import { loggingState } from "./state.js";
type ConsoleSnapshot = {
log: typeof console.log;
info: typeof console.info;
warn: typeof console.warn;
error: typeof console.error;
debug: typeof console.debug;
trace: typeof console.trace;
};
let snapshot: ConsoleSnapshot;
beforeEach(() => {
snapshot = {
log: console.log,
info: console.info,
warn: console.warn,
error: console.error,
debug: console.debug,
trace: console.trace,
};
loggingState.consolePatched = false;
loggingState.forceConsoleToStderr = false;
loggingState.consoleTimestampPrefix = false;
loggingState.rawConsole = null;
resetLogger();
});
afterEach(() => {
console.log = snapshot.log;
console.info = snapshot.info;
console.warn = snapshot.warn;
console.error = snapshot.error;
console.debug = snapshot.debug;
console.trace = snapshot.trace;
loggingState.consolePatched = false;
loggingState.forceConsoleToStderr = false;
loggingState.consoleTimestampPrefix = false;
loggingState.rawConsole = null;
resetLogger();
setLoggerOverride(null);
vi.restoreAllMocks();
});
describe("enableConsoleCapture", () => {
it("swallows EIO from stderr writes", () => {
setLoggerOverride({ level: "info", file: tempLogPath() });
vi.spyOn(process.stderr, "write").mockImplementation(() => {
throw eioError();
});
routeLogsToStderr();
enableConsoleCapture();
expect(() => console.log("hello")).not.toThrow();
});
it("swallows EIO from original console writes", () => {
setLoggerOverride({ level: "info", file: tempLogPath() });
console.log = () => {
throw eioError();
};
enableConsoleCapture();
expect(() => console.log("hello")).not.toThrow();
});
it("prefixes console output with timestamps when enabled", () => {
setLoggerOverride({ level: "info", file: tempLogPath() });
const now = new Date("2026-01-17T18:01:02.000Z");
vi.useFakeTimers();
vi.setSystemTime(now);
const warn = vi.fn();
console.warn = warn;
setConsoleTimestampPrefix(true);
enableConsoleCapture();
console.warn("[EventQueue] Slow listener detected");
expect(warn).toHaveBeenCalledTimes(1);
const firstArg = String(warn.mock.calls[0]?.[0] ?? "");
expect(firstArg.startsWith("2026-01-17T18:01:02.000Z [EventQueue]")).toBe(true);
vi.useRealTimers();
});
it("suppresses discord EventQueue slow listener duplicates", () => {
setLoggerOverride({ level: "info", file: tempLogPath() });
const warn = vi.fn();
console.warn = warn;
enableConsoleCapture();
console.warn(
"[EventQueue] Slow listener detected: DiscordMessageListener took 12.3 seconds for event MESSAGE_CREATE",
);
expect(warn).not.toHaveBeenCalled();
});
it("does not double-prefix timestamps", () => {
setLoggerOverride({ level: "info", file: tempLogPath() });
const warn = vi.fn();
console.warn = warn;
setConsoleTimestampPrefix(true);
enableConsoleCapture();
console.warn("12:34:56 [exec] hello");
expect(warn).toHaveBeenCalledWith("12:34:56 [exec] hello");
});
it("leaves JSON output unchanged when timestamp prefix is enabled", () => {
setLoggerOverride({ level: "info", file: tempLogPath() });
const log = vi.fn();
console.log = log;
setConsoleTimestampPrefix(true);
enableConsoleCapture();
const payload = JSON.stringify({ ok: true });
console.log(payload);
expect(log).toHaveBeenCalledWith(payload);
});
it("swallows async EPIPE on stdout", () => {
setLoggerOverride({ level: "info", file: tempLogPath() });
enableConsoleCapture();
const epipe = new Error("write EPIPE") as NodeJS.ErrnoException;
epipe.code = "EPIPE";
expect(() => process.stdout.emit("error", epipe)).not.toThrow();
});
it("swallows async EPIPE on stderr", () => {
setLoggerOverride({ level: "info", file: tempLogPath() });
enableConsoleCapture();
const epipe = new Error("write EPIPE") as NodeJS.ErrnoException;
epipe.code = "EPIPE";
expect(() => process.stderr.emit("error", epipe)).not.toThrow();
});
it("rethrows non-EPIPE errors on stdout", () => {
setLoggerOverride({ level: "info", file: tempLogPath() });
enableConsoleCapture();
const other = new Error("EACCES") as NodeJS.ErrnoException;
other.code = "EACCES";
expect(() => process.stdout.emit("error", other)).toThrow("EACCES");
});
});
function tempLogPath() {
return path.join(os.tmpdir(), `openclaw-log-${crypto.randomUUID()}.log`);
}
function eioError() {
const err = new Error("EIO") as NodeJS.ErrnoException;
err.code = "EIO";
return err;
}