Skip to content

Commit 08737f8

Browse files
committed
fix(tui): add hybrid polling safety net to HUD bridge (#1204)
Run polling alongside fs.watch simultaneously so that silent watcher failures (containers, restricted FS) never stall HUD/TUI sync. - Default pollIntervalMs 1000 → 200 for faster detection - startPollingFallback accepts silent param for hybrid mode - fs.watch error handler only cleans up watcher (polling already active) - Tests use pollIntervalMs:50 for environment independence - New test verifies both watcher and poll are active in hybrid mode
1 parent 7f07a23 commit 08737f8

2 files changed

Lines changed: 43 additions & 11 deletions

File tree

apps/mcp-server/src/tui/events/hud-file-bridge.spec.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ describe('HudFileBridge', () => {
3030

3131
it('emits MODE_CHANGED when currentMode changes in file', async () => {
3232
writeHudState(hudFile, { currentMode: 'PLAN' });
33-
bridge = new HudFileBridge(eventBus, hudFile, { debounceMs: 10 });
33+
bridge = new HudFileBridge(eventBus, hudFile, { debounceMs: 10, pollIntervalMs: 50 });
3434
bridge.start();
3535

3636
const modeChanges: Array<{ from: string | null; to: string }> = [];
@@ -61,7 +61,7 @@ describe('HudFileBridge', () => {
6161

6262
it('emits AGENT_ACTIVATED when activeAgent changes', async () => {
6363
writeHudState(hudFile, { currentMode: 'PLAN', activeAgent: null });
64-
bridge = new HudFileBridge(eventBus, hudFile, { debounceMs: 10 });
64+
bridge = new HudFileBridge(eventBus, hudFile, { debounceMs: 10, pollIntervalMs: 50 });
6565
bridge.start();
6666

6767
const activations: unknown[] = [];
@@ -107,13 +107,16 @@ describe('HudFileBridge', () => {
107107
const modeChanges: Array<{ from: string | null; to: string }> = [];
108108
eventBus.on(TUI_EVENTS.MODE_CHANGED, p => modeChanges.push(p));
109109

110-
// Simulate watcher error to trigger polling fallback
111-
// Access internal watcher and emit error
110+
// Simulate watcher error — polling is already running (hybrid mode)
112111
const watcher = (bridge as unknown as { watcher: fs.FSWatcher | null }).watcher;
113112
expect(watcher).not.toBeNull();
114113
watcher!.emit('error', new Error('EPERM'));
115114

116-
// Write new state — polling should detect the change
115+
// Watcher should be cleaned up after error
116+
const bridgeInternal = bridge as unknown as { watcher: fs.FSWatcher | null };
117+
expect(bridgeInternal.watcher).toBeNull();
118+
119+
// Write new state — polling safety net should detect the change
117120
await sleep(100);
118121
writeHudState(hudFile, { currentMode: 'ACT' });
119122
await sleep(300);
@@ -122,6 +125,31 @@ describe('HudFileBridge', () => {
122125
expect(modeChanges[modeChanges.length - 1]).toEqual({ from: 'PLAN', to: 'ACT' });
123126
});
124127

128+
it('runs hybrid mode — both watch and poll active simultaneously', async () => {
129+
writeHudState(hudFile, { currentMode: 'PLAN' });
130+
bridge = new HudFileBridge(eventBus, hudFile, { debounceMs: 10, pollIntervalMs: 50 });
131+
bridge.start();
132+
133+
const bridgeInternal = bridge as unknown as {
134+
watcher: fs.FSWatcher | null;
135+
pollInterval: ReturnType<typeof setInterval> | null;
136+
};
137+
138+
// Both watcher and polling should be active (hybrid mode)
139+
expect(bridgeInternal.watcher).not.toBeNull();
140+
expect(bridgeInternal.pollInterval).not.toBeNull();
141+
142+
// Polling alone should detect changes even if fs.watch is silent
143+
const modeChanges: Array<{ from: string | null; to: string }> = [];
144+
eventBus.on(TUI_EVENTS.MODE_CHANGED, p => modeChanges.push(p));
145+
146+
writeHudState(hudFile, { currentMode: 'EVAL' });
147+
await sleep(300);
148+
149+
expect(modeChanges.length).toBeGreaterThanOrEqual(1);
150+
expect(modeChanges[modeChanges.length - 1]).toEqual({ from: 'PLAN', to: 'EVAL' });
151+
});
152+
125153
it('handles malformed JSON gracefully', async () => {
126154
writeHudState(hudFile, { currentMode: 'PLAN' });
127155
bridge = new HudFileBridge(eventBus, hudFile, { debounceMs: 10 });

apps/mcp-server/src/tui/events/hud-file-bridge.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const VALID_MODES = new Set(['PLAN', 'ACT', 'EVAL', 'AUTO']);
1515
export interface HudFileBridgeOptions {
1616
/** Debounce interval in ms (default: 150) */
1717
debounceMs?: number;
18-
/** Polling interval in ms when fs.watch fails (default: 1000) */
18+
/** Polling interval in ms for hybrid safety-net polling (default: 200) */
1919
pollIntervalMs?: number;
2020
}
2121

@@ -41,7 +41,7 @@ export class HudFileBridge {
4141
this.eventBus = eventBus;
4242
this.filePath = filePath;
4343
this.debounceMs = options?.debounceMs ?? 150;
44-
this.pollIntervalMs = options?.pollIntervalMs ?? 1000;
44+
this.pollIntervalMs = options?.pollIntervalMs ?? 200;
4545
}
4646

4747
start(): void {
@@ -61,11 +61,13 @@ export class HudFileBridge {
6161
if (!filename || filename === basename) this.scheduleProcess();
6262
});
6363
this.watcher.on('error', () => {
64-
// Directory deleted or inaccessible — fall back to polling
64+
// Watcher failed — close it; polling is already running as safety net
6565
this.watcher?.close();
6666
this.watcher = null;
67-
this.startPollingFallback();
67+
process.stderr.write('[codingbuddy] fs.watch failed, polling safety net active\n');
6868
});
69+
// Hybrid mode: run polling alongside fs.watch as a silent safety net
70+
this.startPollingFallback(true);
6971
} catch {
7072
// Directory doesn't exist yet — poll until it appears
7173
this.pollUntilExists();
@@ -135,9 +137,11 @@ export class HudFileBridge {
135137
}
136138
}
137139

138-
private startPollingFallback(): void {
140+
private startPollingFallback(silent: boolean = false): void {
139141
if (this.stopped || this.pollInterval) return;
140-
process.stderr.write('[codingbuddy] fs.watch failed, falling back to polling\n');
142+
if (!silent) {
143+
process.stderr.write('[codingbuddy] fs.watch failed, falling back to polling\n');
144+
}
141145
try {
142146
const stat = fs.statSync(this.filePath);
143147
this.lastMtimeMs = stat.mtimeMs;

0 commit comments

Comments
 (0)