From 4db72e1fe43244eac812511438cbd87380fdae08 Mon Sep 17 00:00:00 2001 From: Gagan Date: Mon, 13 Jul 2026 17:41:01 +0530 Subject: [PATCH 1/3] 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, From 5bf743f93de46688cf23344c7f7af813f5a70cb8 Mon Sep 17 00:00:00 2001 From: Gagan Date: Mon, 13 Jul 2026 17:45:51 +0530 Subject: [PATCH 2/3] fix(workspace): pre-create watched roots so late-appearing dirs emit events chokidar v4 silently skips a watched directory that doesn't exist at watch time, so e.g. calendar_sync/ created by the first sync after app start would never emit events until restart. Create the dir roots up front; for the file root only its parent dir is needed. --- apps/x/packages/core/src/workspace/watcher.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/x/packages/core/src/workspace/watcher.ts b/apps/x/packages/core/src/workspace/watcher.ts index be7344c4..c50163d7 100644 --- a/apps/x/packages/core/src/workspace/watcher.ts +++ b/apps/x/packages/core/src/workspace/watcher.ts @@ -16,20 +16,20 @@ export type WorkspaceChangeCallback = (event: z.infer { await ensureWorkspaceRoot(); - const roots = WATCHED_ROOTS.map((rel) => path.join(WorkDir, rel)); + // chokidar v4 never picks up a watched directory that doesn't exist yet + // (a missing file is fine as long as its parent dir exists), so create the + // roots up front — otherwise e.g. calendar_sync/ appearing after app start + // would stay invisible until restart. + for (const rel of WATCHED_DIR_ROOTS) { + await fs.mkdir(path.join(WorkDir, rel), { recursive: true }); + } + for (const rel of WATCHED_FILE_ROOTS) { + await fs.mkdir(path.dirname(path.join(WorkDir, rel)), { recursive: true }); + } + + const roots = [...WATCHED_DIR_ROOTS, ...WATCHED_FILE_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 From aa47871e471046dd76a811da4b845ec4979a00b7 Mon Sep 17 00:00:00 2001 From: Gagan Date: Mon, 13 Jul 2026 18:16:12 +0530 Subject: [PATCH 3/3] fix(watchers): ignore .git/node_modules in apps watcher, degrade watcher fd errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The apps-dir watcher recursed into anything an installed app ships; ignore .git and node_modules segments like the workspace watcher does. (The skills watcher already bounds itself with depth: 1.) fs.watch failures (EMFILE/ENOSPC) escape chokidar's error handler as uncaught exceptions from Node watcher internals and killed the main process with the native error dialog. Catch them at the process level and log instead — watching is degradable; other uncaught exceptions keep the default dialog behavior. --- apps/x/apps/main/src/main.ts | 20 +++++++++++++++++++- apps/x/packages/core/src/apps/server.ts | 6 ++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 1e1d3874..c0f7d39f 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, safeStorage, type Session } from "electron"; +import { app, BrowserWindow, desktopCapturer, dialog, protocol, net, shell, session, safeStorage, type Session } from "electron"; import path from "node:path"; import { setupIpcHandlers, @@ -74,6 +74,24 @@ const APP_LAUNCHED_AT = Date.now(); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +// fs.watch failures (EMFILE fd exhaustion, ENOSPC watch limits) surface as +// uncaught exceptions from Node's watcher internals, bypassing chokidar's +// 'error' handlers. Watching is a degradable feature — log and keep running. +// Everything else keeps Electron's default behavior (native error dialog), +// which we replicate since registering any listener suppresses it. +process.on('uncaughtException', (err) => { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + if ((code === 'EMFILE' || code === 'ENOSPC') && (err?.stack ?? '').includes('FSWatcher')) { + console.error('[Main] file watcher error (non-fatal):', err); + return; + } + console.error('[Main] uncaught exception:', err); + dialog.showErrorBox( + 'A JavaScript error occurred in the main process', + err?.stack ?? String(err), + ); +}); + // run this as early in the main process as possible if (started) app.quit(); diff --git a/apps/x/packages/core/src/apps/server.ts b/apps/x/packages/core/src/apps/server.ts index 58565e7b..e73f26b9 100644 --- a/apps/x/packages/core/src/apps/server.ts +++ b/apps/x/packages/core/src/apps/server.ts @@ -719,6 +719,12 @@ async function startWatcher(): Promise { if (watcher) return; const w = chokidar.watch(APPS_DIR, { ignoreInitial: true, + // Installed apps may ship .git/node_modules trees — thousands of files + // no consumer renders, at one watch fd per file (chokidar v4, no fsevents). + ignored: (watchedPath: string) => { + const segments = path.relative(APPS_DIR, watchedPath).split(path.sep); + return segments.includes('.git') || segments.includes('node_modules'); + }, awaitWriteFinish: { stabilityThreshold: 180, pollInterval: 50 }, }); w.on('all', (eventName, absolutePath) => {