Skip to content

fix(watcher): release DashMap guard before insert to stop file-event deadlock - #83

Open
hindog wants to merge 1 commit into
Jakedismo:mainfrom
hindog:fix/watcher-dashmap-deadlock
Open

fix(watcher): release DashMap guard before insert to stop file-event deadlock#83
hindog wants to merge 1 commit into
Jakedismo:mainfrom
hindog:fix/watcher-dashmap-deadlock

Conversation

@hindog

@hindog hindog commented Jul 27, 2026

Copy link
Copy Markdown

The bug

process_fs_event's Modify branch in crates/codegraph-parser/src/watcher.rs
held a DashMap read guard across a write to the same map:

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 that holds a read lock on the key's shard. The
let old_metadata = old_metadata.value().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 very shard the same task is still read-locking.

The spawned task hangs, no FileChangeEvent::Modified is ever sent, and the
shard 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: Running while doing nothing.

Why it presents as intermittent

macOS emits both Create(File) and Modify(Data(Content)) for a single
append, and each is spawned as a concurrent task. If Create wins the race
it inserts first, so Modify then sees old == new, skips the deadlocking
branch entirely, and Create's unconditional send still delivers an event.
That is why a first edit often appears to work. In steady state only Modify
arrives, 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 an
owned value — rather than if let Some(..) = file_registry.get(..) — also
prevents a future edit from silently re-holding the guard across the branch.

Verification

macOS 15 (arm64), release build, --all-features. Three successive edits to a
watched .rs file, measured by whether the new symbols appear in the nodes
table:

Entry point Before After
codegraph daemon start 0 / 3 reindexed 3 / 3
codegraph start --watch 0 / 3 reindexed 3 / 3

The deadlock itself was confirmed directly by instrumenting both sides of the
insert: the "about to insert" log fires, the "insert returned" log never
does, 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_changes
is #[cfg(test)], so when no filesystem event arrives, next_batch falls back
to polling the registry and synthesises Modified events. Tests therefore pass
whether 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:

  1. No tracing subscriber on the daemon start path. main() installs a
    subscriber only inside handle_start, so every info!/error! emitted by
    the daemon, watcher, and indexer on the codegraph daemon path is silently
    discarded — including failures. codegraph daemon start --foreground -v --debug
    with RUST_LOG=debug still prints only its two startup banner lines.
  2. handle_daemon_start hardcodes its debounce. debounce_ms: 30 and
    batch_timeout_ms: 200 are struct literals rather than being read from the
    [daemon] section of config.toml, and exclude_patterns comes from the
    CLI flag (empty by default), so target/ is not excluded on that path.
    The start --watch path does read the config.

🤖 Generated with Claude Code

…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant