Fix debounce timer memory leak and suppress timer cleanup#127
Conversation
Address review feedback on PR #124: - Replace debounceTimers array with Map instance field to prevent unbounded growth — the Map naturally deduplicates by filename - Track the suppressConfigReload reset timer in the Map so it gets cancelled on server stop - Reset suppressConfigReload immediately if saveRawConfig throws, preventing hot-reload from staying permanently disabled - Remove unused `join` import Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| const resetTimer = setTimeout(() => { this.suppressConfigReload = false; }, 300); | ||
| this.debounceTimers.set('__suppress_reset__', resetTimer); |
There was a problem hiding this comment.
🟡 Missing clearTimeout before overwriting __suppress_reset__ timer in debounceTimers map
When handleModelSet is called multiple times in quick succession, the previous __suppress_reset__ timer is overwritten in the map without being cancelled first. The orphaned timer still fires and prematurely resets suppressConfigReload to false, allowing the file watcher to trigger a redundant config reload that evicts sessions.
Root Cause
At assistant/src/daemon/server.ts:377-378, a new timer is created and stored in the map:
const resetTimer = setTimeout(() => { this.suppressConfigReload = false; }, 300);
this.debounceTimers.set('__suppress_reset__', resetTimer);But unlike the file watcher debounce code at assistant/src/daemon/server.ts:121-122 which properly clears existing timers:
const existing = this.debounceTimers.get(filename);
if (existing) clearTimeout(existing);…the handleModelSet method does not clear the previous __suppress_reset__ timer before setting a new one.
Scenario:
- First
handleModelSetcall: setssuppressConfigReload = true, creates timer A (300ms), stores as__suppress_reset__ - Second
handleModelSetcall (within 300ms): setssuppressConfigReload = true, creates timer B (300ms), overwrites timer A in the map - Timer A fires (~300ms after first call): sets
suppressConfigReload = falseprematurely - File watcher event from the second write arrives:
suppressConfigReloadis alreadyfalse, so it triggers a redundant reload that evicts sessions
Impact: Rapid model changes can cause unexpected session eviction due to the suppression window ending too early.
| const resetTimer = setTimeout(() => { this.suppressConfigReload = false; }, 300); | |
| this.debounceTimers.set('__suppress_reset__', resetTimer); | |
| const existingSuppressTimer = this.debounceTimers.get('__suppress_reset__'); | |
| if (existingSuppressTimer) clearTimeout(existingSuppressTimer); | |
| const resetTimer = setTimeout(() => { this.suppressConfigReload = false; }, 300); | |
| this.debounceTimers.set('__suppress_reset__', resetTimer); |
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
debounceTimersarray with aMap<string, Timer>instance field — the Map naturally deduplicates by filename, preventing unbounded growth from repeated file-change eventssuppressConfigReloadreset timer in the Map so it gets cancelled on server stopsuppressConfigReloadimmediately ifsaveRawConfigthrows, preventing hot-reload from staying permanently disabled after a failed model updatejoinimportContext
Addresses all 3 review comments from Devin and Codex on #124:
this.debounceTimers.push(timer)accumulated timer refs without cleanup — replaced array with Map instance field that naturally deduplicatessuppressConfigReloadreset timer wasn't tracked for cancellation on server stop — now stored in the debounce MapsaveRawConfigthrew,suppressConfigReloadstayedtrueforever — now wrapped in try/catch that resets the flag on failureTest plan
npx tsc --noEmitpassesbun testpasses (212 tests)🤖 Generated with Claude Code