From 4db72e1fe43244eac812511438cbd87380fdae08 Mon Sep 17 00:00:00 2001 From: Gagan Date: Mon, 13 Jul 2026 17:41:01 +0530 Subject: [PATCH] fix(workspace): whitelist watched roots to stop EMFILE crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace watcher watched all of ~/.rowboat recursively. Chokidar v4 (no fsevents) holds one OS watch handle per file, and internal state dirs (runs-archive, storage/turns, engines, ...) grow unboundedly with usage — ~20k open fds on a 1.1G workdir, crashing the app with EMFILE once the fd limit is hit. Only watch the roots whose change events actually have consumers: knowledge, bases, inbox_lists, gmail_sync, calendar_sync, bg-tasks and config/agent-schedule.json — with .git ignored (knowledge/ is a git repo). --- apps/x/apps/main/src/ipc.ts | 7 ++-- apps/x/packages/core/src/workspace/watcher.ts | 34 ++++++++++++++----- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index ef530ff0..2ada48a9 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -583,11 +583,12 @@ function handleWorkspaceChange(event: z.infer) => void; +// The only WorkDir paths whose change events have a consumer (knowledge index +// invalidation in main, and the tree/editor, live-notes, email, sidebar and +// meetings views in the renderer). Everything else under WorkDir — runs-archive, +// storage/turns, engines, logs, code-mode, ... — is internal state that grows +// unboundedly with usage; chokidar v4 holds one OS watch handle per file, so +// watching all of WorkDir exhausts the process fd limit (EMFILE crash) once +// the workdir gets big enough. +const WATCHED_ROOTS = [ + 'knowledge', + 'bases', + 'inbox_lists', + 'gmail_sync', + 'calendar_sync', + 'bg-tasks', + 'config/agent-schedule.json', +]; + /** * Create a workspace watcher - * Watches the configured workspace root recursively and emits change events via callback - * + * Watches the user-facing workspace roots (WATCHED_ROOTS) recursively and + * emits WorkDir-relative change events via callback. + * * Returns a watcher instance that can be closed. * The watcher emits events immediately without debouncing. * Debouncing and lifecycle management should be handled by the caller. @@ -22,15 +40,13 @@ export async function createWorkspaceWatcher( ): Promise { await ensureWorkspaceRoot(); - // Code-section session worktrees are full repo checkouts (thousands of files, - // possibly node_modules) living under WorkDir — watching them would flood the - // event stream and burn file handles, and nothing in the app renders them - // from workspace events. - const codeModeDir = path.join(WorkDir, 'code-mode'); - const watcher = chokidar.watch(WorkDir, { + const roots = WATCHED_ROOTS.map((rel) => path.join(WorkDir, rel)); + const watcher = chokidar.watch(roots, { ignoreInitial: true, + // knowledge/ is a git repo (version history) — its .git object store is + // thousands of files nothing renders, so keep it out of the watch set. ignored: (watchedPath: string) => - watchedPath === codeModeDir || watchedPath.startsWith(codeModeDir + path.sep), + path.relative(WorkDir, watchedPath).split(path.sep).includes('.git'), awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50,