From 4fa1db8d068a88ff29a7856ef0854b744d65b94c Mon Sep 17 00:00:00 2001 From: xuzhe <312223105@qq.com> Date: Thu, 7 May 2026 19:17:33 +0800 Subject: [PATCH] fix(server): stop Linux watcher event storms on busy working trees (#794) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Linux fs.watch isn't recursive, so each working tree manually walks the entire subtree and registers a per-directory watcher. Every inotify event then re-scans the tree and rebuilds watchers. A working tree containing a directory that's continuously written (e.g. test-runtime data, build artefacts) drives the event loop to 100% CPU and the daemon stops responding on its websocket. Skip git-ignored directories when walking — load `git ls-files -o -i --directory --exclude-standard` once per root (5 min cache) and skip its entries. Cap the walked directory count at 5000 so a misconfigured repo can't blow past the inotify budget. Add a 2s cooldown between refresh passes so a flurry of events collapses into one re-scan instead of a tight loop. Co-authored-by: xuzhe.7766 --- .../src/server/workspace-git-service.ts | 62 ++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 1fca27249..e949728c3 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -40,6 +40,9 @@ const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000; const WORKSPACE_GIT_CONSUMER_TTL_MS = 15_000; // Non-forced refresh triggers share this minimum gap to absorb watcher/self-heal bursts; force bypasses it. const WORKSPACE_GIT_INTERNAL_MIN_GAP_MS = 2_000; +const LINUX_WATCH_MAX_DIRS = 5_000; +const LINUX_WATCH_REFRESH_COOLDOWN_MS = 2_000; +const LINUX_WATCH_IGNORE_TTL_MS = 5 * 60 * 1_000; const linuxWatchReaddirConcurrency = parseInt(process.env.PASEO_LINUX_WATCH_READDIR_CONCURRENCY ?? "16", 10) || 16; @@ -323,6 +326,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private readonly repoTargets = new Map(); private readonly workingTreeWatchTargets = new Map(); private readonly workingTreeWatchSetups = new Map>(); + private readonly linuxIgnoredDirsCache = new Map; ts: number }>(); private readonly branchValidationCache = new Map< string, WorkspaceGitAuxiliaryReadCacheEntry @@ -1195,6 +1199,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { "Failed to refresh Linux working tree watchers", ); } + if (target.linuxTreeRefreshQueued) { + await new Promise((r) => setTimeout(r, LINUX_WATCH_REFRESH_COOLDOWN_MS)); + } } while (target.linuxTreeRefreshQueued); })(); @@ -1206,11 +1213,17 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } private async listLinuxWatchDirectories(rootPath: string): Promise { + const ignored = await this.loadLinuxIgnoredDirs(rootPath); const directories: string[] = []; let currentLevel: string[] = [rootPath]; + let capped = false; while (currentLevel.length > 0) { directories.push(...currentLevel); + if (directories.length >= LINUX_WATCH_MAX_DIRS) { + capped = true; + break; + } const readResults = await Promise.all( currentLevel.map((directory) => linuxWatchReaddirLimit(async () => { @@ -1231,15 +1244,59 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (!entry.isDirectory() || entry.name === ".git") { continue; } - nextLevel.push(join(directory, entry.name)); + const childPath = join(directory, entry.name); + if (ignored.has(childPath)) { + continue; + } + nextLevel.push(childPath); } } currentLevel = nextLevel; } + if (capped) { + this.logger.warn( + { rootPath, limit: LINUX_WATCH_MAX_DIRS, walked: directories.length }, + "Linux working tree exceeds watcher cap; skipping deeper directories", + ); + } + return directories; } + private async loadLinuxIgnoredDirs(rootPath: string): Promise> { + const cached = this.linuxIgnoredDirsCache.get(rootPath); + if (cached && Date.now() - cached.ts < LINUX_WATCH_IGNORE_TTL_MS) { + return cached.ignored; + } + + const ignored = new Set(); + try { + const result = await this.deps.runGitCommand( + ["ls-files", "-o", "-i", "--directory", "--exclude-standard"], + { cwd: rootPath, env: READ_ONLY_GIT_ENV }, + ); + for (const raw of result.stdout.split("\n")) { + if (!raw.endsWith("/")) { + continue; + } + const rel = raw.replace(/\/+$/, ""); + if (!rel) { + continue; + } + ignored.add(resolve(rootPath, rel)); + } + } catch (error) { + this.logger.debug( + { err: error, rootPath }, + "Failed to load gitignore directories; falling back to name-based skip only", + ); + } + + this.linuxIgnoredDirsCache.set(rootPath, { ignored, ts: Date.now() }); + return ignored; + } + private async refreshWorkspaceTarget( target: WorkspaceGitTarget, request: WorkspaceGitRefreshRequest, @@ -1587,6 +1644,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target.watchers = []; target.watchedPaths.clear(); target.listeners.clear(); + if (target.repoWatchPath) { + this.linuxIgnoredDirsCache.delete(target.repoWatchPath); + } } private closeRepoTarget(target: RepoGitTarget): void {