diff --git a/CHANGELOG.md b/CHANGELOG.md index b9c9b180d..23833b6fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06. - Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06. - Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight. +- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06. - Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer. - Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer. - Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr. diff --git a/src/logging/console-capture.test.ts b/src/logging/console-capture.test.ts new file mode 100644 index 000000000..623587604 --- /dev/null +++ b/src/logging/console-capture.test.ts @@ -0,0 +1,85 @@ +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, + 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.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.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(); + }); +}); + +function tempLogPath() { + return path.join(os.tmpdir(), `clawdbot-log-${crypto.randomUUID()}.log`); +} + +function eioError() { + const err = new Error("EIO") as NodeJS.ErrnoException; + err.code = "EIO"; + return err; +} diff --git a/src/logging/console.ts b/src/logging/console.ts index 3d7c6ca00..88adbb1b1 100644 --- a/src/logging/console.ts +++ b/src/logging/console.ts @@ -89,7 +89,8 @@ function shouldSuppressConsoleMessage(message: string): boolean { } function isEpipeError(err: unknown): boolean { - return Boolean((err as { code?: string })?.code === "EPIPE"); + const code = (err as { code?: string })?.code; + return code === "EPIPE" || code === "EIO"; } /**