diff --git a/CHANGELOG.md b/CHANGELOG.md index 4efb079ba..0bb0d1c54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - A program that uses CodeGraph as a library and indexes chosen files with `indexFiles()` now keeps other CodeGraph processes, like a `codegraph sync` run by a git hook, out of the index until those files are stored. Before, it let go of the write lock that keeps two writers apart as soon as indexing started, so another process could write to the index at the same time. - When several CodeGraph processes start at the same moment after one of them crashed, like the MCP server, a git hook's `codegraph sync` and `codegraph index`, only one of them now takes over the write lock the crashed process left behind. Before, two of them could both take it, the second one deleting the first one's lock as it did, and both went on to write to the index at once. A write lock held by another user's CodeGraph, or on Windows by one running as administrator, is no longer mistaken for a crashed one's and taken over either. Thanks @inth3shadows for the report and @vedantb7 for the first fix. (#2979) - A program that uses CodeGraph as a library and runs `indexAll()` again on a project it has already indexed now keeps every symbol findable by the words in its name, which the prompt hook and `codegraph_explore` use to match a question like "how does the order state machine work" to `OrderStateMachine`. Before, only the files changed since the last index kept their names' words, and a cancelled run lost them all; `codegraph index` was not affected. Thanks @rudycelekli. +- A program that uses CodeGraph as a library no longer crashes when a `watch()` callback it passed, `onSyncError` or `onDegraded`, throws: the error is logged, a failed sync still retries, and a watcher that gave up still stops. A throwing `onSyncComplete` no longer counts as a failed sync either. Thanks @johnhldiver-beep. (#2643) - The shared background server is no longer killed with "Main thread unresponsive" while it brings another project's index up to date, such as one a session names with `projectPath`: only its own project's index counted as progress, so a long update of any other looked like a hang, and the server was stopped and restarted again and again. Every index the server keeps up to date now counts, and so does the one a session updates on its own when the background server is unavailable. Thanks @mradwankhalil for the report and the first fix. (#2404) - A long-running session, like the background server behind your agent, now notices a framework that arrives or goes while it runs, such as Express in a new `package.json`, or one app replaced by another with as many files. Files it indexes from then on are read with the frameworks the project has now; before, they were read with the ones it had when the session started, so a new Express app's routes were missing until a full `codegraph index`. Files indexed before the change are not read again, so `codegraph status` now says which frameworks changed since the last full index and recommends running one, including when only `package.json` changed. Thanks @nk33-dev for the report and the first fix, and @inth3shadows for the `package.json` case. (#2516, #2519, #3007) - On Windows, deleting the folder a link in your project points to, like a junction to a library kept outside the project, no longer makes CodeGraph's server use a full CPU core until it is restarted, or stop picking up your edits. The linked files now drop out of the index while the rest of the project keeps updating, and if the project folder itself disappears while watched, live updates turn off with a message that says so. Thanks @L0garithmic. diff --git a/__tests__/watcher.test.ts b/__tests__/watcher.test.ts index c90c73c7f..c6bc0825c 100644 --- a/__tests__/watcher.test.ts +++ b/__tests__/watcher.test.ts @@ -978,6 +978,87 @@ describe('FileWatcher', () => { watcher.stop(); }); + + // These run from a timer, with no caller to catch a throw: out of flush() it + // is an unhandled rejection, which ends the host's process. + describe('one that throws', () => { + const unhandled: unknown[] = []; + const recordUnhandled = (reason: unknown) => { unhandled.push(reason); }; + const hostBug = () => { throw new Error('host bug'); }; + const warned = (spy: { mock: { calls: unknown[][] } }, text: string) => + spy.mock.calls.some((c) => typeof c[0] === 'string' && c[0].includes(text)); + + beforeEach(() => { + unhandled.length = 0; + process.on('unhandledRejection', recordUnhandled); + }); + + afterEach(() => { + process.off('unhandledRejection', recordUnhandled); + }); + + it('leaves a failed sync to its retry when onSyncError throws', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const syncFn = vi.fn() + .mockRejectedValueOnce(new Error('disk full')) + .mockResolvedValue({ filesChanged: 1, durationMs: 1 }); + const onSyncComplete = vi.fn(); + const onSyncError = vi.fn(hostBug); + const watcher = newWatcher(syncFn, { debounceMs: 25, onSyncComplete, onSyncError }); + + watcher.start(); + await watcher.waitUntilReady(); + __emitWatchEventForTests(testDir, 'src/retry.ts'); + + await waitFor(() => onSyncComplete.mock.calls.length > 0, 8000, 10); + expect(onSyncError).toHaveBeenCalledTimes(1); + expect(syncFn).toHaveBeenCalledTimes(2); + expect(unhandled).toEqual([]); + expect(warned(warnSpy, 'onSyncError')).toBe(true); + }); + + it('still stops a degrading watcher when onDegraded throws', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const syncFn = vi.fn().mockRejectedValue(new LockUnavailableError()); + const onDegraded = vi.fn(hostBug); + const watcher = newWatcher(syncFn, { debounceMs: 25, onDegraded }); + + watcher.start(); + await watcher.waitUntilReady(); + __emitWatchEventForTests(testDir, 'src/long-lock.ts'); + + await waitFor(() => onDegraded.mock.calls.length > 0, 8000, 10); + await waitFor(() => !watcher.isActive(), 1000, 10); + const syncs = syncFn.mock.calls.length; + await new Promise((resolve) => setTimeout(resolve, 150)); + + expect(watcher.isDegraded()).toBe(true); + expect(syncFn).toHaveBeenCalledTimes(syncs); // stopped means no more retries + expect(onDegraded).toHaveBeenCalledTimes(1); + expect(unhandled).toEqual([]); + expect(warned(warnSpy, 'onDegraded')).toBe(true); + }); + + it('keeps a sync that worked a sync that worked when onSyncComplete throws', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const syncFn = vi.fn().mockResolvedValue({ filesChanged: 1, durationMs: 1 }); + const onSyncComplete = vi.fn(hostBug); + const onSyncError = vi.fn(); + const watcher = newWatcher(syncFn, { debounceMs: 25, onSyncComplete, onSyncError }); + + watcher.start(); + await watcher.waitUntilReady(); + __emitWatchEventForTests(testDir, 'src/ok.ts'); + + await waitFor(() => onSyncComplete.mock.calls.length > 0, 8000, 10); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(onSyncError).not.toHaveBeenCalled(); + expect(syncFn).toHaveBeenCalledTimes(1); + expect(watcher.isActive()).toBe(true); + expect(warned(warnSpy, 'onSyncComplete')).toBe(true); + }); + }); }); describe('CodeGraph integration', () => { diff --git a/src/sync/watcher.ts b/src/sync/watcher.ts index bae5d18c7..130730833 100644 --- a/src/sync/watcher.ts +++ b/src/sync/watcher.ts @@ -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