From 9922758476807928bf888b6d3a0c6e9c61361846 Mon Sep 17 00:00:00 2001 From: mameikagou Date: Sat, 15 Aug 2026 06:09:29 +0800 Subject: [PATCH] fix(agent-core): stop retrying exhausted file watchers --- .changeset/disable-exhausted-fs-watchers.md | 5 +++ .../backends/node-local/hostFsWatchService.ts | 23 ++++++++++- .../node-local/hostFsWatchService.test.ts | 41 ++++++++++++++++++- 3 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 .changeset/disable-exhausted-fs-watchers.md diff --git a/.changeset/disable-exhausted-fs-watchers.md b/.changeset/disable-exhausted-fs-watchers.md new file mode 100644 index 0000000000..23847c7bfe --- /dev/null +++ b/.changeset/disable-exhausted-fs-watchers.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Prevent filesystem watcher exhaustion from repeatedly retrying and crashing the Kimi CLI. diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts index 333f631498..d0d57573bb 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts @@ -2,7 +2,8 @@ * `hostFsWatch` domain — `IHostFsWatchService` implementation. * * Reports precise or coarse host filesystem changes through platform - * watchers. Each handle owns and disposes its watcher. Bound at App scope. + * watchers. Each handle owns and disposes its watcher, and disables itself + * when the process or system watcher budget is exhausted. Bound at App scope. */ import { watch as fsWatch } from 'node:fs'; @@ -30,6 +31,11 @@ const DEFAULT_IGNORED = (p: string): boolean => /(?:^|[/\\])\.git(?:$|[/\\])/.te const NATIVE_RETRY_BASE_MS = 1000; const NATIVE_RETRY_MAX_MS = 30000; +function isWatchResourceExhaustion(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code === 'EMFILE' || code === 'ENFILE'; +} + interface NativeFsWatcher { close(): void; on(event: 'error', listener: (error: NodeJS.ErrnoException) => void): this; @@ -114,6 +120,11 @@ class HostFsWatchHandle implements IHostFsWatchHandle { if (mapped !== undefined) this.emitter.fire(mapped); }); this.watcher.on('error', (error: unknown) => { + if (isWatchResourceExhaustion(error)) { + onUnexpectedError(error); + this.dispose(); + return; + } this.readiness.reject(error); onUnexpectedError(error); }); @@ -142,6 +153,7 @@ class SignalWatchHandle implements IHostFsWatchHandle { private retry: IDisposable | undefined; private retryAttempts = 0; private recovering = false; + private resourceExhausted = false; private disposed = false; constructor( @@ -157,7 +169,7 @@ class SignalWatchHandle implements IHostFsWatchHandle { } private startNativeLeg(): void { - if (this.disposed) return; + if (this.disposed || this.resourceExhausted) return; try { const watcher = this.runtime.watchNative(this.root, (_eventType, filename) => { if (this.disposed) return; @@ -185,6 +197,13 @@ class SignalWatchHandle implements IHostFsWatchHandle { if (watcher !== undefined && watcher !== this.nativeWatcher) return; watcher?.close(); this.nativeWatcher = undefined; + if (isWatchResourceExhaustion(error)) { + this.resourceExhausted = true; + this.recovering = false; + this.readiness.resolve(); + onUnexpectedError(error); + return; + } if (error.code === 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM') { this.recovering = false; this.startChokidarLeg(); diff --git a/packages/agent-core-v2/test/os/backends/node-local/hostFsWatchService.test.ts b/packages/agent-core-v2/test/os/backends/node-local/hostFsWatchService.test.ts index 95e9bb9d45..6d0db63442 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/hostFsWatchService.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/hostFsWatchService.test.ts @@ -58,7 +58,10 @@ interface TestRetry { run(): void; } -function signalRig(options?: { readonly synchronousFailures?: number }): { +function signalRig(options?: { + readonly synchronousFailures?: number; + readonly synchronousFailureCode?: string; +}): { readonly service: IHostFsWatchService; readonly attempts: TestNativeAttempt[]; readonly retries: TestRetry[]; @@ -73,7 +76,9 @@ function signalRig(options?: { readonly synchronousFailures?: number }): { watchNative: (_root, listener) => { if (synchronousFailures > 0) { synchronousFailures -= 1; - throw Object.assign(new Error('native watch creation failed'), { code: 'EIO' }); + throw Object.assign(new Error('native watch creation failed'), { + code: options?.synchronousFailureCode ?? 'EIO', + }); } const watcher = new TestNativeWatcher(); attempts.push({ @@ -230,6 +235,38 @@ describe('host filesystem change notifications', () => { expect(rig.retries.map((retry) => retry.delayMs)).toEqual([1000, 1000]); }); + it.each(['EMFILE', 'ENFILE'])( + 'disables a native watch instead of retrying after %s', + async (code) => { + const rig = signalRig(); + const events: HostFsChange[] = []; + const reported: unknown[] = []; + setUnexpectedErrorHandler((error) => reported.push(error)); + handle = rig.service.watch('/repo', { signal: true }); + handle.onDidChange((event) => events.push(event)); + await handle.ready; + + rig.attempt(0).watcher.fail(code); + + expect(rig.attempt(0).watcher.closed).toBe(true); + expect(rig.retries).toHaveLength(0); + expect(events).toHaveLength(0); + expect(reported).toHaveLength(1); + }, + ); + + it.each(['EMFILE', 'ENFILE'])( + 'becomes ready without retrying when native watch creation fails with %s', + async (code) => { + const rig = signalRig({ synchronousFailures: 1, synchronousFailureCode: code }); + handle = rig.service.watch('/repo', { signal: true }); + + await expect(handle.ready).resolves.toBeUndefined(); + expect(rig.attempts).toHaveLength(0); + expect(rig.retries).toHaveLength(0); + }, + ); + it('cancels a pending native retry when the watch handle is disposed', () => { const rig = signalRig(); handle = rig.service.watch('/repo', { signal: true });