fix(tui): don't crash when fs.watch throws (e.g. ENOSPC) (#51210)

Co-authored-by: Alireza Haghdoost <haghdoost@uber.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ARH
2026-09-24 17:16:43 -07:00
committed by GitHub
parent 5256f30957
commit e8b3e19e85
2 changed files with 53 additions and 8 deletions
+16 -8
View File
@@ -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<string, { readonly value: Entry<object>; readonly reload: () => void }>()
const memories = new Map<string, MemoryEntry<object>>()
const pending = new Set<Promise<void>>()
@@ -111,17 +111,25 @@ function createStorage(root: string, channel: string) {
}
let reload: ReturnType<typeof setTimeout> | 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<typeof watch> | 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()
},
}
}
+37
View File
@@ -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<typeof createStorage> | 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 })
}
})