diff --git a/src/ui/tui/hooks/__tests__/file-watcher.test.ts b/src/ui/tui/hooks/__tests__/file-watcher.test.ts index 8a467d25..2a66abbb 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,18 @@ import { type FileWatcherHandle, } from '@ui/tui/hooks/file-watcher'; +// Wrap `fs` so we can assert the watcher never reaches for the throwing +// `accessSync` existence probe. All other fs calls pass through untouched. +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + accessSync: vi.fn((...args: Parameters) => + actual.accessSync(...args), + ), + }; +}); + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); describe('startFileWatcher', () => { @@ -88,6 +101,27 @@ describe('startFileWatcher', () => { expect(onUpdate.mock.calls.at(-1)?.[0]).toEqual({ ready: true }); }); + it('never probes for the missing file with a throwing accessSync', async () => { + // Regression: the attach-retry loop used to poll with `fs.accessSync`, + // which throws ENOENT every tick while the file is absent. With exception + // autocapture on, each throw was reported as a spurious `$exception`. + const accessSync = vi.mocked(fs.accessSync); + accessSync.mockClear(); + const onUpdate = vi.fn(); + const target = path.join(workdir, 'never.json'); + + handle = startFileWatcher(target, onUpdate, { + pollIntervalMs: 20, + attachRetryIntervalMs: 20, + }); + + // Several retry ticks elapse with the file still absent. + await wait(120); + + expect(accessSync).not.toHaveBeenCalled(); + expect(onUpdate).not.toHaveBeenCalled(); + }); + it('swallows invalid JSON without throwing', 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..00595848 100644 --- a/src/ui/tui/hooks/file-watcher.ts +++ b/src/ui/tui/hooks/file-watcher.ts @@ -66,15 +66,23 @@ export function startFileWatcher( // 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. + // + // Probe with a *non-throwing* `existsSync` rather than `accessSync`: the + // "file not there yet" state is expected and hit on every tick until the + // agent writes the file, and a throw here gets picked up by exception + // autocapture and reported as a spurious `$exception` once a second. const attachInterval = setInterval(() => { + if (!fs.existsSync(path)) return; // Still waiting for the file. try { - fs.accessSync(path); + // Attach the watch first, then stop retrying — so a file that is + // removed between the existence check and `fs.watch` just leaves the + // poll running for the next tick instead of dropping the watcher. + watchers.push(fs.watch(path, () => read(true))); clearInterval(attachInterval); const idx = intervals.indexOf(attachInterval); if (idx >= 0) intervals.splice(idx, 1); - watchers.push(fs.watch(path, () => read(true))); } catch { - // Still waiting. + // Raced with the file disappearing; keep polling and retry. } }, attachRetryIntervalMs); intervals.push(attachInterval); diff --git a/src/ui/tui/primitives/LogViewer.tsx b/src/ui/tui/primitives/LogViewer.tsx index 8277802c..992c8672 100644 --- a/src/ui/tui/primitives/LogViewer.tsx +++ b/src/ui/tui/primitives/LogViewer.tsx @@ -96,13 +96,17 @@ export const LogViewer = ({ filePath, height }: LogViewerProps) => { watcher = fs.watch(filePath, () => scheduleRead()); } catch { const interval = setInterval(() => { + // Probe with a *non-throwing* `existsSync` rather than `accessSync`: + // the "file not there yet" state is expected on every tick until the + // file appears, and a throw here gets picked up by exception + // autocapture and reported as a spurious `$exception`. + if (!fs.existsSync(filePath)) return; // Still waiting for the file. try { - fs.accessSync(filePath); + watcher = fs.watch(filePath, () => scheduleRead()); readTail(); clearInterval(interval); - watcher = fs.watch(filePath, () => scheduleRead()); } catch { - // Still waiting for the file to appear + // Raced with the file disappearing; keep polling and retry. } }, 1000);