From 494ca27b8b6c43270557ec6e281ad1320fcdf68a Mon Sep 17 00:00:00 2001 From: Altay Date: Sat, 2 May 2026 19:24:43 +0300 Subject: [PATCH] fix: narrow watcher transient classifier --- src/infra/unhandled-rejections.test.ts | 13 +++++++++++-- src/infra/unhandled-rejections.ts | 13 +++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/infra/unhandled-rejections.test.ts b/src/infra/unhandled-rejections.test.ts index b852fa1cb8a..94f14d62a3e 100644 --- a/src/infra/unhandled-rejections.test.ts +++ b/src/infra/unhandled-rejections.test.ts @@ -308,9 +308,18 @@ describe("isTransientFileWatchError", () => { ).toBe(true); }); - it("returns true for watcher-related error messages", () => { + it("returns true for watcher-related ENOSPC messages", () => { expect(isTransientFileWatchError(new Error("watcher error: ENOSPC"))).toBe(true); - expect(isTransientFileWatchError(new Error("file watcher failed"))).toBe(true); + expect(isTransientFileWatchError(new Error("file watcher: no space left on device"))).toBe( + true, + ); + }); + + it("returns false for generic code-less watcher messages", () => { + expect(isTransientFileWatchError(new Error("file watcher failed"))).toBe(false); + expect(isTransientFileWatchError(new Error("watcher error: boom"))).toBe(false); + expect(isTransientUnhandledRejectionError(new Error("file watcher failed"))).toBe(false); + expect(isTransientUnhandledRejectionError(new Error("watcher error: boom"))).toBe(false); }); it("returns true for ENOSPC with cause chain containing watch indicator", () => { diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts index 9dbf431a7e6..437fe36cbda 100644 --- a/src/infra/unhandled-rejections.ts +++ b/src/infra/unhandled-rejections.ts @@ -371,6 +371,12 @@ export function isTransientFileWatchError(err: unknown): boolean { message.includes("file watcher") || message.includes("watch limit") || message.includes("max watches"); + const hasFileWatchExhaustionSignal = (message: string) => + message.includes("inotify watches") || + message.includes("inotify watch") || + message.includes("system limit for number of file watchers") || + message.includes("watch limit") || + message.includes("max watches"); for (const candidate of collectNestedUnhandledErrorCandidates(err)) { // Skip non-object candidates early @@ -393,16 +399,15 @@ export function isTransientFileWatchError(err: unknown): boolean { continue; } - // Check for file watcher error message patterns (without ENOSPC code) + // Without an ENOSPC code, only classify explicit watcher resource exhaustion. + // Generic "file watcher failed" labels can wrap permission/config/runtime failures. if (!message) { continue; } if ( ((message.includes("no space left on device") || message.includes("enosp")) && hasFileWatchSignal(message)) || - message.includes("inotify watches") || - message.includes("file watcher") || - message.includes("watcher error") + hasFileWatchExhaustionSignal(message) ) { return true; }