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
49 changes: 48 additions & 1 deletion 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,27 @@ 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<typeof import('fs')>();
const watch: typeof actual.watch = (
...args: Parameters<typeof actual.watch>
) => {
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', () => {
Expand All @@ -14,11 +36,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 });
});

Expand Down Expand Up @@ -115,6 +142,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');
Expand Down
54 changes: 45 additions & 9 deletions src/ui/tui/hooks/file-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,14 @@ export function startFileWatcher(
const watchers: fs.FSWatcher[] = [];
const intervals: Array<ReturnType<typeof setInterval>> = [];
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;
Expand All @@ -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.
}
}
},
};
}
Expand Down
Loading