Merge pull request #746 from rowboatlabs/fix/workspace-watcher-scope

fix(workspace): scope the workspace watcher to whitelisted roots (EMFILE crash)
This commit is contained in:
gagan 2026-07-14 13:48:31 +05:30 committed by GitHub
commit 5f71ff2000
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 65 additions and 13 deletions

View file

@ -585,11 +585,12 @@ function handleWorkspaceChange(event: z.infer<typeof workspaceShared.WorkspaceCh
/** /**
* Start workspace watcher * Start workspace watcher
* Watches the configured workspace root recursively and emits change events to renderer * Watches the user-facing workspace roots recursively and emits change events to renderer
* *
* This should be called once when the app starts (from main.ts). * This should be called once when the app starts (from main.ts).
* The watcher runs as a main-process service and catches ALL filesystem changes * The watcher runs as a main-process service and catches all filesystem changes
* (both from IPC handlers and external changes like terminal/git). * under the watched roots (both from IPC handlers and external changes like
* terminal/git).
* *
* Safe to call multiple times - guards against duplicate watchers. * Safe to call multiple times - guards against duplicate watchers.
*/ */

View file

@ -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 path from "node:path";
import { import {
setupIpcHandlers, setupIpcHandlers,
@ -73,6 +73,24 @@ const APP_LAUNCHED_AT = Date.now();
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); 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 // run this as early in the main process as possible
if (started) app.quit(); if (started) app.quit();

View file

@ -719,6 +719,12 @@ async function startWatcher(): Promise<void> {
if (watcher) return; if (watcher) return;
const w = chokidar.watch(APPS_DIR, { const w = chokidar.watch(APPS_DIR, {
ignoreInitial: true, 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 }, awaitWriteFinish: { stabilityThreshold: 180, pollInterval: 50 },
}); });
w.on('all', (eventName, absolutePath) => { w.on('all', (eventName, absolutePath) => {

View file

@ -9,9 +9,27 @@ import { Stats } from 'node:fs';
export type WorkspaceChangeCallback = (event: z.infer<typeof WorkspaceChangeEvent>) => void; export type WorkspaceChangeCallback = (event: z.infer<typeof WorkspaceChangeEvent>) => 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_DIR_ROOTS = [
'knowledge',
'bases',
'inbox_lists',
'gmail_sync',
'calendar_sync',
'bg-tasks',
];
const WATCHED_FILE_ROOTS = ['config/agent-schedule.json'];
/** /**
* Create a workspace watcher * Create a workspace watcher
* Watches the configured workspace root recursively and emits change events via callback * Watches the user-facing workspace roots (WATCHED_DIR_ROOTS / WATCHED_FILE_ROOTS)
* recursively and emits WorkDir-relative change events via callback.
* *
* Returns a watcher instance that can be closed. * Returns a watcher instance that can be closed.
* The watcher emits events immediately without debouncing. * The watcher emits events immediately without debouncing.
@ -22,15 +40,24 @@ export async function createWorkspaceWatcher(
): Promise<FSWatcher> { ): Promise<FSWatcher> {
await ensureWorkspaceRoot(); await ensureWorkspaceRoot();
// Code-section session worktrees are full repo checkouts (thousands of files, // chokidar v4 never picks up a watched directory that doesn't exist yet
// possibly node_modules) living under WorkDir — watching them would flood the // (a missing file is fine as long as its parent dir exists), so create the
// event stream and burn file handles, and nothing in the app renders them // roots up front — otherwise e.g. calendar_sync/ appearing after app start
// from workspace events. // would stay invisible until restart.
const codeModeDir = path.join(WorkDir, 'code-mode'); for (const rel of WATCHED_DIR_ROOTS) {
const watcher = chokidar.watch(WorkDir, { 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, 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) => ignored: (watchedPath: string) =>
watchedPath === codeModeDir || watchedPath.startsWith(codeModeDir + path.sep), path.relative(WorkDir, watchedPath).split(path.sep).includes('.git'),
awaitWriteFinish: { awaitWriteFinish: {
stabilityThreshold: 150, stabilityThreshold: 150,
pollInterval: 50, pollInterval: 50,