From e8b3e19e85e11a31c2168db8756d4bc72bff0989 Mon Sep 17 00:00:00 2001 From: ARH Date: Thu, 24 Sep 2026 17:16:43 -0700 Subject: [PATCH] fix(tui): don't crash when fs.watch throws (e.g. ENOSPC) (#51210) Co-authored-by: Alireza Haghdoost Co-authored-by: Claude Sonnet 5 --- packages/tui/src/context/storage.tsx | 24 ++++++++++----- packages/tui/test/context/storage.test.ts | 37 +++++++++++++++++++++++ 2 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 packages/tui/test/context/storage.test.ts diff --git a/packages/tui/src/context/storage.tsx b/packages/tui/src/context/storage.tsx index b45480797f5..adac30b440b 100644 --- a/packages/tui/src/context/storage.tsx +++ b/packages/tui/src/context/storage.tsx @@ -44,7 +44,7 @@ function segment(value: string) { return value } -function createStorage(root: string, channel: string) { +export function createStorage(root: string, channel: string) { const entries = new Map; readonly reload: () => void }>() const memories = new Map>() const pending = new Set>() @@ -111,17 +111,25 @@ function createStorage(root: string, channel: string) { } let reload: ReturnType | undefined - const watcher = watch(directory, () => { - clearTimeout(reload) - // Atomic writes notify for the temporary file before its final rename, and some - // platforms coalesce the rename event. Reload after the event burst has settled. - reload = setTimeout(() => entries.forEach((entry) => entry.reload()), 50) - }) + let watcher: ReturnType | undefined + try { + watcher = watch(directory, () => { + clearTimeout(reload) + // Atomic writes notify for the temporary file before its final rename, and some + // platforms coalesce the rename event. Reload after the event burst has settled. + reload = setTimeout(() => entries.forEach((entry) => entry.reload()), 50) + }) + } catch (error) { + // fs.watch throws synchronously (e.g. ENOSPC when the inotify watch limit is + // exhausted). Losing cross-process live-reload is recoverable; crashing the + // whole TUI over it is not, so degrade instead of propagating. + console.error("Failed to watch storage directory, live-reload disabled", { directory, error }) + } return { storage, close: () => { clearTimeout(reload) - watcher.close() + watcher?.close() }, } } diff --git a/packages/tui/test/context/storage.test.ts b/packages/tui/test/context/storage.test.ts new file mode 100644 index 00000000000..c632fecdf0e --- /dev/null +++ b/packages/tui/test/context/storage.test.ts @@ -0,0 +1,37 @@ +import { afterEach, expect, spyOn, test } from "bun:test" +import * as fs from "fs" +import { mkdtempSync, rmSync } from "fs" +import { tmpdir } from "os" +import path from "path" +import { createStorage } from "../../src/context/storage" + +afterEach(() => { + spyOn(fs, "watch").mockRestore() +}) + +test("createStorage degrades gracefully when fs.watch throws (e.g. ENOSPC)", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "storage-test-")) + // fs.watch throws synchronously when inotify_add_watch fails (e.g. the watch + // limit is exhausted). Simulate that scoped to this test only. + spyOn(fs, "watch").mockImplementation(() => { + throw Object.assign(new Error("ENOSPC: no space left on device, watch '/some/dir'"), { code: "ENOSPC" }) + }) + + try { + let result: ReturnType | undefined + expect(() => { + result = createStorage(dir, "next") + }).not.toThrow() + + // storage should still be usable even though the live-reload watcher failed to attach + const [store, update] = result!.storage.store("kv", { initial: { count: 0 } }) + expect(store.count).toBe(0) + await update((draft) => { + draft.count = 1 + }) + expect(store.count).toBe(1) + expect(() => result!.close()).not.toThrow() + } finally { + rmSync(dir, { recursive: true, force: true }) + } +})