mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-21 21:31:12 +02:00
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:
commit
5f71ff2000
4 changed files with 65 additions and 13 deletions
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue