From c9046f33e200f09b3c6bb64c770c0259b34870e8 Mon Sep 17 00:00:00 2001 From: Altay Date: Sat, 2 May 2026 20:03:59 +0300 Subject: [PATCH] fix: tighten watcher exhaustion handling --- .../src/memory/manager.watcher-config.test.ts | 35 ++++++++++++++++--- src/infra/unhandled-rejections.test.ts | 5 +-- src/infra/unhandled-rejections.ts | 3 +- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/extensions/memory-core/src/memory/manager.watcher-config.test.ts b/extensions/memory-core/src/memory/manager.watcher-config.test.ts index 362cf135348..1c909ede938 100644 --- a/extensions/memory-core/src/memory/manager.watcher-config.test.ts +++ b/extensions/memory-core/src/memory/manager.watcher-config.test.ts @@ -9,9 +9,9 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vites type WatchIgnoredFn = (watchPath: string, stats?: { isDirectory?: () => boolean }) => boolean; -const { createdWatchers, watchMock } = vi.hoisted(() => { - type WatchEvent = "add" | "change" | "unlink" | "unlinkDir"; - type WatchCallback = () => void; +const { createdWatchers, memoryLoggerWarn, watchMock } = vi.hoisted(() => { + type WatchEvent = "add" | "change" | "unlink" | "unlinkDir" | "error"; + type WatchCallback = (value?: unknown) => void; function createMockWatcher() { const handlers = new Map(); const watcher = { @@ -20,9 +20,9 @@ const { createdWatchers, watchMock } = vi.hoisted(() => { return watcher; }), close: vi.fn(async () => undefined), - emit: (event: WatchEvent) => { + emit: (event: WatchEvent, value?: unknown) => { for (const callback of handlers.get(event) ?? []) { - callback(); + callback(value); } }, }; @@ -31,6 +31,7 @@ const { createdWatchers, watchMock } = vi.hoisted(() => { const watchers: Array> = []; const result = { createdWatchers: watchers, + memoryLoggerWarn: vi.fn(), watchMock: vi.fn(() => { const watcher = createMockWatcher(); watchers.push(watcher); @@ -42,6 +43,18 @@ const { createdWatchers, watchMock } = vi.hoisted(() => { return result; }); +vi.mock("openclaw/plugin-sdk/memory-core-host-engine-foundation", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + createSubsystemLogger: (subsystem: string) => ({ + ...actual.createSubsystemLogger(subsystem), + warn: memoryLoggerWarn, + }), + }; +}); + vi.mock("./sqlite-vec.js", () => ({ loadSqliteVecExtension: async () => ({ ok: false, error: "sqlite-vec disabled in tests" }), })); @@ -246,4 +259,16 @@ describe("memory watcher config", () => { expect(syncSpy).toHaveBeenCalledWith({ reason: "watch" }); }, ); + + it("attaches a logging non-throwing watcher error listener", async () => { + await setupWatcherWorkspace({ name: "notes.md", contents: "hello" }); + const cfg = createWatcherConfig(); + + await expectWatcherManager(cfg); + + const watcher = createdWatchers[0]; + expect(watcher?.on).toHaveBeenCalledWith("error", expect.any(Function)); + expect(() => watcher?.emit("error", new Error("watcher error: ENOSPC"))).not.toThrow(); + expect(memoryLoggerWarn).toHaveBeenCalledWith("memory watcher error: watcher error: ENOSPC"); + }); }); diff --git a/src/infra/unhandled-rejections.test.ts b/src/infra/unhandled-rejections.test.ts index 94f14d62a3e..98bee47e5c7 100644 --- a/src/infra/unhandled-rejections.test.ts +++ b/src/infra/unhandled-rejections.test.ts @@ -308,8 +308,7 @@ describe("isTransientFileWatchError", () => { ).toBe(true); }); - it("returns true for watcher-related ENOSPC messages", () => { - expect(isTransientFileWatchError(new Error("watcher error: ENOSPC"))).toBe(true); + it("returns true for watcher-related no-space messages", () => { expect(isTransientFileWatchError(new Error("file watcher: no space left on device"))).toBe( true, ); @@ -318,8 +317,10 @@ describe("isTransientFileWatchError", () => { 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(isTransientFileWatchError(new Error("watcher error: ENOSPC"))).toBe(false); expect(isTransientUnhandledRejectionError(new Error("file watcher failed"))).toBe(false); expect(isTransientUnhandledRejectionError(new Error("watcher error: boom"))).toBe(false); + expect(isTransientUnhandledRejectionError(new Error("watcher error: ENOSPC"))).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 437fe36cbda..b763715721c 100644 --- a/src/infra/unhandled-rejections.ts +++ b/src/infra/unhandled-rejections.ts @@ -405,8 +405,7 @@ export function isTransientFileWatchError(err: unknown): boolean { continue; } if ( - ((message.includes("no space left on device") || message.includes("enosp")) && - hasFileWatchSignal(message)) || + (message.includes("no space left on device") && hasFileWatchSignal(message)) || hasFileWatchExhaustionSignal(message) ) { return true;