fix(shared): cap log file growth via size-based rotation

Refs #3772 (the rotation half — EPIPE shutdown-noise suppression
remains a separate follow-up).

`src/shared/logger.ts` appends every entry to `os.tmpdir()/oh-my-opencode.log`
via `fs.appendFileSync` with no size cap. On long-running or busy projects
the file grows into the multi-GB range — a real-world reproduction on one
machine showed a 4.5 GB `oh-my-opencode.log.1` accumulated from per-shutdown
noise across many sessions. Eats `%TEMP%` on Windows and `/tmp` on Unix.

Add size-based rotation inside the existing batched `flush()` path:

  oh-my-opencode.log    → oh-my-opencode.log.1
  oh-my-opencode.log.1  → oh-my-opencode.log.2 (oldest dropped)

Cap is 50 MB per file; worst-case on-disk footprint is therefore ~150 MB.
The check runs only inside `flush()`, so the cost is amortized over
`BUFFER_SIZE_LIMIT` (50 entries) or the 500 ms flush timer. All filesystem
ops stay wrapped in try/catch — logging must never throw — and a failed
rotation leaves existing on-disk state intact rather than crashing the
agent. Pattern mirrors `src/openclaw/reply-listener-log.ts`, but with two
backup slots instead of one to keep a usable history window for debugging.

No config knobs in this iteration. The issue proposes `logs.max_size_mb`
/ `logs.max_files`, but the defaults are reasonable and adding schema is
more surface area than the bug warrants. Easy to promote later (the
existing test seams already let callers override the cap).

Tests:
- `src/shared/logger.test.ts` (new): under-threshold no-rotate, over-
  threshold rotates to `.1`, repeated rotation evicts oldest, rotation-
  failure-doesn't-throw, default path lives under `os.tmpdir()`. Uses a
  `mock.module(...)` substring marker so `script/run-ci-tests.ts` routes
  the file to its own bun process — the logger module's singleton state
  otherwise gets contaminated by sibling tests that mock `./shared`.

Out of scope: suppressing specific shutdown-noise messages (EPIPE,
`unhandledRejection received during shutdown cleanup`). The rotation
cap bounds the disk impact regardless of which noise pattern is
generating volume; per-message suppression can stand on its own
merits in a follow-up.
This commit is contained in:
Chau Luu
2026-05-08 09:51:50 +00:00
parent 7285163c6f
commit 4713a90816
5 changed files with 301 additions and 5 deletions
+185
View File
@@ -0,0 +1,185 @@
/// <reference types="bun-types" />
// This test file mutates the logger module's singleton state. It must run in an
// isolated CI batch so that other test files mocking `./shared` (the barrel that
// re-exports this logger) cannot leak a no-op `log` into our imports. See
// script/run-ci-tests.ts — the `mock.module(` substring routes the file out of
// the shared batch.
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
mock.module("./logger-test-isolation", () => ({}))
import * as fs from "fs"
import * as os from "os"
import * as path from "path"
import {
_flushForTesting,
_resetLoggerForTesting,
_setLoggerForTesting,
getLogFilePath,
log,
} from "./logger"
const TEST_PREFIX = "oh-my-opencode-logger-test"
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), `${TEST_PREFIX}-`))
}
describe("#given the shared logger", () => {
let tempDir: string
let logFilePath: string
beforeEach(() => {
tempDir = makeTempDir()
logFilePath = path.join(tempDir, "log.txt")
})
afterEach(() => {
_resetLoggerForTesting()
fs.rmSync(tempDir, { recursive: true, force: true })
})
describe("#given log file size under threshold", () => {
test("#when log() is called and flushed #then the file is not rotated", () => {
_setLoggerForTesting({ filePath: logFilePath, maxSizeBytes: 1024, maxBackups: 2 })
log("small entry")
_flushForTesting()
expect(fs.existsSync(logFilePath)).toBe(true)
expect(fs.existsSync(`${logFilePath}.1`)).toBe(false)
})
})
describe("#given log file size over threshold", () => {
test("#when next flush runs #then the file rotates to .1 and a fresh file is created", () => {
_setLoggerForTesting({ filePath: logFilePath, maxSizeBytes: 100, maxBackups: 2 })
// Pre-fill the log file beyond the threshold so the next flush triggers rotation.
fs.writeFileSync(logFilePath, "x".repeat(200))
log("after rotation")
_flushForTesting()
// flush() appends first, then rotates — the in-flight batch becomes part
// of .1 so the post-flush primary is bounded to ≤ cap. The primary path
// is left absent after rotation; the next log() will re-create it on
// its next flush.
expect(fs.existsSync(`${logFilePath}.1`)).toBe(true)
const rotated = fs.readFileSync(`${logFilePath}.1`, "utf8")
expect(rotated).toContain("xxxx")
expect(rotated).toContain("after rotation")
expect(rotated.length).toBeGreaterThan(200)
expect(fs.existsSync(logFilePath)).toBe(false)
// A subsequent log() recreates the primary on its flush.
log("after recreation")
_flushForTesting()
expect(fs.existsSync(logFilePath)).toBe(true)
expect(fs.readFileSync(logFilePath, "utf8")).toContain("after recreation")
})
test("#when rotation happens repeatedly #then only maxBackups files are kept and the ladder shifts in order", () => {
_setLoggerForTesting({ filePath: logFilePath, maxSizeBytes: 100, maxBackups: 2 })
// First rotation
fs.writeFileSync(logFilePath, "first".repeat(50))
log("entry-A")
_flushForTesting()
expect(fs.existsSync(`${logFilePath}.1`)).toBe(true)
expect(fs.existsSync(`${logFilePath}.2`)).toBe(false)
// Second rotation
fs.writeFileSync(logFilePath, "second".repeat(50))
log("entry-B")
_flushForTesting()
expect(fs.existsSync(`${logFilePath}.1`)).toBe(true)
expect(fs.existsSync(`${logFilePath}.2`)).toBe(true)
// The previous .1 (containing entry-A) should now live at .2 — assert the
// ladder shifts in the expected direction so a regression that reverses
// the loop (.2 → .1) would fail here, not just silently keep two files.
expect(fs.readFileSync(`${logFilePath}.2`, "utf8")).toContain("entry-A")
expect(fs.readFileSync(`${logFilePath}.1`, "utf8")).toContain("entry-B")
// Third rotation should drop the oldest (.2) and shift .1 -> .2
fs.writeFileSync(logFilePath, "third".repeat(50))
log("entry-C")
_flushForTesting()
expect(fs.existsSync(`${logFilePath}.1`)).toBe(true)
expect(fs.existsSync(`${logFilePath}.2`)).toBe(true)
expect(fs.existsSync(`${logFilePath}.3`)).toBe(false)
// entry-A (oldest) was dropped; entry-B shifted from .1 to .2; entry-C is now .1.
expect(fs.readFileSync(`${logFilePath}.2`, "utf8")).toContain("entry-B")
expect(fs.readFileSync(`${logFilePath}.1`, "utf8")).toContain("entry-C")
// Total worst-case files on disk: primary + 2 backups
const survivors = fs
.readdirSync(tempDir)
.filter((name) => name.startsWith(path.basename(logFilePath)))
expect(survivors.length).toBeLessThanOrEqual(3)
})
test("#when log() is called past BUFFER_SIZE_LIMIT without explicit flush #then the inline flush path writes to disk", () => {
// BUFFER_SIZE_LIMIT in logger.ts is 50 — past that, log() flushes
// synchronously rather than scheduling a timer. A regression that drops
// the inline flush in favor of always scheduling would only surface here.
_setLoggerForTesting({ filePath: logFilePath, maxSizeBytes: 1024 * 1024, maxBackups: 2 })
for (let i = 0; i < 100; i += 1) {
log(`entry-${i}`)
}
// Note: no _flushForTesting() — relies on the inline flush at i=49 and i=99.
expect(fs.existsSync(logFilePath)).toBe(true)
const contents = fs.readFileSync(logFilePath, "utf8")
expect(contents).toContain("entry-0")
expect(contents).toContain("entry-99")
})
})
describe("#given filesystem failures during flush", () => {
test("#when the parent directory is missing #then append fails silently and does not throw", () => {
_setLoggerForTesting({
filePath: path.join(tempDir, "no-such-dir", "log.txt"),
maxSizeBytes: 10,
maxBackups: 2,
})
expect(() => {
log("entry")
_flushForTesting()
}).not.toThrow()
})
test("#when rotation fails partway through #then log() does not throw and primary keeps the entry", () => {
_setLoggerForTesting({ filePath: logFilePath, maxSizeBytes: 10, maxBackups: 2 })
// Pre-fill primary past the cap so rotateLogFileIfNeeded() actually triggers.
fs.writeFileSync(logFilePath, "x".repeat(200))
// Sabotage the oldest-eviction step: occupy the `.2` slot with a directory so
// unlinkSync inside rotateLogFileIfNeeded throws, exercising its inner catch.
// Portable: unlinkSync on a directory throws EISDIR on Linux/macOS and
// EPERM/EISDIR on Windows — both hit the catch.
fs.mkdirSync(`${logFilePath}.2`)
expect(() => {
log("entry")
_flushForTesting()
}).not.toThrow()
// appendFileSync succeeded; rotation failed silently; the primary still holds
// the new entry (rotation didn't move it) — confirms we reached the rotation
// path and recovered cleanly rather than short-circuiting on append failure.
expect(fs.readFileSync(logFilePath, "utf8")).toContain("entry")
})
})
describe("#given default configuration", () => {
test("#when getLogFilePath is called #then it points at os.tmpdir()", () => {
_resetLoggerForTesting()
expect(getLogFilePath().startsWith(os.tmpdir())).toBe(true)
})
})
})