Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 60 additions & 15 deletions packages/cli/bin/flue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1228,7 +1228,7 @@ function superviseDevCommand(args: DevArgs) {
configFilesByDirectory.set(directory, basenames);
}

const watchers: fs.FSWatcher[] = [];
const watchers: Array<{ close(): void }> = [];
let child: ChildProcess | undefined;
let restartTimer: NodeJS.Timeout | undefined;
let restartRequested = false;
Expand All @@ -1239,6 +1239,30 @@ function superviseDevCommand(args: DevArgs) {
const closeWatchers = () => {
for (const watcher of watchers.splice(0)) watcher.close();
};
const isWatchResourceError = (err: unknown): boolean => {
const code =
err && typeof err === 'object' && 'code' in err ? (err as { code?: unknown }).code : undefined;
return code === 'EMFILE' || code === 'ENOSPC';
};
const formatWatchError = (err: unknown): string =>
err instanceof Error ? err.message : String(err);
const createConfigWatchFileFallback = (
directory: string,
basenames: ReadonlySet<string>,
): { close(): void } => {
const files = [...basenames].map((basename) => path.join(directory, basename));
for (const file of files) {
fs.watchFile(file, { interval: 500 }, (curr, prev) => {
if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return;
restart(file);
});
}
return {
close() {
for (const file of files) fs.unwatchFile(file);
},
};
};
const exit = (code: number) => {
closeWatchers();
process.exit(code);
Expand Down Expand Up @@ -1288,28 +1312,49 @@ function superviseDevCommand(args: DevArgs) {

try {
for (const [directory, basenames] of configFilesByDirectory) {
const watcher = fs.watch(directory, (_event, filename) => {
const basename = filename?.toString();
if (basename !== undefined) {
if (!basenames.has(basename)) return;
restart(path.join(directory, basename));
return;
}
for (const configFile of configFiles) {
if (configFileStates.get(configFile) !== readConfigFileState(configFile)) {
restart(configFile);
let watcher: fs.FSWatcher;
try {
watcher = fs.watch(directory, (_event, filename) => {
const basename = filename?.toString();
if (basename !== undefined) {
if (!basenames.has(basename)) return;
restart(path.join(directory, basename));
return;
}
}
});
for (const configFile of configFiles) {
if (configFileStates.get(configFile) !== readConfigFileState(configFile)) {
restart(configFile);
return;
}
}
});
} catch (err) {
if (!isWatchResourceError(err)) throw err;
console.warn(
`${dim('config')} Config watcher unavailable: ${formatWatchError(err)}; polling config files`,
);
watchers.push(createConfigWatchFileFallback(directory, basenames));
continue;
}
watcher.on('error', (err) => {
cliError(`Config watcher failed: ${err instanceof Error ? err.message : String(err)}`);
if (isWatchResourceError(err)) {
console.warn(
`${dim('config')} Config watcher unavailable: ${formatWatchError(err)}; polling config files`,
);
watcher.close();
const replacement = createConfigWatchFileFallback(directory, basenames);
const index = watchers.indexOf(watcher);
if (index === -1) watchers.push(replacement);
else watchers.splice(index, 1, replacement);
return;
}
cliError(`Config watcher failed: ${formatWatchError(err)}`);
exit(1);
});
watchers.push(watcher);
}
} catch (err) {
cliError(`Config watcher failed: ${err instanceof Error ? err.message : String(err)}`);
cliError(`Config watcher failed: ${formatWatchError(err)}`);
exit(1);
}

Expand Down
46 changes: 45 additions & 1 deletion packages/cli/test/dev.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,49 @@ test('watches an explicit config outside the project root', async () => {
}
});

test('falls back to polling config files when directory watch hits resource limits', async () => {
const root = createFixtureRoot();
const port = await getAvailablePort();
writeWorkflow(root);
fs.writeFileSync(
path.join(root, 'flue.config.mjs'),
`export default { target: 'node', output: 'dist-one' };\n`,
);
const preload = path.join(root, 'fail-root-watch.cjs');
fs.writeFileSync(
preload,
`const fs = require('node:fs');
const originalWatch = fs.watch;
fs.watch = function watch(target, ...args) {
if (String(target) === process.cwd()) {
const err = new Error('too many open files, watch');
err.code = 'EMFILE';
throw err;
}
return originalWatch.call(this, target, ...args);
};
`,
);

const dev = startDev(root, ['--port', String(port)], {
NODE_OPTIONS: `--require ${JSON.stringify(preload)}`,
});
try {
await dev.waitForLog('Config watcher unavailable: too many open files, watch; polling config files');
await waitForServer(port, dev.logs);
assert.equal(fs.existsSync(path.join(root, 'dist-one', 'server.mjs')), true);

fs.writeFileSync(
path.join(root, 'flue.config.mjs'),
`export default { target: 'node', output: 'dist-two' };\n`,
);
await waitForPath(path.join(root, 'dist-two', 'server.mjs'));
await waitForServer(port);
} finally {
await dev.stop();
}
});

function createFixtureRoot() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'flue-cli-dev-'));
fixtureRoots.push(root);
Expand All @@ -106,9 +149,10 @@ function writeWorkflow(root) {
);
}

function startDev(cwd, args) {
function startDev(cwd, args, env = {}) {
const child = spawn(process.execPath, [cli.pathname, 'dev', ...args], {
cwd,
env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
Expand Down
Loading