From 3d8fbb6c76d633808af045a269cbfab26d2169ac Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:55:31 +0000 Subject: [PATCH 1/2] fix(tui): harden file-watcher against vanished-path ENOENT errors startFileWatcher schedules three long-lived callbacks (poll setInterval, fs.watch change listener, attach-retry setInterval) that stat/access the watched path. When the path's directory disappears out from under them, the fs.watch watchers had no 'error' listener, so a watcher error surfaced as an uncaught ENOENT, and already-scheduled callbacks could run after teardown. - Attach an 'error' handler to every fs.watch watcher (via attachWatch) so a vanished path can't raise an unhandled error event. - Add a stopped flag guarding read() and the attach-retry loop so callbacks scheduled before stop() become no-ops after teardown. - Guard w.close() in stop() against double-close. - Tighten the test teardown to stop the watcher and yield before removing the temp dir, and add a regression test for the fs.watch error-event path. Generated-By: PostHog Code Task-Id: 2a9a5b2b-5927-4eab-8fa9-af9c7fae3e57 --- .../tui/hooks/__tests__/file-watcher.test.ts | 45 +++++++++++++++- src/ui/tui/hooks/file-watcher.ts | 54 +++++++++++++++---- 2 files changed, 89 insertions(+), 10 deletions(-) diff --git a/src/ui/tui/hooks/__tests__/file-watcher.test.ts b/src/ui/tui/hooks/__tests__/file-watcher.test.ts index 8a467d25..d8c90a9f 100644 --- a/src/ui/tui/hooks/__tests__/file-watcher.test.ts +++ b/src/ui/tui/hooks/__tests__/file-watcher.test.ts @@ -1,3 +1,4 @@ +import * as fs from 'fs'; import { mkdtempSync, rmSync, writeFileSync, renameSync, unlinkSync } from 'fs'; import { tmpdir } from 'os'; import path from 'path'; @@ -6,6 +7,23 @@ import { type FileWatcherHandle, } from '@ui/tui/hooks/file-watcher'; +// Capture the FSWatcher instances the hook attaches by wrapping the real +// fs.watch. The vitest ESM namespace is frozen (neither vi.spyOn nor direct +// assignment works), so we mock the module and delegate to the actual impl. +const { createdWatchers } = vi.hoisted(() => ({ + createdWatchers: [] as import('fs').FSWatcher[], +})); + +vi.mock('fs', async (importActual) => { + const actual = await importActual(); + const watch: typeof actual.watch = (...args: Parameters) => { + const watcher = (actual.watch as (...a: unknown[]) => fs.FSWatcher)(...args); + createdWatchers.push(watcher); + return watcher; + }; + return { ...actual, default: actual, watch }; +}); + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); describe('startFileWatcher', () => { @@ -14,11 +32,16 @@ describe('startFileWatcher', () => { beforeEach(() => { workdir = mkdtempSync(path.join(tmpdir(), 'wizard-fw-')); + createdWatchers.length = 0; }); - afterEach(() => { + afterEach(async () => { + // Stop the watcher before deleting its directory, then yield once so any + // already-scheduled timer/watch callbacks drain against the still-present + // path rather than a vanished one. handle?.stop(); handle = undefined; + await wait(0); rmSync(workdir, { recursive: true, force: true }); }); @@ -115,6 +138,26 @@ describe('startFileWatcher', () => { expect(onUpdate).not.toHaveBeenCalled(); }); + it('handles fs.watch error events instead of letting them go unhandled', () => { + const onUpdate = vi.fn(); + const target = path.join(workdir, 'data.json'); + writeFileSync(target, JSON.stringify({ v: 1 })); + + handle = startFileWatcher(target, onUpdate, { pollIntervalMs: 1000 }); + + // When the watched path's directory is removed, Node emits an 'error' + // event on the FSWatcher; on an emitter with no 'error' listener that is + // re-thrown as an uncaught exception — the reported crash. The hook must + // register a listener so this is swallowed. + const watcher = createdWatchers[0]; + expect(watcher).toBeDefined(); + + const enoent = Object.assign(new Error('ENOENT: watched path removed'), { + code: 'ENOENT', + }); + expect(() => watcher.emit('error', enoent)).not.toThrow(); + }); + it('survives the file being deleted and recreated', async () => { const onUpdate = vi.fn(); const target = path.join(workdir, 'data.json'); diff --git a/src/ui/tui/hooks/file-watcher.ts b/src/ui/tui/hooks/file-watcher.ts index 1167a3ac..964b5e78 100644 --- a/src/ui/tui/hooks/file-watcher.ts +++ b/src/ui/tui/hooks/file-watcher.ts @@ -44,8 +44,14 @@ export function startFileWatcher( const watchers: fs.FSWatcher[] = []; const intervals: Array> = []; let lastMtimeMs = 0; + // Once stopped, already-scheduled timer and watch callbacks must not touch + // the path again — `clearInterval`/`close()` don't unqueue a callback that + // already fired, and the path (or its directory) may vanish out from under a + // straggler. + let stopped = false; const read = (force = false) => { + if (stopped) return; try { const stat = fs.statSync(path); if (!force && stat.mtimeMs === lastMtimeMs) return; @@ -57,33 +63,63 @@ export function startFileWatcher( } }; + // Attach an `fs.watch` with an `'error'` handler. Without the handler a + // watcher error (e.g. the watched path's directory is removed) is emitted on + // an EventEmitter with no listener, which Node re-throws as an uncaught + // exception. Returns true if the watch attached. + const attachWatch = (): boolean => { + try { + const watcher = fs.watch(path, () => read(true)); + watcher.on('error', () => { + // The watched path went away. Drop this watcher and swallow — the poll + // loop keeps updates flowing and re-attach is handled elsewhere. + try { + watcher.close(); + } catch { + // Already closed. + } + }); + watchers.push(watcher); + return true; + } catch { + return false; + } + }; + intervals.push(setInterval(() => read(), pollIntervalMs)); - try { - watchers.push(fs.watch(path, () => read(true))); + if (attachWatch()) { read(true); - } catch { + } else { // File doesn't exist yet — retry attaching the watch periodically until // it appears. The poll above already covers updates; this just upgrades // latency once the file shows up. const attachInterval = setInterval(() => { + if (stopped) return; try { fs.accessSync(path); - clearInterval(attachInterval); - const idx = intervals.indexOf(attachInterval); - if (idx >= 0) intervals.splice(idx, 1); - watchers.push(fs.watch(path, () => read(true))); } catch { - // Still waiting. + return; // Still waiting. } + clearInterval(attachInterval); + const idx = intervals.indexOf(attachInterval); + if (idx >= 0) intervals.splice(idx, 1); + attachWatch(); }, attachRetryIntervalMs); intervals.push(attachInterval); } return { stop() { - for (const w of watchers) w.close(); + stopped = true; for (const i of intervals) clearInterval(i); + for (const w of watchers) { + try { + w.close(); + } catch { + // Already closed. + } + } }, }; } From 377f0a84a9128cb78b30bcf5d8431b73a2caee0f Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:23:25 +0000 Subject: [PATCH 2/2] style: apply Prettier formatting to file-watcher test Generated-By: PostHog Code Task-Id: 2a9a5b2b-5927-4eab-8fa9-af9c7fae3e57 --- src/ui/tui/hooks/__tests__/file-watcher.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ui/tui/hooks/__tests__/file-watcher.test.ts b/src/ui/tui/hooks/__tests__/file-watcher.test.ts index d8c90a9f..af87e402 100644 --- a/src/ui/tui/hooks/__tests__/file-watcher.test.ts +++ b/src/ui/tui/hooks/__tests__/file-watcher.test.ts @@ -16,8 +16,12 @@ const { createdWatchers } = vi.hoisted(() => ({ vi.mock('fs', async (importActual) => { const actual = await importActual(); - const watch: typeof actual.watch = (...args: Parameters) => { - const watcher = (actual.watch as (...a: unknown[]) => fs.FSWatcher)(...args); + const watch: typeof actual.watch = ( + ...args: Parameters + ) => { + const watcher = (actual.watch as (...a: unknown[]) => fs.FSWatcher)( + ...args, + ); createdWatchers.push(watcher); return watcher; };