fix(coding-agent,mom): retry fs watchers on async errors closes #3564
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { type ExecFileException, execFile, spawnSync } from "child_process";
|
||||
import { existsSync, type FSWatcher, readFileSync, statSync, unwatchFile, watch, watchFile } from "fs";
|
||||
import { existsSync, type FSWatcher, readFileSync, statSync, unwatchFile, watchFile } from "fs";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import { closeWatcher, FS_WATCH_RETRY_DELAY_MS, watchWithErrorHandler } from "../utils/fs-watch.js";
|
||||
|
||||
type GitPaths = {
|
||||
repoDir: string;
|
||||
@@ -97,6 +98,7 @@ export class FooterDataProvider {
|
||||
private branchChangeCallbacks = new Set<() => void>();
|
||||
private availableProviderCount = 0;
|
||||
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private gitWatcherRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private refreshInFlight = false;
|
||||
private refreshPending = false;
|
||||
private disposed = false;
|
||||
@@ -160,22 +162,7 @@ export class FooterDataProvider {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
if (this.headWatcher) {
|
||||
this.headWatcher.close();
|
||||
this.headWatcher = null;
|
||||
}
|
||||
if (this.reftableWatcher) {
|
||||
this.reftableWatcher.close();
|
||||
this.reftableWatcher = null;
|
||||
}
|
||||
if (this.reftableTablesListWatcher) {
|
||||
this.reftableTablesListWatcher.close();
|
||||
this.reftableTablesListWatcher = null;
|
||||
}
|
||||
if (this.reftableTablesListPath) {
|
||||
unwatchFile(this.reftableTablesListPath);
|
||||
this.reftableTablesListPath = null;
|
||||
}
|
||||
this.clearGitWatchers();
|
||||
this.cachedBranch = undefined;
|
||||
this.gitPaths = findGitPaths(cwd);
|
||||
this.setupGitWatcher();
|
||||
@@ -189,22 +176,7 @@ export class FooterDataProvider {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
if (this.headWatcher) {
|
||||
this.headWatcher.close();
|
||||
this.headWatcher = null;
|
||||
}
|
||||
if (this.reftableWatcher) {
|
||||
this.reftableWatcher.close();
|
||||
this.reftableWatcher = null;
|
||||
}
|
||||
if (this.reftableTablesListWatcher) {
|
||||
this.reftableTablesListWatcher.close();
|
||||
this.reftableTablesListWatcher = null;
|
||||
}
|
||||
if (this.reftableTablesListPath) {
|
||||
unwatchFile(this.reftableTablesListPath);
|
||||
this.reftableTablesListPath = null;
|
||||
}
|
||||
this.clearGitWatchers();
|
||||
this.branchChangeCallbacks.clear();
|
||||
}
|
||||
|
||||
@@ -280,43 +252,86 @@ export class FooterDataProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private clearGitWatchers(): void {
|
||||
closeWatcher(this.headWatcher);
|
||||
this.headWatcher = null;
|
||||
closeWatcher(this.reftableWatcher);
|
||||
this.reftableWatcher = null;
|
||||
closeWatcher(this.reftableTablesListWatcher);
|
||||
this.reftableTablesListWatcher = null;
|
||||
if (this.reftableTablesListPath) {
|
||||
unwatchFile(this.reftableTablesListPath);
|
||||
this.reftableTablesListPath = null;
|
||||
}
|
||||
if (this.gitWatcherRetryTimer) {
|
||||
clearTimeout(this.gitWatcherRetryTimer);
|
||||
this.gitWatcherRetryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleGitWatcherRetry(): void {
|
||||
if (this.disposed || this.gitWatcherRetryTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.gitWatcherRetryTimer = setTimeout(() => {
|
||||
this.gitWatcherRetryTimer = null;
|
||||
this.setupGitWatcher();
|
||||
}, FS_WATCH_RETRY_DELAY_MS);
|
||||
}
|
||||
|
||||
private handleGitWatcherError(): void {
|
||||
this.clearGitWatchers();
|
||||
this.scheduleGitWatcherRetry();
|
||||
}
|
||||
|
||||
private setupGitWatcher(): void {
|
||||
this.clearGitWatchers();
|
||||
if (!this.gitPaths) return;
|
||||
|
||||
// Watch the directory containing HEAD, not HEAD itself.
|
||||
// Git uses atomic writes (write temp, rename over HEAD), which changes the inode.
|
||||
// fs.watch on a file stops working after the inode changes.
|
||||
try {
|
||||
this.headWatcher = watch(dirname(this.gitPaths.headPath), (_eventType, filename) => {
|
||||
if (!filename || filename.toString() === "HEAD") {
|
||||
this.headWatcher = watchWithErrorHandler(
|
||||
dirname(this.gitPaths.headPath),
|
||||
(_eventType, filename) => {
|
||||
if (!filename || filename === "HEAD") {
|
||||
this.scheduleRefresh();
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Silently fail if we can't watch
|
||||
},
|
||||
() => this.handleGitWatcherError(),
|
||||
);
|
||||
if (!this.headWatcher) {
|
||||
return;
|
||||
}
|
||||
|
||||
// In reftable repos, branch switches update files in the reftable directory
|
||||
// instead of HEAD. Watch it separately so the footer picks up those changes.
|
||||
const reftableDir = join(this.gitPaths.commonGitDir, "reftable");
|
||||
if (existsSync(reftableDir)) {
|
||||
try {
|
||||
this.reftableWatcher = watch(reftableDir, () => {
|
||||
this.reftableWatcher = watchWithErrorHandler(
|
||||
reftableDir,
|
||||
() => {
|
||||
this.scheduleRefresh();
|
||||
});
|
||||
} catch {
|
||||
// Silently fail if we can't watch
|
||||
},
|
||||
() => this.handleGitWatcherError(),
|
||||
);
|
||||
if (!this.reftableWatcher) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tablesListPath = join(reftableDir, "tables.list");
|
||||
if (existsSync(tablesListPath)) {
|
||||
this.reftableTablesListPath = tablesListPath;
|
||||
try {
|
||||
this.reftableTablesListWatcher = watch(tablesListPath, () => {
|
||||
this.reftableTablesListWatcher = watchWithErrorHandler(
|
||||
tablesListPath,
|
||||
() => {
|
||||
this.scheduleRefresh();
|
||||
});
|
||||
} catch {
|
||||
// Silently fail if we can't watch
|
||||
},
|
||||
() => this.handleGitWatcherError(),
|
||||
);
|
||||
if (!this.reftableTablesListWatcher) {
|
||||
return;
|
||||
}
|
||||
watchFile(tablesListPath, { interval: 250 }, (current, previous) => {
|
||||
if (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { type Static, Type } from "typebox";
|
||||
import { Compile } from "typebox/compile";
|
||||
import { getCustomThemesDir, getThemesDir } from "../../../config.js";
|
||||
import type { SourceInfo } from "../../../core/source-info.js";
|
||||
import { closeWatcher, watchWithErrorHandler } from "../../../utils/fs-watch.js";
|
||||
|
||||
// ============================================================================
|
||||
// Types & Schema
|
||||
@@ -789,32 +790,27 @@ function startThemeWatcher(): void {
|
||||
}, 100);
|
||||
};
|
||||
|
||||
try {
|
||||
themeWatcher = fs.watch(customThemesDir, (_eventType, filename) => {
|
||||
if (currentThemeName !== watchedThemeName) {
|
||||
return;
|
||||
}
|
||||
if (!filename) {
|
||||
themeWatcher =
|
||||
watchWithErrorHandler(
|
||||
customThemesDir,
|
||||
(_eventType, filename) => {
|
||||
if (currentThemeName !== watchedThemeName) {
|
||||
return;
|
||||
}
|
||||
if (!filename) {
|
||||
scheduleReload();
|
||||
return;
|
||||
}
|
||||
if (filename !== watchedFileName) {
|
||||
return;
|
||||
}
|
||||
scheduleReload();
|
||||
return;
|
||||
}
|
||||
const changedFile = String(filename);
|
||||
if (changedFile !== watchedFileName) {
|
||||
return;
|
||||
}
|
||||
scheduleReload();
|
||||
});
|
||||
themeWatcher.on("error", () => {
|
||||
try {
|
||||
themeWatcher?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
themeWatcher = undefined;
|
||||
});
|
||||
} catch (_error) {
|
||||
// Ignore errors starting watcher
|
||||
}
|
||||
},
|
||||
() => {
|
||||
closeWatcher(themeWatcher);
|
||||
themeWatcher = undefined;
|
||||
},
|
||||
) ?? undefined;
|
||||
}
|
||||
|
||||
export function stopThemeWatcher(): void {
|
||||
@@ -822,10 +818,8 @@ export function stopThemeWatcher(): void {
|
||||
clearTimeout(themeReloadTimer);
|
||||
themeReloadTimer = undefined;
|
||||
}
|
||||
if (themeWatcher) {
|
||||
themeWatcher.close();
|
||||
themeWatcher = undefined;
|
||||
}
|
||||
closeWatcher(themeWatcher);
|
||||
themeWatcher = undefined;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
30
packages/coding-agent/src/utils/fs-watch.ts
Normal file
30
packages/coding-agent/src/utils/fs-watch.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { type FSWatcher, type WatchListener, watch } from "node:fs";
|
||||
|
||||
export const FS_WATCH_RETRY_DELAY_MS = 5000;
|
||||
|
||||
export function closeWatcher(watcher: FSWatcher | null | undefined): void {
|
||||
if (!watcher) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
watcher.close();
|
||||
} catch {
|
||||
// Ignore watcher close errors
|
||||
}
|
||||
}
|
||||
|
||||
export function watchWithErrorHandler(
|
||||
path: string,
|
||||
listener: WatchListener<string>,
|
||||
onError: () => void,
|
||||
): FSWatcher | null {
|
||||
try {
|
||||
const watcher = watch(path, listener);
|
||||
watcher.on("error", onError);
|
||||
return watcher;
|
||||
} catch {
|
||||
onError();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user