fix(watch): a watch callback that throws no longer ends the host's process (#3092)

A program that uses CodeGraph as a library passes callbacks to watch().
FileWatcher.flush() runs from a timer with no caller to catch anything, and it
called onSyncError and onDegraded (through degrade()) in its catch block. A
throw from either left flush() as an unhandled rejection, which ends the host
process with exit 1. A throw from onDegraded also skipped the stop() that
degrade() owes, so a watcher reporting itself degraded kept retrying. A throw
from onSyncComplete sat inside the try, so it counted a sync that worked as a
failed one and called onSyncError with an error that was no sync failure.

All three now go through one guard, callHost(), which logs the throw as a
warning and changes nothing else. The CLI and MCP server are not affected:
their callbacks only write to stderr. Only a host's own callback can reach this.

Validation:
- Three new tests in watcher.test.ts fail on main and pass here (64/64 in the
  file). Putting each guard back one at a time fails exactly its own test.
- A host script on the public API (watch() with a throwing onSyncComplete and
  onSyncError, then a real file edit): main exits 1, this branch logs the throw
  and keeps running, exit 0.

PR #3074 listed ten lint findings; this keeps the two watcher call sites'
purpose and leaves out the rest, which change no behaviour:
- flushOrdered(): flushChain's body catches everything, so it cannot reject.
- stop() from timers and signals: nothing stop() awaits can reject today, and
  a forced rejection already exits (exit 1, one fatal line).
- await on getCodeGraph and the upgrade functions: plain values, nothing else
  runs in that microtask.
- { cause } on the rebuild and daemon-lock errors: the rebuild message already
  carries the reason, and nothing reads or prints cause.

Reimplements PR #3074 by @johnhldiver-beep. Refs issue 2643.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-30 18:19:56 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 17d5426d63
commit 22cf7d3aa1
3 changed files with 100 additions and 3 deletions
+18 -3
View File
@@ -853,6 +853,21 @@ export class FileWatcher {
return this.ignoreMatcher.ignores(rel + '/');
}
/**
* Call one of the host's {@link WatchOptions} callbacks. They are diagnostics:
* one that throws is logged and changes nothing here. A sync runs from a timer,
* so a throw would have no caller and end the host's process as an unhandled
* rejection. It would also read as a failed sync, or skip the stop that a
* degrade owes.
*/
private callHost(name: string, call: () => void): void {
try {
call();
} catch (err) {
logWarn(`File watcher ${name} callback threw`, { error: err instanceof Error ? err.message : String(err) });
}
}
/**
* Disable live watching after a terminal runtime failure
* (watch-resource exhaustion, lock contention past the retry budget, or a
@@ -866,7 +881,7 @@ export class FileWatcher {
this.degradedByLock = byLock;
this.recoveringFromLock = false;
logWarn('File watcher disabled', { projectRoot: this.projectRoot, reason, ...context });
this.onDegraded?.(reason);
this.callHost('onDegraded', () => this.onDegraded?.(reason));
this.stop();
}
@@ -1114,7 +1129,7 @@ export class FileWatcher {
this.pendingFiles.delete(filePath);
}
}
this.onSyncComplete?.(result);
this.callHost('onSyncComplete', () => this.onSyncComplete?.(result));
} catch (err) {
if (!scoped) this.needsFullScan = true;
if (err instanceof LockUnavailableError) {
@@ -1145,7 +1160,7 @@ export class FileWatcher {
error: error.message,
retryCount: this.syncFailureRetryCount,
});
this.onSyncError?.(error);
this.callHost('onSyncError', () => this.onSyncError?.(error));
// A persistent (deterministic) sync failure — a broken extractor on a
// specific file, DB corruption, SQLITE_FULL, an OOM in resolution —
// would otherwise retry forever at the debounce cadence, spamming logs