-
Notifications
You must be signed in to change notification settings - Fork 75
Fix file watcher robustness: dir watching, cleanup, suppression #124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -24,6 +24,8 @@ export class DaemonServer { | |||||||||
| private socketToSession = new Map<net.Socket, string>(); | ||||||||||
| private socketPath: string; | ||||||||||
| private watchers: FSWatcher[] = []; | ||||||||||
| private debounceTimers: ReturnType<typeof setTimeout>[] = []; | ||||||||||
| private suppressConfigReload = false; | ||||||||||
|
|
||||||||||
| constructor() { | ||||||||||
| this.socketPath = getSocketPath(); | ||||||||||
|
|
@@ -83,53 +85,63 @@ export class DaemonServer { | |||||||||
|
|
||||||||||
| private startFileWatchers(): void { | ||||||||||
| const dataDir = getDataDir(); | ||||||||||
| const configPath = join(dataDir, 'config.json'); | ||||||||||
| const trustPath = join(dataDir, 'trust.json'); | ||||||||||
|
|
||||||||||
| const watchFile = (filePath: string, label: string, onChange: () => void) => { | ||||||||||
| if (!existsSync(filePath)) return; | ||||||||||
| try { | ||||||||||
| let debounceTimer: ReturnType<typeof setTimeout> | null = null; | ||||||||||
| const watcher = watch(filePath, () => { | ||||||||||
| // Debounce: editors often write files in multiple steps | ||||||||||
| if (debounceTimer) clearTimeout(debounceTimer); | ||||||||||
| debounceTimer = setTimeout(() => { | ||||||||||
| log.info({ file: label }, 'File changed, reloading'); | ||||||||||
| onChange(); | ||||||||||
| }, 200); | ||||||||||
| }); | ||||||||||
| this.watchers.push(watcher); | ||||||||||
| log.info({ file: label }, 'Watching for changes'); | ||||||||||
| } catch (err) { | ||||||||||
| log.warn({ err, file: label }, 'Failed to watch file'); | ||||||||||
| } | ||||||||||
| // Watch the data directory instead of individual files so we detect: | ||||||||||
| // - Files that don't exist yet at startup (new config/trust creation) | ||||||||||
| // - Atomic rename writes (trust-store uses renameSync) | ||||||||||
| const handlers: Record<string, () => void> = { | ||||||||||
| 'config.json': () => { | ||||||||||
| if (this.suppressConfigReload) return; | ||||||||||
| invalidateConfigCache(); | ||||||||||
| try { | ||||||||||
| const config = getConfig(); | ||||||||||
| initializeProviders(config); | ||||||||||
| } catch (err) { | ||||||||||
| log.error({ err }, 'Failed to reload config'); | ||||||||||
| return; | ||||||||||
| } | ||||||||||
| // Evict idle sessions; mark busy ones as stale | ||||||||||
| for (const [id, session] of this.sessions) { | ||||||||||
| if (!session.isProcessing()) { | ||||||||||
| this.sessions.delete(id); | ||||||||||
| } else { | ||||||||||
| session.markStale(); | ||||||||||
| } | ||||||||||
| } | ||||||||||
| }, | ||||||||||
| 'trust.json': () => { | ||||||||||
| clearTrustCache(); | ||||||||||
| }, | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| watchFile(configPath, 'config.json', () => { | ||||||||||
| invalidateConfigCache(); | ||||||||||
| try { | ||||||||||
| const config = getConfig(); | ||||||||||
| initializeProviders(config); | ||||||||||
| } catch (err) { | ||||||||||
| log.error({ err }, 'Failed to reload config'); | ||||||||||
| return; | ||||||||||
| } | ||||||||||
| // Evict idle sessions; mark busy ones as stale | ||||||||||
| for (const [id, session] of this.sessions) { | ||||||||||
| if (!session.isProcessing()) { | ||||||||||
| this.sessions.delete(id); | ||||||||||
| } else { | ||||||||||
| session.markStale(); | ||||||||||
| } | ||||||||||
| } | ||||||||||
| }); | ||||||||||
| const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>(); | ||||||||||
|
|
||||||||||
| watchFile(trustPath, 'trust.json', () => { | ||||||||||
| clearTrustCache(); | ||||||||||
| }); | ||||||||||
| try { | ||||||||||
| const watcher = watch(dataDir, (_eventType, filename) => { | ||||||||||
| if (!filename || !handlers[filename]) return; | ||||||||||
| // Debounce: editors often write files in multiple steps | ||||||||||
| const existing = debounceTimers.get(filename); | ||||||||||
| if (existing) clearTimeout(existing); | ||||||||||
| const timer = setTimeout(() => { | ||||||||||
| debounceTimers.delete(filename); | ||||||||||
| log.info({ file: filename }, 'File changed, reloading'); | ||||||||||
| handlers[filename](); | ||||||||||
| }, 200); | ||||||||||
| debounceTimers.set(filename, timer); | ||||||||||
| this.debounceTimers.push(timer); | ||||||||||
| }); | ||||||||||
| this.watchers.push(watcher); | ||||||||||
| log.info({ dir: dataDir }, 'Watching data directory for config/trust changes'); | ||||||||||
| } catch (err) { | ||||||||||
| log.warn({ err }, 'Failed to watch data directory'); | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| private stopFileWatchers(): void { | ||||||||||
| for (const timer of this.debounceTimers) { | ||||||||||
| clearTimeout(timer); | ||||||||||
| } | ||||||||||
| this.debounceTimers = []; | ||||||||||
| for (const watcher of this.watchers) { | ||||||||||
| watcher.close(); | ||||||||||
| } | ||||||||||
|
|
@@ -351,7 +363,13 @@ export class DaemonServer { | |||||||||
| // Use raw config to avoid persisting env-var API keys to disk | ||||||||||
| const raw = loadRawConfig(); | ||||||||||
| raw.model = model; | ||||||||||
|
|
||||||||||
| // Suppress the file watcher callback — handleModelSet already does | ||||||||||
| // the full reload sequence; a redundant watcher-triggered reload | ||||||||||
| // would incorrectly evict sessions created after this method returns. | ||||||||||
| this.suppressConfigReload = true; | ||||||||||
| saveRawConfig(raw); | ||||||||||
|
Comment on lines
+370
to
371
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||||||||||
| setTimeout(() => { this.suppressConfigReload = false; }, 300); | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The Detailed ExplanationIn this.suppressConfigReload = true;
saveRawConfig(raw);
setTimeout(() => { this.suppressConfigReload = false; }, 300);This PR specifically aims to fix use-after-teardown issues from timers firing after The practical impact is minimal (it just sets a boolean to Impact: Minor inconsistency — timer fires after server stop, but only sets a boolean with no side effects on a stopped server.
Suggested change
Was this helpful? React with 👍 or 👎 to provide feedback. |
||||||||||
|
|
||||||||||
| // Re-initialize provider with the new model so LLM calls use it | ||||||||||
| const config = getConfig(); | ||||||||||
|
|
||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡
debounceTimersarray grows without bound (memory leak)Every file-change event pushes a new timer to
this.debounceTimers(line 131), but completed or replaced timers are never removed from the array. The localdebounceTimersMap correctly deduplicates per filename, but the instance-level array only accumulates.Root Cause and Impact
When a watched file changes, the handler at
assistant/src/daemon/server.ts:125-131creates a newsetTimeoutand pushes it tothis.debounceTimers. If the same file changes again within the debounce window, the old timer is cleared via the local Map (debounceTimers.delete/clearTimeout), but its reference remains inthis.debounceTimers. Over the lifetime of a long-running daemon, every single fs event adds an entry:Additionally,
stopFileWatchers()callsclearTimeouton every entry including already-fired timers (harmless but wasteful), and the array is only cleared onstop(). For a daemon that runs for days/weeks, this is an unbounded memory leak proportional to the number of file-change events received.Impact: Memory leak in long-running daemon processes. Each fs event leaks a timer ID reference that is never reclaimed until server stop.
Prompt for agents
Was this helpful? React with 👍 or 👎 to provide feedback.