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:
+69
-1
@@ -4,19 +4,53 @@ import * as path from "path"
|
||||
|
||||
import { LOG_FILENAME } from "./plugin-identity"
|
||||
|
||||
const logFile = path.join(os.tmpdir(), LOG_FILENAME)
|
||||
const DEFAULT_MAX_LOG_FILE_SIZE_BYTES = 50 * 1024 * 1024
|
||||
const DEFAULT_MAX_LOG_FILE_BACKUPS = 2
|
||||
|
||||
let logFile = path.join(os.tmpdir(), LOG_FILENAME)
|
||||
let maxLogFileSizeBytes = DEFAULT_MAX_LOG_FILE_SIZE_BYTES
|
||||
let maxLogFileBackups = DEFAULT_MAX_LOG_FILE_BACKUPS
|
||||
|
||||
let buffer: string[] = []
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const FLUSH_INTERVAL_MS = 500
|
||||
const BUFFER_SIZE_LIMIT = 50
|
||||
|
||||
function rotateLogFileIfNeeded(): void {
|
||||
// Best-effort, single-process: not safe under concurrent writers from sibling
|
||||
// agents sharing the same tmpdir (worst case: one rotated backup is clobbered;
|
||||
// primary writes still succeed). Same TOCTOU profile as
|
||||
// src/openclaw/reply-listener-log.ts. All errors are swallowed because logging
|
||||
// itself must never throw — a corrupt rotation state is preferable to crashing
|
||||
// the agent over a temp-file rename failure.
|
||||
try {
|
||||
if (!fs.existsSync(logFile)) return
|
||||
const stats = fs.statSync(logFile)
|
||||
if (stats.size <= maxLogFileSizeBytes) return
|
||||
|
||||
const oldest = `${logFile}.${maxLogFileBackups}`
|
||||
if (fs.existsSync(oldest)) {
|
||||
fs.unlinkSync(oldest)
|
||||
}
|
||||
for (let i = maxLogFileBackups - 1; i >= 1; i -= 1) {
|
||||
const src = `${logFile}.${i}`
|
||||
const dst = `${logFile}.${i + 1}`
|
||||
if (fs.existsSync(src)) {
|
||||
fs.renameSync(src, dst)
|
||||
}
|
||||
}
|
||||
fs.renameSync(logFile, `${logFile}.1`)
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function flush(): void {
|
||||
if (buffer.length === 0) return
|
||||
const data = buffer.join("")
|
||||
buffer = []
|
||||
try {
|
||||
fs.appendFileSync(logFile, data)
|
||||
rotateLogFileIfNeeded()
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
@@ -46,3 +80,37 @@ export function log(message: string, data?: unknown): void {
|
||||
export function getLogFilePath(): string {
|
||||
return logFile
|
||||
}
|
||||
|
||||
interface LoggerTestOverrides {
|
||||
filePath?: string
|
||||
maxSizeBytes?: number
|
||||
maxBackups?: number
|
||||
}
|
||||
|
||||
/** @internal test-only seam */
|
||||
export function _setLoggerForTesting(overrides: LoggerTestOverrides): void {
|
||||
if (overrides.filePath !== undefined) logFile = overrides.filePath
|
||||
if (overrides.maxSizeBytes !== undefined) maxLogFileSizeBytes = overrides.maxSizeBytes
|
||||
if (overrides.maxBackups !== undefined) maxLogFileBackups = overrides.maxBackups
|
||||
}
|
||||
|
||||
/** @internal test-only seam */
|
||||
export function _resetLoggerForTesting(): void {
|
||||
logFile = path.join(os.tmpdir(), LOG_FILENAME)
|
||||
maxLogFileSizeBytes = DEFAULT_MAX_LOG_FILE_SIZE_BYTES
|
||||
maxLogFileBackups = DEFAULT_MAX_LOG_FILE_BACKUPS
|
||||
buffer = []
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer)
|
||||
flushTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal test-only seam: synchronously flush the buffer */
|
||||
export function _flushForTesting(): void {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer)
|
||||
flushTimer = null
|
||||
}
|
||||
flush()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user