fix(cli): keep live reload working after an atomic save on Linux (#4346)

* fix(cli): keep hearing a file after an atomic save on Linux

* fix(cli): skip a subdirectory the watcher cannot open

* test(cli): pin that one unwatchable subdirectory leaves the project watched

* test(cli): assert the tree keeps its other watches when one subdirectory fails

* test(cli): cover the manifest directory and prefix-named siblings

* test(cli): reset fake directories per test and cover an unwatchable root
This commit is contained in:
Miguel Ángel
2026-09-23 14:49:35 -04:00
committed by GitHub
parent a57e095468
commit 67c363b9cf
3 changed files with 197 additions and 17 deletions
@@ -0,0 +1,80 @@
import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js";
// Real files, no fs mock: the failure lives in how the OS watch tracks a replaced inode.
describe("createProjectWatcher on a real directory", () => {
let dir = "";
let watcher: ProjectWatcher | null = null;
afterEach(() => {
watcher?.close();
rmSync(dir, { recursive: true, force: true });
});
const watchProject = async () => {
const seen: string[] = [];
watcher = createProjectWatcher(dir);
watcher.addListener((path) => seen.push(path));
await new Promise((resolve) => setTimeout(resolve, 100));
return seen;
};
const expectReported = async (seen: string[], path: string) => {
await vi.waitFor(() => expect(seen).toContain(path), { timeout: 3000, interval: 25 });
seen.length = 0;
};
const replaceByRename = (path: string, text: string) => {
writeFileSync(`${path}.tmp`, text);
renameSync(`${path}.tmp`, path);
};
it("keeps reporting a file after an atomic save replaced it", async () => {
dir = mkdtempSync(join(tmpdir(), "hf-watch-"));
mkdirSync(join(dir, "compositions"));
writeFileSync(join(dir, "index.html"), "v0");
writeFileSync(join(dir, "compositions", "scene.html"), "v0");
const seen = await watchProject();
for (const path of ["index.html", join("compositions", "scene.html")]) {
replaceByRename(join(dir, path), "stamped");
await expectReported(seen, path);
writeFileSync(join(dir, path), "edited in place");
await expectReported(seen, path);
replaceByRename(join(dir, path), "saved again");
await expectReported(seen, path);
}
});
it("reports Studio's manifest writes inside .hyperframes", async () => {
dir = mkdtempSync(join(tmpdir(), "hf-watch-"));
mkdirSync(join(dir, ".hyperframes"));
const seen = await watchProject();
writeFileSync(join(dir, ".hyperframes", "studio-motion.json"), "{}");
await expectReported(seen, join(".hyperframes", "studio-motion.json"));
});
it("keeps watching a sibling whose name starts with a removed directory's", async () => {
dir = mkdtempSync(join(tmpdir(), "hf-watch-"));
mkdirSync(join(dir, "scene"));
mkdirSync(join(dir, "scenes"));
const seen = await watchProject();
rmSync(join(dir, "scene"), { recursive: true });
await expectReported(seen, "scene");
writeFileSync(join(dir, "scenes", "a.html"), "v1");
await expectReported(seen, join("scenes", "a.html"));
});
it("reports files in a directory created after it started", async () => {
dir = mkdtempSync(join(tmpdir(), "hf-watch-"));
const seen = await watchProject();
mkdirSync(join(dir, "scenes"));
await expectReported(seen, "scenes");
writeFileSync(join(dir, "scenes", "new.html"), "v0");
await expectReported(seen, join("scenes", "new.html"));
});
});
+38 -1
View File
@@ -6,18 +6,29 @@ type WatchCallback = (eventType: string, filename: string | Buffer | null) => vo
const mockWatcher = new EventEmitter() as EventEmitter & { close: () => void };
mockWatcher.close = vi.fn();
const fakeDirs = { children: [] as string[], unwatchable: "" };
vi.mock("node:fs", async (importOriginal) => {
const original = await importOriginal<typeof import("node:fs")>();
return {
...original,
watch: vi.fn((_path: string, _options: unknown, onChange: WatchCallback) => {
watch: vi.fn((path: string, _options: unknown, onChange: WatchCallback) => {
if (fakeDirs.unwatchable && path.endsWith(fakeDirs.unwatchable)) {
throw Object.assign(new Error("ENOSPC"), { code: "ENOSPC" });
}
mockWatcher.on("change", onChange);
return mockWatcher;
}),
readdirSync: vi.fn((path: string) =>
path === "/fake/project/dir"
? fakeDirs.children.map((name) => ({ name, isDirectory: () => true }))
: [],
),
};
});
const { shouldWatchProjectFile, createProjectWatcher } = await import("./fileWatcher.js");
const { watch } = await import("node:fs");
describe("shouldWatchProjectFile", () => {
it("watches files that can affect the project signature", () => {
@@ -42,6 +53,8 @@ describe("createProjectWatcher", () => {
beforeEach(() => {
mockWatcher.removeAllListeners();
vi.clearAllMocks();
fakeDirs.children = [];
fakeDirs.unwatchable = "";
vi.useRealTimers();
});
@@ -60,6 +73,30 @@ describe("createProjectWatcher", () => {
projectWatcher.close();
});
it.runIf(process.platform === "linux")(
"keeps watching the rest of the tree when one subdirectory cannot be watched",
() => {
fakeDirs.children = ["full", "compositions"];
fakeDirs.unwatchable = "full";
const projectWatcher = createProjectWatcher("/fake/project/dir");
expect(vi.mocked(watch).mock.calls.map(([path]) => path)).toContain(
"/fake/project/dir/compositions",
);
projectWatcher.close();
expect(mockWatcher.close).toHaveBeenCalled();
},
);
it("degrades to no live reload when the project root cannot be watched", () => {
fakeDirs.unwatchable = "/fake/project/dir";
let projectWatcher: ReturnType<typeof createProjectWatcher> | null = null;
expect(() => {
projectWatcher = createProjectWatcher("/fake/project/dir");
}).not.toThrow();
expect(() => projectWatcher?.close()).not.toThrow();
});
// Regression: fs.watch can fail asynchronously (e.g. EMFILE from exhausted
// OS watch handles) via an 'error' event, not a thrown exception. An
// EventEmitter 'error' with no listener crashes the whole process — this
+79 -16
View File
@@ -1,5 +1,5 @@
import { watch, type FSWatcher } from "node:fs";
import { join } from "node:path";
import { lstatSync, readdirSync, watch, type FSWatcher } from "node:fs";
import { join, relative, sep } from "node:path";
import { affectsProjectSignature } from "@hyperframes/studio-server";
export type FileChangeListener = (relativePath: string) => void;
@@ -34,16 +34,88 @@ export function shouldWatchProjectFile(filename: string): boolean {
return !parts.some((part) => WATCHER_EXCLUDED_DIRS.has(part));
}
function isDirectory(path: string): boolean {
try {
return lstatSync(path).isDirectory();
} catch {
return false;
}
}
// On Linux, Node's `recursive` watch arms inotify per file inode, so a file replaced by rename
// (an atomic save) is never reported again; a watch per directory reports children by name.
function watchProjectTree(
projectDir: string,
onChange: (relativePath: string) => void,
): () => void {
if (process.platform !== "linux") {
const tree = watch(projectDir, { recursive: true }, (_event, filename) => {
if (filename) onChange(filename.toString());
});
// An async 'error' (e.g. EMFILE) with no listener would crash the process.
tree.on("error", () => tree.close());
return () => tree.close();
}
const directories = new Map<string, FSWatcher>();
const unwatch = (dir: string) => {
for (const [watched, watcher] of directories) {
if (watched === dir || watched.startsWith(dir + sep)) {
watcher.close();
directories.delete(watched);
}
}
};
const watchDirectory = (dir: string) => {
if (directories.has(dir)) return;
let watcher: FSWatcher;
try {
watcher = watch(dir, { persistent: true }, (event, name) => {
if (!name) return;
const path = join(dir, name.toString());
onChange(relative(projectDir, path));
if (event !== "rename") return;
if (isDirectory(path)) descend(path);
else unwatch(path);
});
} catch (error) {
// One unwatchable subdirectory (EACCES, inotify limit) must not cost the rest of the tree.
if (dir === projectDir) throw error;
return;
}
watcher.on("error", () => unwatch(dir));
directories.set(dir, watcher);
let entries: string[] = [];
try {
entries = readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => join(dir, entry.name));
} catch {
// Gone before we could list it; its parent reports the removal.
}
for (const child of entries) descend(child);
};
// `.hyperframes/` itself holds the two manifests the signature reads; nothing below it matters.
const descend = (dir: string) => {
const rel = relative(projectDir, dir);
if (shouldWatchProjectFile(rel) || rel === ".hyperframes") watchDirectory(dir);
};
watchDirectory(projectDir);
return () => {
for (const watcher of directories.values()) watcher.close();
directories.clear();
};
}
export function createProjectWatcher(projectDir: string): ProjectWatcher {
const listeners = new Set<FileChangeListener>();
const pendingPaths = new Set<string>();
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let watcher: FSWatcher | null = null;
let closeTree: (() => void) | null = null;
try {
watcher = watch(projectDir, { recursive: true }, (_event, filename) => {
if (!filename) return;
const relativePath = filename.toString();
closeTree = watchProjectTree(projectDir, (relativePath) => {
// The reload filter excludes all of `.hyperframes/`, but two files in
// there feed the preview signature and Studio writes one of them at
// runtime — dropping those at ingest left the CLI server's ETag stale
@@ -69,15 +141,6 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher {
}
}, DEBOUNCE_MS);
});
// fs.watch can fail asynchronously too (e.g. EMFILE from exhausted OS watch
// handles) — that surfaces as an 'error' event, not a thrown exception. An
// EventEmitter 'error' with no listener crashes the whole process, so this
// listener is required for the same "degrade gracefully" the catch below
// already promises for the synchronous failure mode.
watcher.on("error", () => {
watcher?.close();
watcher = null;
});
} catch {
// fs.watch may fail on some platforms — degrade gracefully (no auto-refresh)
}
@@ -92,7 +155,7 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher {
close() {
if (debounceTimer) clearTimeout(debounceTimer);
pendingPaths.clear();
watcher?.close();
closeTree?.();
listeners.clear();
},
};