Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/ui/tui/hooks/__tests__/file-watcher.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<typeof import('fs')>();
return {
...actual,
accessSync: vi.fn((...args: Parameters<typeof actual.accessSync>) =>
actual.accessSync(...args),
),
};
});

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

describe('startFileWatcher', () => {
Expand Down Expand Up @@ -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');
Expand Down
14 changes: 11 additions & 3 deletions src/ui/tui/hooks/file-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 7 additions & 3 deletions src/ui/tui/primitives/LogViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading