From 1d35d855e28a3b37a8af8275eb58dea1a7254860 Mon Sep 17 00:00:00 2001 From: Noah Khomer <108771853+noahkhomer18@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:06:15 -0500 Subject: [PATCH] fix(cli): handle EMFILE/ENOSPC in file watchers with polling fallback When fs.watch() throws EMFILE or ENOSPC due to system file watcher limits being exhausted, the dev server would crash before starting. Now each watcher is wrapped in its own try/catch, and resource errors fall back to polling (stat-based) change detection with a 1-second interval. Non-resource errors still fail fast. Applies to: - Config file watchers in flue.ts (config directory watches) - Recursive root watcher in dev.ts (project file watching) - Environment file watcher in dev.ts (.env changes) Closes #314 --- packages/cli/bin/flue.ts | 26 +++++++++++---- packages/cli/src/lib/dev.ts | 63 +++++++++++++++++++++++++++++++++++-- 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/packages/cli/bin/flue.ts b/packages/cli/bin/flue.ts index 16f1fa268..2ddf17354 100644 --- a/packages/cli/bin/flue.ts +++ b/packages/cli/bin/flue.ts @@ -1228,7 +1228,7 @@ function superviseDevCommand(args: DevArgs) { configFilesByDirectory.set(directory, basenames); } - const watchers: fs.FSWatcher[] = []; + const watchers: { close(): void }[] = []; let child: ChildProcess | undefined; let restartTimer: NodeJS.Timeout | undefined; let restartRequested = false; @@ -1286,8 +1286,8 @@ function superviseDevCommand(args: DevArgs) { child?.kill('SIGTERM'); }; - try { - for (const [directory, basenames] of configFilesByDirectory) { + for (const [directory, basenames] of configFilesByDirectory) { + try { const watcher = fs.watch(directory, (_event, filename) => { const basename = filename?.toString(); if (basename !== undefined) { @@ -1307,10 +1307,24 @@ function superviseDevCommand(args: DevArgs) { exit(1); }); watchers.push(watcher); + } catch (err) { + if (err instanceof Error && 'code' in err && ((err as NodeJS.ErrnoException).code === 'EMFILE' || (err as NodeJS.ErrnoException).code === 'ENOSPC')) { + console.error(`${dim('config')} ${(err as NodeJS.ErrnoException).code}: ${(err as Error).message}; falling back to polling`); + const interval = setInterval(() => { + for (const basename of basenames) { + const configFile = path.join(directory, basename); + if (configFileStates.get(configFile) !== readConfigFileState(configFile)) { + restart(configFile); + return; + } + } + }, 1000); + watchers.push({ close: () => clearInterval(interval) }); + } else { + cliError(`Config watcher failed: ${err instanceof Error ? err.message : String(err)}`); + exit(1); + } } - } catch (err) { - cliError(`Config watcher failed: ${err instanceof Error ? err.message : String(err)}`); - exit(1); } const shutdown = (signal: NodeJS.Signals) => { diff --git a/packages/cli/src/lib/dev.ts b/packages/cli/src/lib/dev.ts index 16f4ddc06..e5da3ff91 100644 --- a/packages/cli/src/lib/dev.ts +++ b/packages/cli/src/lib/dev.ts @@ -341,7 +341,7 @@ interface WatcherHandle { */ function createWatcher(options: WatcherOptions): WatcherHandle { const { root, sourceRoot, output, envFile, configFiles, onChange } = options; - const watchers: fs.FSWatcher[] = []; + const watchers: { close(): void }[] = []; const watchesDotFlue = sourceRoot === path.join(root, '.flue'); const ignoredConfigFiles = new Set(configFiles.map((file) => path.resolve(file))); @@ -387,7 +387,43 @@ function createWatcher(options: WatcherOptions): WatcherHandle { }); watchers.push(w); } catch (err) { - error(`Failed to watch ${root}: ${err instanceof Error ? err.message : String(err)}`); + if (err instanceof Error && 'code' in err && ((err as NodeJS.ErrnoException).code === 'EMFILE' || (err as NodeJS.ErrnoException).code === 'ENOSPC')) { + console.warn(`${dim('dev')} ${(err as NodeJS.ErrnoException).code}: file watching unavailable; polling ${root}`); + const cache = new Map(); + const interval = setInterval(() => { + const walk = (dir: string, baseRel: string) => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const relPath = baseRel ? `${baseRel}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + if (isIgnoredPath(relPath)) continue; + walk(path.join(dir, entry.name), relPath); + } else if (entry.isFile()) { + if (isIgnoredPath(relPath)) continue; + try { + const stat = fs.statSync(path.join(dir, entry.name)); + const cached = cache.get(relPath); + if (cached !== undefined && cached !== stat.mtimeMs) { + onChange(relPath); + } + cache.set(relPath, stat.mtimeMs); + } catch { + // file removed between readdir and stat + } + } + } + }; + walk(root, ''); + }, 1000); + watchers.push({ close: () => clearInterval(interval) }); + } else { + error(`Failed to watch ${root}: ${err instanceof Error ? err.message : String(err)}`); + } } try { @@ -397,7 +433,28 @@ function createWatcher(options: WatcherOptions): WatcherHandle { if (filename?.toString() === envBasename) onChange(envFile); }); watchers.push(w); - } catch {} + } catch (err) { + if (err instanceof Error && 'code' in err && ((err as NodeJS.ErrnoException).code === 'EMFILE' || (err as NodeJS.ErrnoException).code === 'ENOSPC')) { + let cached: string | undefined; + try { + const stat = fs.statSync(envFile); + cached = `${stat.mtimeMs}:${stat.size}`; + } catch {} + const interval = setInterval(() => { + try { + const stat = fs.statSync(envFile); + const state = `${stat.mtimeMs}:${stat.size}`; + if (cached !== undefined && cached !== state) { + onChange(envFile); + } + cached = state; + } catch { + // file removed + } + }, 1000); + watchers.push({ close: () => clearInterval(interval) }); + } + } return { close() {