mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user