From c42c02552af3f6702bec0d3370d2ebb329a2fcc6 Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Fri, 3 Apr 2026 22:50:07 +0900 Subject: [PATCH] fix(tui): add hybrid polling safety net to HUD bridge (#1204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/tui/events/hud-file-bridge.spec.ts | 38 ++++++++++++++++--- .../src/tui/events/hud-file-bridge.ts | 16 +++++--- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/apps/mcp-server/src/tui/events/hud-file-bridge.spec.ts b/apps/mcp-server/src/tui/events/hud-file-bridge.spec.ts index 366a3c4b..8a81059d 100644 --- a/apps/mcp-server/src/tui/events/hud-file-bridge.spec.ts +++ b/apps/mcp-server/src/tui/events/hud-file-bridge.spec.ts @@ -30,7 +30,7 @@ describe('HudFileBridge', () => { it('emits MODE_CHANGED when currentMode changes in file', async () => { writeHudState(hudFile, { currentMode: 'PLAN' }); - bridge = new HudFileBridge(eventBus, hudFile, { debounceMs: 10 }); + bridge = new HudFileBridge(eventBus, hudFile, { debounceMs: 10, pollIntervalMs: 50 }); bridge.start(); const modeChanges: Array<{ from: string | null; to: string }> = []; @@ -61,7 +61,7 @@ describe('HudFileBridge', () => { it('emits AGENT_ACTIVATED when activeAgent changes', async () => { writeHudState(hudFile, { currentMode: 'PLAN', activeAgent: null }); - bridge = new HudFileBridge(eventBus, hudFile, { debounceMs: 10 }); + bridge = new HudFileBridge(eventBus, hudFile, { debounceMs: 10, pollIntervalMs: 50 }); bridge.start(); const activations: unknown[] = []; @@ -107,13 +107,16 @@ describe('HudFileBridge', () => { const modeChanges: Array<{ from: string | null; to: string }> = []; eventBus.on(TUI_EVENTS.MODE_CHANGED, p => modeChanges.push(p)); - // Simulate watcher error to trigger polling fallback - // Access internal watcher and emit error + // Simulate watcher error — polling is already running (hybrid mode) const watcher = (bridge as unknown as { watcher: fs.FSWatcher | null }).watcher; expect(watcher).not.toBeNull(); watcher!.emit('error', new Error('EPERM')); - // Write new state — polling should detect the change + // Watcher should be cleaned up after error + const bridgeInternal = bridge as unknown as { watcher: fs.FSWatcher | null }; + expect(bridgeInternal.watcher).toBeNull(); + + // Write new state — polling safety net should detect the change await sleep(100); writeHudState(hudFile, { currentMode: 'ACT' }); await sleep(300); @@ -122,6 +125,31 @@ describe('HudFileBridge', () => { expect(modeChanges[modeChanges.length - 1]).toEqual({ from: 'PLAN', to: 'ACT' }); }); + it('runs hybrid mode — both watch and poll active simultaneously', async () => { + writeHudState(hudFile, { currentMode: 'PLAN' }); + bridge = new HudFileBridge(eventBus, hudFile, { debounceMs: 10, pollIntervalMs: 50 }); + bridge.start(); + + const bridgeInternal = bridge as unknown as { + watcher: fs.FSWatcher | null; + pollInterval: ReturnType | null; + }; + + // Both watcher and polling should be active (hybrid mode) + expect(bridgeInternal.watcher).not.toBeNull(); + expect(bridgeInternal.pollInterval).not.toBeNull(); + + // Polling alone should detect changes even if fs.watch is silent + const modeChanges: Array<{ from: string | null; to: string }> = []; + eventBus.on(TUI_EVENTS.MODE_CHANGED, p => modeChanges.push(p)); + + writeHudState(hudFile, { currentMode: 'EVAL' }); + await sleep(300); + + expect(modeChanges.length).toBeGreaterThanOrEqual(1); + expect(modeChanges[modeChanges.length - 1]).toEqual({ from: 'PLAN', to: 'EVAL' }); + }); + it('handles malformed JSON gracefully', async () => { writeHudState(hudFile, { currentMode: 'PLAN' }); bridge = new HudFileBridge(eventBus, hudFile, { debounceMs: 10 }); diff --git a/apps/mcp-server/src/tui/events/hud-file-bridge.ts b/apps/mcp-server/src/tui/events/hud-file-bridge.ts index 86884234..9af03e5d 100644 --- a/apps/mcp-server/src/tui/events/hud-file-bridge.ts +++ b/apps/mcp-server/src/tui/events/hud-file-bridge.ts @@ -15,7 +15,7 @@ const VALID_MODES = new Set(['PLAN', 'ACT', 'EVAL', 'AUTO']); export interface HudFileBridgeOptions { /** Debounce interval in ms (default: 150) */ debounceMs?: number; - /** Polling interval in ms when fs.watch fails (default: 1000) */ + /** Polling interval in ms for hybrid safety-net polling (default: 200) */ pollIntervalMs?: number; } @@ -41,7 +41,7 @@ export class HudFileBridge { this.eventBus = eventBus; this.filePath = filePath; this.debounceMs = options?.debounceMs ?? 150; - this.pollIntervalMs = options?.pollIntervalMs ?? 1000; + this.pollIntervalMs = options?.pollIntervalMs ?? 200; } start(): void { @@ -61,11 +61,13 @@ export class HudFileBridge { if (!filename || filename === basename) this.scheduleProcess(); }); this.watcher.on('error', () => { - // Directory deleted or inaccessible — fall back to polling + // Watcher failed — close it; polling is already running as safety net this.watcher?.close(); this.watcher = null; - this.startPollingFallback(); + process.stderr.write('[codingbuddy] fs.watch failed, polling safety net active\n'); }); + // Hybrid mode: run polling alongside fs.watch as a silent safety net + this.startPollingFallback(true); } catch { // Directory doesn't exist yet — poll until it appears this.pollUntilExists(); @@ -135,9 +137,11 @@ export class HudFileBridge { } } - private startPollingFallback(): void { + private startPollingFallback(silent: boolean = false): void { if (this.stopped || this.pollInterval) return; - process.stderr.write('[codingbuddy] fs.watch failed, falling back to polling\n'); + if (!silent) { + process.stderr.write('[codingbuddy] fs.watch failed, falling back to polling\n'); + } try { const stat = fs.statSync(this.filePath); this.lastMtimeMs = stat.mtimeMs;