fix(watcher): release DashMap guard before insert to stop file-event deadlock - #83
Open
hindog wants to merge 1 commit into
Open
fix(watcher): release DashMap guard before insert to stop file-event deadlock#83hindog wants to merge 1 commit into
hindog wants to merge 1 commit into
Conversation
…deadlock
`process_fs_event`'s Modify branch held a DashMap read guard across a write:
if let Some(old_metadata) = file_registry.get(&file_id) {
let old_metadata = old_metadata.value().clone();
if old_metadata.content_hash != new_metadata.content_hash {
file_registry.insert(file_id.clone(), new_metadata.clone());
`DashMap::get` returns a `Ref` holding a read lock on the key's shard. The
`let old_metadata = ...clone()` line looks like it releases that guard, but
shadowing a binding does not drop the original value — the `Ref` lives to the
end of the `if let` block. The `insert` then blocks forever waiting for a
write lock on the shard the same task is still read-locking.
The task hangs, no `FileChangeEvent::Modified` is ever sent, and the shard
stays locked, so every later event for a file on it is lost too. Net effect:
watch-mode auto-reindexing silently stops working.
It looked intermittent because macOS emits both `Create(File)` and
`Modify(Data(Content))` for a single append, spawned as concurrent tasks. When
`Create` won the race it inserted first, so `Modify` saw equal hashes, skipped
the deadlocking branch, and `Create`'s unconditional send still delivered an
event — the first edit often appeared to work. In steady state only `Modify`
arrives and the watcher wedges.
Fix: clone out of the registry in a single statement so the temporary guard is
dropped at the semicolon, then match on the owned `Option`. Matching on an
owned value (rather than `if let Some(..) = registry.get(..)`) also keeps a
future edit from silently re-holding the guard across the branch.
Verified on macOS with a release build: three successive edits to a watched
file produced zero reindexes before the change and three after, via both
`codegraph daemon start` and `codegraph start --watch`.
Note: the existing tests cannot catch this. `best_effort_poll_for_changes` is
`#[cfg(test)]`, so when no event arrives `next_batch` falls back to polling and
synthesizes `Modified` events — tests pass whether or not event delivery works
in a release binary.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
process_fs_event'sModifybranch incrates/codegraph-parser/src/watcher.rsheld a DashMap read guard across a write to the same map:
DashMap::getreturns aRefthat holds a read lock on the key's shard. Thelet old_metadata = old_metadata.value().clone();line looks like it releasesthat guard, but shadowing a binding does not drop the original value — the
Reflives to the end of theif letblock. Theinsertthen blocks foreverwaiting for a write lock on the very shard the same task is still read-locking.
The spawned task hangs, no
FileChangeEvent::Modifiedis ever sent, and theshard stays locked, so subsequent events for any file on it are lost too.
The user-visible effect is that watch-mode auto-reindexing silently stops
working — the daemon reports
Status: Runningwhile doing nothing.Why it presents as intermittent
macOS emits both
Create(File)andModify(Data(Content))for a singleappend, and each is
spawned as a concurrent task. IfCreatewins the raceit inserts first, so
Modifythen seesold == new, skips the deadlockingbranch entirely, and
Create's unconditional send still delivers an event.That is why a first edit often appears to work. In steady state only
Modifyarrives, and the watcher wedges permanently.
The fix
Clone out of the registry in a single statement so the temporary guard is
dropped at the semicolon, then match on the owned
Option. Matching on anowned value — rather than
if let Some(..) = file_registry.get(..)— alsoprevents a future edit from silently re-holding the guard across the branch.
Verification
macOS 15 (arm64), release build,
--all-features. Three successive edits to awatched
.rsfile, measured by whether the new symbols appear in thenodestable:
codegraph daemon startcodegraph start --watchThe deadlock itself was confirmed directly by instrumenting both sides of the
insert: the "about to insert" log fires, the "insert returned" log neverdoes, and the registry stays frozen at the stale hash while further edits pile
up behind it.
Note on test coverage
The existing tests cannot catch this class of bug.
best_effort_poll_for_changesis
#[cfg(test)], so when no filesystem event arrives,next_batchfalls backto polling the registry and synthesises
Modifiedevents. Tests therefore passwhether or not real event delivery works in a release binary. Worth considering
a test that exercises the release event path, though I have not added one here.
Unrelated issues noticed while debugging
Not addressed in this PR, but flagging them since they made this bug much
harder to find:
daemon startpath.main()installs asubscriber only inside
handle_start, so everyinfo!/error!emitted bythe daemon, watcher, and indexer on the
codegraph daemonpath is silentlydiscarded — including failures.
codegraph daemon start --foreground -v --debugwith
RUST_LOG=debugstill prints only its two startup banner lines.handle_daemon_starthardcodes its debounce.debounce_ms: 30andbatch_timeout_ms: 200are struct literals rather than being read from the[daemon]section ofconfig.toml, andexclude_patternscomes from theCLI flag (empty by default), so
target/is not excluded on that path.The
start --watchpath does read the config.🤖 Generated with Claude Code