fix(watcher): retain pending files on zero-result sync (#450)
* fix(watcher): retain pending files on zero-result sync
* refactor(watcher): detect lock-unavailable at the wrapper
Replace the heuristic `(filesChanged === 0 && durationMs === 0)` check
inside `FileWatcher.flush()` with a typed `LockUnavailableError` thrown
by `CodeGraph.watch()`'s sync wrapper. The wrapper has access to the
full `SyncResult`, including `filesChecked` — which is **only** zero
when `sync()` failed to acquire the cross-process file lock (a real
empty sync always has `filesChecked > 0` because `scanDirectory` ran).
That eliminates the heuristic's edge case where a fast no-op sync
returns `durationMs === 0` by `Date.now()` rounding and gets mistaken
for a lock failure on tiny projects.
The watcher's `catch` block now distinguishes `LockUnavailableError`
from real errors: it logs at `logDebug` (not `logWarn`) and does NOT
call `onSyncError` — so a long-running external indexer holding the
lock doesn't spam stderr every debounce cycle via the MCP daemon's
`Auto-sync error` handler. The existing post-catch path already
preserves `pendingFiles` and reschedules, so no new control flow is
needed.
A/B validated end-to-end against the built dist on macOS with a
three-scenario repro (lock held, lock released mid-flight, real sync
error):
- main: lock-held silently clears pendingFiles (BUG);
lock-released never recovers (no real sync runs).
- PR-as-is: lock-held preserves pendingFiles; lock-released
drains. Same observable behavior as wrapper-level.
- wrapper-level: same outcomes; lock-failure goes through the catch
path silently (logDebug only, no onSyncError noise);
real errors still surface via onSyncError.
Updates the regression test to throw `LockUnavailableError` (the real
contract surfaced to `FileWatcher` by `CodeGraph.watch()`), and
asserts `onSyncError` stays quiet during the lock-held cycle.
Closes #449.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Colby McHenry <me@colbymchenry.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
Colby McHenry
parent
6015e4fdd2
commit
72c08c2bef
+26
-3
@@ -45,6 +45,20 @@ export interface WatchOptions {
|
||||
onSyncError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by a `syncFn` to signal that the underlying sync couldn't acquire
|
||||
* the cross-process write lock (#449). The watcher treats this as "no
|
||||
* progress" — preserves `pendingFiles`, skips `onSyncComplete`, and the
|
||||
* `finally` block reschedules. Quiet (debug-only) because a long-running
|
||||
* external indexer can hit this every debounce cycle.
|
||||
*/
|
||||
export class LockUnavailableError extends Error {
|
||||
constructor(message = 'CodeGraph file lock unavailable; another process is writing') {
|
||||
super(message);
|
||||
this.name = 'LockUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-file pending entry — tracks a source file the watcher saw an event for
|
||||
* but hasn't yet synced into the index. Exposed via {@link FileWatcher.getPendingFiles}
|
||||
@@ -352,11 +366,20 @@ export class FileWatcher {
|
||||
}
|
||||
this.onSyncComplete?.(result);
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
logWarn('Watch sync failed', { error: error.message });
|
||||
if (err instanceof LockUnavailableError) {
|
||||
// Lock-failure no-op (another writer holds the lock). pendingFiles
|
||||
// stays intact and the `finally` block reschedules. Debug-only —
|
||||
// a long external index would otherwise spam stderr every cycle.
|
||||
logDebug('Watch sync skipped: file lock unavailable', {
|
||||
pendingFiles: this.pendingFiles.size,
|
||||
});
|
||||
} else {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
logWarn('Watch sync failed', { error: error.message });
|
||||
this.onSyncError?.(error);
|
||||
}
|
||||
// Failure: leave pendingFiles untouched. Every edit it tracks is
|
||||
// still unindexed; the rescheduled sync sees the same set.
|
||||
this.onSyncError?.(error);
|
||||
} finally {
|
||||
this.syncing = false;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user