From 9f8bbaa461e7523e846f934e14e5e3ea1134498d Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 16 Jun 2026 00:30:04 +0800 Subject: [PATCH 1/4] fix: tolerate busy log rotation --- .changeset/steady-log-rotation.md | 6 ++++ packages/agent-core/src/logging/sinks.ts | 24 ++++++++++++--- .../agent-core/test/logging/sinks.test.ts | 30 ++++++++++++++++++- 3 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 .changeset/steady-log-rotation.md diff --git a/.changeset/steady-log-rotation.md b/.changeset/steady-log-rotation.md new file mode 100644 index 000000000..48d339fdd --- /dev/null +++ b/.changeset/steady-log-rotation.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Avoid surfacing diagnostic log rotation failures when a shared log file is temporarily busy. diff --git a/packages/agent-core/src/logging/sinks.ts b/packages/agent-core/src/logging/sinks.ts index ef23eb101..0f5481c46 100644 --- a/packages/agent-core/src/logging/sinks.ts +++ b/packages/agent-core/src/logging/sinks.ts @@ -6,6 +6,7 @@ import { syncDir } from '#/utils/fs'; export const PENDING_MAX = 1000; const STDERR_NOTICE_INTERVAL_MS = 30_000; +const TRANSIENT_ROTATION_ERROR_CODES = new Set(['EACCES', 'EBUSY', 'EPERM']); class AsyncSerialQueue { private tail: Promise = Promise.resolve(); @@ -168,28 +169,35 @@ export class RotatingFileSink implements Sink { private async rotate(): Promise { const { path, files } = this.options; + this.directorySynced = false; for (let i = files - 2; i >= 1; i--) { const from = `${path}.${i}`; const to = `${path}.${i + 1}`; try { await rename(from, to); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + if (isNotFoundError(error)) continue; + if (isTransientRotationError(error)) return; + throw error; } } try { await rename(path, `${path}.1`); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + if (isNotFoundError(error)) { + this.currentBytes = 0; + return; + } + if (isTransientRotationError(error)) return; + throw error; } // last archive may be evicted; ensure we don't keep > files try { await unlink(`${path}.${files}`); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + if (!isNotFoundError(error) && !isTransientRotationError(error)) throw error; } this.currentBytes = 0; - this.directorySynced = false; } private async statSize(p: string): Promise { @@ -219,3 +227,11 @@ export class RotatingFileSink implements Sink { } catch {} } } + +function isNotFoundError(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === 'ENOENT'; +} + +function isTransientRotationError(error: unknown): boolean { + return TRANSIENT_ROTATION_ERROR_CODES.has((error as NodeJS.ErrnoException).code ?? ''); +} diff --git a/packages/agent-core/test/logging/sinks.test.ts b/packages/agent-core/test/logging/sinks.test.ts index 48a199f21..bbd139feb 100644 --- a/packages/agent-core/test/logging/sinks.test.ts +++ b/packages/agent-core/test/logging/sinks.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'pathe'; @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { PENDING_MAX, RotatingFileSink } from '#/logging/sinks'; let workDir: string; +const itPosix = process.platform === 'win32' ? it.skip : it; beforeEach(async () => { workDir = await mkdtemp(join(tmpdir(), 'logger-sinks-')); @@ -78,6 +79,33 @@ describe('RotatingFileSink', () => { } }); + itPosix('keeps writing when rotation is temporarily blocked', async () => { + const dir = join(workDir, 'locked'); + const path = join(dir, 'app.log'); + await mkdir(dir); + await writeFile(path, 'seed\n', 'utf-8'); + await chmod(dir, 0o555); + + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const sink = new RotatingFileSink({ path, maxBytes: 8, files: 2 }); + sink.enqueue('after\n'); + + expect(await sink.flush()).toBe(true); + await chmod(dir, 0o755); + + const text = await readFile(path, 'utf-8'); + expect(text).toContain('seed\n'); + expect(text).toContain('after\n'); + expect( + stderrSpy.mock.calls.some((c) => String(c[0]).includes('[logger] write failed')), + ).toBe(false); + } finally { + stderrSpy.mockRestore(); + await chmod(dir, 0o755).catch(() => {}); + } + }); + it('drops oldest when pending overflows', async () => { const path = join(workDir, 'app.log'); const sink = new RotatingFileSink({ path, maxBytes: 1_000_000, files: 2 }); From cdb128514c45cc71bae06e5bff8d42b3dabd374d Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 16 Jun 2026 00:36:03 +0800 Subject: [PATCH 2/4] test: use recognized skip helper for log rotation --- .../agent-core/test/logging/sinks.test.ts | 56 ++++++++++--------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/packages/agent-core/test/logging/sinks.test.ts b/packages/agent-core/test/logging/sinks.test.ts index bbd139feb..c4bde390c 100644 --- a/packages/agent-core/test/logging/sinks.test.ts +++ b/packages/agent-core/test/logging/sinks.test.ts @@ -7,7 +7,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { PENDING_MAX, RotatingFileSink } from '#/logging/sinks'; let workDir: string; -const itPosix = process.platform === 'win32' ? it.skip : it; beforeEach(async () => { workDir = await mkdtemp(join(tmpdir(), 'logger-sinks-')); @@ -79,32 +78,35 @@ describe('RotatingFileSink', () => { } }); - itPosix('keeps writing when rotation is temporarily blocked', async () => { - const dir = join(workDir, 'locked'); - const path = join(dir, 'app.log'); - await mkdir(dir); - await writeFile(path, 'seed\n', 'utf-8'); - await chmod(dir, 0o555); - - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - try { - const sink = new RotatingFileSink({ path, maxBytes: 8, files: 2 }); - sink.enqueue('after\n'); - - expect(await sink.flush()).toBe(true); - await chmod(dir, 0o755); - - const text = await readFile(path, 'utf-8'); - expect(text).toContain('seed\n'); - expect(text).toContain('after\n'); - expect( - stderrSpy.mock.calls.some((c) => String(c[0]).includes('[logger] write failed')), - ).toBe(false); - } finally { - stderrSpy.mockRestore(); - await chmod(dir, 0o755).catch(() => {}); - } - }); + it.skipIf(process.platform === 'win32')( + 'keeps writing when rotation is temporarily blocked', + async () => { + const dir = join(workDir, 'locked'); + const path = join(dir, 'app.log'); + await mkdir(dir); + await writeFile(path, 'seed\n', 'utf-8'); + await chmod(dir, 0o555); + + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const sink = new RotatingFileSink({ path, maxBytes: 8, files: 2 }); + sink.enqueue('after\n'); + + expect(await sink.flush()).toBe(true); + await chmod(dir, 0o755); + + const text = await readFile(path, 'utf-8'); + expect(text).toContain('seed\n'); + expect(text).toContain('after\n'); + expect( + stderrSpy.mock.calls.some((c) => String(c[0]).includes('[logger] write failed')), + ).toBe(false); + } finally { + stderrSpy.mockRestore(); + await chmod(dir, 0o755).catch(() => {}); + } + }, + ); it('drops oldest when pending overflows', async () => { const path = join(workDir, 'app.log'); From cbddada82de9b168bbfd304f5fb6c16612bbd2fe Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 16 Jun 2026 01:42:13 +0800 Subject: [PATCH 3/4] test: mock blocked log rotation --- .../agent-core/test/logging/sinks.test.ts | 82 ++++++++++++------- 1 file changed, 52 insertions(+), 30 deletions(-) diff --git a/packages/agent-core/test/logging/sinks.test.ts b/packages/agent-core/test/logging/sinks.test.ts index c4bde390c..8b86746b0 100644 --- a/packages/agent-core/test/logging/sinks.test.ts +++ b/packages/agent-core/test/logging/sinks.test.ts @@ -1,9 +1,33 @@ -import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +/* eslint-disable import/first -- vi.mock setup must run before RotatingFileSink imports fs promises. */ +import type * as FsPromises from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +const fsMockState = vi.hoisted(() => ({ + blockedRename: undefined as { from: string; to: string; code: string } | undefined, +})); + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises'); + return { + ...actual, + rename: async (...args: Parameters) => { + const [from, to] = args; + const blockedRename = fsMockState.blockedRename; + if (blockedRename && String(from) === blockedRename.from && String(to) === blockedRename.to) { + throw Object.assign(new Error(`${blockedRename.code}: busy rename`), { + code: blockedRename.code, + }); + } + return actual.rename(...args); + }, + }; +}); + +import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; + import { PENDING_MAX, RotatingFileSink } from '#/logging/sinks'; let workDir: string; @@ -13,6 +37,7 @@ beforeEach(async () => { }); afterEach(async () => { + fsMockState.blockedRename = undefined; await rm(workDir, { recursive: true, force: true }); }); @@ -78,35 +103,32 @@ describe('RotatingFileSink', () => { } }); - it.skipIf(process.platform === 'win32')( - 'keeps writing when rotation is temporarily blocked', - async () => { - const dir = join(workDir, 'locked'); - const path = join(dir, 'app.log'); - await mkdir(dir); - await writeFile(path, 'seed\n', 'utf-8'); - await chmod(dir, 0o555); - - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - try { - const sink = new RotatingFileSink({ path, maxBytes: 8, files: 2 }); - sink.enqueue('after\n'); - - expect(await sink.flush()).toBe(true); - await chmod(dir, 0o755); - - const text = await readFile(path, 'utf-8'); - expect(text).toContain('seed\n'); - expect(text).toContain('after\n'); - expect( - stderrSpy.mock.calls.some((c) => String(c[0]).includes('[logger] write failed')), - ).toBe(false); - } finally { - stderrSpy.mockRestore(); - await chmod(dir, 0o755).catch(() => {}); - } - }, - ); + it('keeps writing when rotation is temporarily blocked', async () => { + const path = join(workDir, 'app.log'); + await writeFile(path, 'seed\n', 'utf-8'); + fsMockState.blockedRename = { + from: path, + to: `${path}.1`, + code: 'EBUSY', + }; + + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const sink = new RotatingFileSink({ path, maxBytes: 8, files: 2 }); + sink.enqueue('after\n'); + + expect(await sink.flush()).toBe(true); + + const text = await readFile(path, 'utf-8'); + expect(text).toContain('seed\n'); + expect(text).toContain('after\n'); + expect(stderrSpy.mock.calls.some((c) => String(c[0]).includes('[logger] write failed'))).toBe( + false, + ); + } finally { + stderrSpy.mockRestore(); + } + }); it('drops oldest when pending overflows', async () => { const path = join(workDir, 'app.log'); From b52c80031f16302c7f45eb9806a3821760b859b7 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 16 Jun 2026 13:11:15 +0800 Subject: [PATCH 4/4] fix: surface access-denied log rotation failures --- packages/agent-core/src/logging/sinks.ts | 2 +- .../agent-core/test/logging/sinks.test.ts | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/agent-core/src/logging/sinks.ts b/packages/agent-core/src/logging/sinks.ts index 0f5481c46..69b6d3f02 100644 --- a/packages/agent-core/src/logging/sinks.ts +++ b/packages/agent-core/src/logging/sinks.ts @@ -6,7 +6,7 @@ import { syncDir } from '#/utils/fs'; export const PENDING_MAX = 1000; const STDERR_NOTICE_INTERVAL_MS = 30_000; -const TRANSIENT_ROTATION_ERROR_CODES = new Set(['EACCES', 'EBUSY', 'EPERM']); +const TRANSIENT_ROTATION_ERROR_CODES = new Set(['EBUSY', 'EPERM']); class AsyncSerialQueue { private tail: Promise = Promise.resolve(); diff --git a/packages/agent-core/test/logging/sinks.test.ts b/packages/agent-core/test/logging/sinks.test.ts index 8b86746b0..af67cdcf7 100644 --- a/packages/agent-core/test/logging/sinks.test.ts +++ b/packages/agent-core/test/logging/sinks.test.ts @@ -130,6 +130,30 @@ describe('RotatingFileSink', () => { } }); + it('surfaces permanent EACCES rotation failures', async () => { + const path = join(workDir, 'app.log'); + await writeFile(path, 'seed\n', 'utf-8'); + fsMockState.blockedRename = { + from: path, + to: `${path}.1`, + code: 'EACCES', + }; + + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const sink = new RotatingFileSink({ path, maxBytes: 8, files: 2 }); + sink.enqueue('after\n'); + + expect(await sink.flush()).toBe(false); + expect(await readFile(path, 'utf-8')).toBe('seed\n'); + expect(stderrSpy.mock.calls.some((c) => String(c[0]).includes('[logger] write failed'))).toBe( + true, + ); + } finally { + stderrSpy.mockRestore(); + } + }); + it('drops oldest when pending overflows', async () => { const path = join(workDir, 'app.log'); const sink = new RotatingFileSink({ path, maxBytes: 1_000_000, files: 2 });