[BUG] Race conditions in teamMemorySync watcher: shutdown may miss pending pushes or leak timers

Resolved 💬 3 comments Opened Apr 6, 2026 by lihaokun Closed Jun 1, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Two race conditions exist in src/services/teamMemorySync/watcher.ts. During shutdown, pending team memory changes may be silently lost, or a debounce timer may leak and fire a pushTeamMemory() call after the watcher has already stopped.

Note: This analysis is based on source code from v2.1.88. We do not have access to the current version's source to verify whether these issues persist. Line numbers below refer to that version.

Race A: hasPendingChanges lost update during shutdown

schedulePush() sets hasPendingChanges = true, and executePush() clears it to false. When stopTeamMemoryWatcher() runs concurrently with in-flight pushes, the following interleaving is possible:

  1. T1: schedulePush() → sets hasPendingChanges = true, arms debounce timer
  2. T2: schedulePush() → sets hasPendingChanges = true again
  3. T3: stopTeamMemoryWatcher() reads hasPendingChanges — but a push already in flight may clear it before the stop handler acts on it, or the stop handler may read true while the push has already completed

Result: pending changes silently lost on shutdown, or redundant double push.

Relevant locations (v2.1.88):

  • Line 146: schedulePush() writes hasPendingChanges = true
  • Line 98: executePush() writes hasPendingChanges = false
  • Line 374: stopTeamMemoryWatcher() reads hasPendingChanges

Race B: debounceTimer leak across shutdown

schedulePush() clears and re-arms debounceTimer. stopTeamMemoryWatcher() also clears debounceTimer. When a notifyTeamMemoryWrite() call arrives during shutdown:

  1. stopTeamMemoryWatcher() clears the current debounceTimer
  2. A concurrent notifyTeamMemoryWrite()schedulePush() sets a new debounceTimer after the stop function has already cleared the old one
  3. The stop function completes, unaware of the new timer
  4. After the 2s debounce, pushTeamMemory() fires on a stopped watcher

Result: an HTTP push request fires after the watcher has been stopped, racing with process exit.

Relevant locations (v2.1.88):

  • Lines 147–150: schedulePush() reads/clears debounceTimer
  • Lines 152–161: schedulePush() sets new debounceTimer
  • Lines 352–356: stopTeamMemoryWatcher() reads/clears debounceTimer

What Should Happen?

After stopTeamMemoryWatcher() returns, no further pushTeamMemory() calls should fire. All pending changes at shutdown should either be flushed before stop completes or explicitly discarded with a warning — never silently lost.

Error Messages/Logs

No error messages — the symptoms are silent (lost writes or post-shutdown push requests).

Steps to Reproduce

Race B is reproduced by injecting a notifyTeamMemoryWrite() call during stopTeamMemoryWatcher() execution:

  1. Start the team memory watcher normally
  2. Trigger a notifyTeamMemoryWrite() so a debounce timer is armed
  3. Call stopTeamMemoryWatcher() — it clears the current timer
  4. During stop execution, trigger another notifyTeamMemoryWrite() — this arms a new timer that stop doesn't know about
  5. Wait 2.5 seconds after stop returns

Observed:

  • Push count before wait: 1
  • Push count after wait: 2 (leaked timer fired)
  • stopTeamMemoryWatcher() had already returned: true

Reproduction rate: 1/1 (100%)

A minimal repro test is available — happy to share or open a PR with it.

Claude Model

None

Is this a regression?

I don't know

Last Working Version

2.1.88

Claude Code Version

2.1.88

Platform

Anthropic API

Operating System

Ubuntu/Debian Linux

Terminal/Shell

WSL (Windows Subsystem for Linux)

Additional Information

Suggested fix direction: Use an AbortController signal scoped to the watcher lifecycle, so all pending timers and async operations are cancelled on stop, rather than tracking individual timer references. The hasPendingChanges flag could be replaced with an atomic counter or guarded by an async mutex to prevent lost updates.

---

Found by Haokun Li, Xiaoxue Ma, and Claude Code via formal concurrency interleaving analysis.

View original on GitHub ↗

This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗