From 3144013dd3d40a3c71ebfd99b955def987971527 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:59:40 +0000 Subject: [PATCH] fix(tui): stop file-watcher polling for a missing file with a throwing probe The file-watcher's attach-retry loop polled for a not-yet-created file with `fs.accessSync`, which throws ENOENT on every tick while the file is absent. With exception autocapture on, each throw surfaced as a spurious `$exception`, polluting error tracking (~1/sec while the watcher waits for the agent to write `.posthog-events.json`). Swap the throwing `accessSync` existence probe for a non-throwing `fs.existsSync` so the expected "file not there yet" state never raises. `existsSync` returns a boolean without constructing an Error, so there is nothing to capture regardless of how the exception path is wired. Apply the same fix to LogViewer, which uses the identical accessSync-in-retry pattern. Attach the watch before clearing the retry interval so a file removed mid-probe just keeps the poll running. Generated-By: PostHog Code Task-Id: 091f2350-ce0c-4767-bb84-ced895939554 --- .../tui/hooks/__tests__/file-watcher.test.ts | 34 +++++++++++++++++++ src/ui/tui/hooks/file-watcher.ts | 14 ++++++-- src/ui/tui/primitives/LogViewer.tsx | 10 ++++-- 3 files changed, 52 insertions(+), 6 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..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);