From 3ba9821254a5eb6feaf8234a9bf55d3ba88f4413 Mon Sep 17 00:00:00 2001 From: Travis Date: Fri, 23 Jan 2026 21:30:57 -0500 Subject: [PATCH] Logging: guard console settings recursion --- src/logging/console-settings.test.ts | 83 ++++++++++++++++++++++++++++ src/logging/console.ts | 19 +++++-- src/logging/state.ts | 1 + 3 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 src/logging/console-settings.test.ts diff --git a/src/logging/console-settings.test.ts b/src/logging/console-settings.test.ts new file mode 100644 index 000000000..20956e685 --- /dev/null +++ b/src/logging/console-settings.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./config.js", () => ({ + readLoggingConfig: () => undefined, +})); + +vi.mock("./logger.js", () => ({ + getLogger: () => ({ + trace: () => {}, + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + fatal: () => {}, + }), +})); + +let loadConfigCalls = 0; +vi.mock("node:module", async () => { + const actual = await vi.importActual("node:module"); + return { + ...actual, + createRequire: (url: string | URL) => { + const realRequire = actual.createRequire(url); + return (specifier: string) => { + if (specifier.endsWith("config.js")) { + return { + loadConfig: () => { + loadConfigCalls += 1; + if (loadConfigCalls > 5) { + return {}; + } + console.error("config load failed"); + return {}; + }, + }; + } + return realRequire(specifier); + }; + }, + }; +}); +let originalIsTty: boolean | undefined; + +beforeEach(() => { + loadConfigCalls = 0; + vi.resetModules(); + originalIsTty = process.stdout.isTTY; + Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true }); +}); + +afterEach(() => { + Object.defineProperty(process.stdout, "isTTY", { value: originalIsTty, configurable: true }); + vi.restoreAllMocks(); +}); + +async function loadLogging() { + const logging = await import("../logging.js"); + const state = await import("./state.js"); + state.loggingState.cachedConsoleSettings = null; + return { logging, state }; +} + +describe("getConsoleSettings", () => { + it("does not recurse when loadConfig logs during resolution", async () => { + const { logging } = await loadLogging(); + logging.setConsoleTimestampPrefix(true); + logging.enableConsoleCapture(); + const { getConsoleSettings } = logging; + getConsoleSettings(); + expect(loadConfigCalls).toBe(1); + }); + + it("skips config fallback during re-entrant resolution", async () => { + const { logging, state } = await loadLogging(); + state.loggingState.resolvingConsoleSettings = true; + logging.setConsoleTimestampPrefix(true); + logging.enableConsoleCapture(); + logging.getConsoleSettings(); + expect(loadConfigCalls).toBe(0); + state.loggingState.resolvingConsoleSettings = false; + }); +}); diff --git a/src/logging/console.ts b/src/logging/console.ts index 68c3d2066..a9d19aae6 100644 --- a/src/logging/console.ts +++ b/src/logging/console.ts @@ -35,13 +35,20 @@ function resolveConsoleSettings(): ConsoleSettings { let cfg: ClawdbotConfig["logging"] | undefined = (loggingState.overrideSettings as LoggerSettings | null) ?? readLoggingConfig(); if (!cfg) { - try { - const loaded = requireConfig("../config/config.js") as { - loadConfig?: () => ClawdbotConfig; - }; - cfg = loaded.loadConfig?.().logging; - } catch { + if (loggingState.resolvingConsoleSettings) { cfg = undefined; + } else { + loggingState.resolvingConsoleSettings = true; + try { + const loaded = requireConfig("../config/config.js") as { + loadConfig?: () => ClawdbotConfig; + }; + cfg = loaded.loadConfig?.().logging; + } catch { + cfg = undefined; + } finally { + loggingState.resolvingConsoleSettings = false; + } } } const level = normalizeConsoleLevel(cfg?.consoleLevel); diff --git a/src/logging/state.ts b/src/logging/state.ts index 77badf8ea..4c0c96615 100644 --- a/src/logging/state.ts +++ b/src/logging/state.ts @@ -7,6 +7,7 @@ export const loggingState = { forceConsoleToStderr: false, consoleTimestampPrefix: false, consoleSubsystemFilter: null as string[] | null, + resolvingConsoleSettings: false, rawConsole: null as { log: typeof console.log; info: typeof console.info;