Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/disable-exhausted-fs-watchers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Prevent filesystem watcher exhaustion from repeatedly retrying and crashing the Kimi CLI.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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(
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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({
Expand Down Expand Up @@ -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 });
Expand Down