fix(studio-server): history refuses once closed and reopens when a new project takes its folder (#4537)

This commit is contained in:
Miguel Ángel
2026-09-26 19:01:05 +00:00
committed by GitHub
parent 22eb0ce5e5
commit 7eb6430e4e
14 changed files with 711 additions and 79 deletions
+29 -2
View File
@@ -1,6 +1,14 @@
// @vitest-environment node
import { spawn } from "node:child_process";
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import {
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
@@ -16,7 +24,7 @@ import { runCommand } from "citty";
import { Hono } from "hono";
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
import { consumeCommandResult } from "../utils/commandResult.js";
import { historyDeps } from "../utils/historyOwner.js";
import { historyDeps, withOwner } from "../utils/historyOwner.js";
import historyCommand from "./history.js";
const pause = (ms: number) => new Promise((settle) => setTimeout(settle, ms));
@@ -247,6 +255,24 @@ describe.each(["direct", "preview"])("hyperframes history (%s)", (mode) => {
});
describe("hyperframes history, one owner", () => {
it("lets go of the history when a new project takes the folder's path mid-command", async () => {
const { dir, hf } = project();
await hf("begin", "--who", "claude", "--label", "Retitle");
const moved = `${dir}-moved`;
onTestFinished(() => rmSync(moved, { recursive: true, force: true }));
const swap = async () => {
renameSync(dir, moved);
mkdirSync(dir);
};
await expect(withOwner(dir, swap)).rejects.toThrow("now another project");
const reopened = await openProjectHistory({
projectDir: moved,
historyRoot: historyDeps.historyRoot,
ownerWaitMs: 200,
});
await reopened.close();
});
it("goes through a running preview, so the log keeps one baseline and every entry once", async () => {
const { dir, json, turn } = project();
const held = await preview(dir);
@@ -400,6 +426,7 @@ describe("hyperframes history, one owner", () => {
env: {
...process.env,
HOME: home,
USERPROFILE: home,
HYPERFRAMES_SKIP_UPDATE_CHECK: "1",
HYPERFRAMES_NO_TELEMETRY: "1",
},
+38 -7
View File
@@ -1,12 +1,21 @@
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import {
createProjectSignature,
fileContentVersion,
HistoryBusyError,
HistoryClosedError,
} from "@hyperframes/studio-server";
import { loadHyperframeRuntimeSource } from "@hyperframes/core";
import { loadRuntimeSource } from "./runtimeSource.js";
@@ -156,16 +165,38 @@ describe("createStudioServer project history (D-491)", () => {
await server.shutdown();
});
it("tries a history another process was holding again on the next request, instead of turning it off", async () => {
historyState.open = async () => {
historyState.open = null;
throw new HistoryBusyError(1);
};
it.each([
["another process was holding", new HistoryBusyError(1)],
["whose folder changed while it opened", new HistoryClosedError("now another project")],
])(
"tries a history %s again on the next request, instead of turning it off",
async (_, refusal) => {
historyState.open = async () => {
historyState.open = null;
throw refusal;
};
const projectDir = tmpProject();
server = createStudioServer({ projectDir, historyRoot: tmpProject() });
const historyUrl = `/api/projects/${encodeURIComponent(basename(projectDir))}/history`;
expect((await server.app.request(historyUrl)).status).toBe(404);
expect((await server.app.request(historyUrl)).status).toBe(200);
await server.shutdown();
},
);
it("opens a new project's own history once it takes the folder's path", async () => {
const projectDir = tmpProject();
writeFileSync(join(projectDir, "index.html"), "<html>before</html>");
server = createStudioServer({ projectDir, historyRoot: tmpProject() });
const historyUrl = `/api/projects/${encodeURIComponent(basename(projectDir))}/history`;
expect((await server.app.request(historyUrl)).status).toBe(404);
expect((await server.app.request(historyUrl)).status).toBe(200);
renameSync(projectDir, `${projectDir}-moved`);
dirs.push(`${projectDir}-moved`);
mkdirSync(projectDir);
writeFileSync(join(projectDir, "index.html"), "<html>new</html>");
expect((await server.app.request(historyUrl)).status).toBe(200);
expect(existsSync(join(projectDir, ".hyperframes", "history-id"))).toBe(true);
await server.shutdown();
});
+11 -8
View File
@@ -56,7 +56,8 @@ import {
DEFAULT_HISTORY_ROOT,
openProjectHistory,
HistoryBusyError,
type ProjectHistory,
HistoryClosedError,
historyCache,
} from "@hyperframes/studio-server";
import { resolveAutoProxy } from "../utils/projectConfig.js";
import { getElementScreenshotClip } from "@hyperframes/studio-server/screenshot-clip";
@@ -429,18 +430,20 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
// Opened on first use, so a server that never serves Studio's history never writes one. A failed open stays off
// for this run; one another process was holding is tried again on the next request.
let history: Promise<ProjectHistory | null> | undefined;
const projectHistory = () =>
(history ??= openProjectHistory({
const histories = historyCache(() =>
openProjectHistory({
projectDir,
historyRoot: options.historyRoot ?? DEFAULT_HISTORY_ROOT,
}).catch((error: unknown) => {
console.warn(`[studio] Project history is off: ${String(error)}`);
if (error instanceof HistoryBusyError) history = undefined;
if (error instanceof HistoryBusyError || error instanceof HistoryClosedError)
histories.forget(projectDir);
return null;
}));
}),
);
const projectHistory = () => histories.get(projectDir);
watcher.addListener((changedPath) => {
void history?.then((opened) => opened?.noteChange(changedPath));
void histories.peek(projectDir)?.then((opened) => opened?.noteChange(changedPath));
});
const inFlightRenders = new Map<AbortController, Promise<void>>();
@@ -1098,7 +1101,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
const shutdown = async (): Promise<void> => {
shuttingDown = true;
// Commits any open edit window; bounded with the renders below, so a history still opening cannot hold exit.
const closeHistory = history?.then((opened) => opened?.close()).catch(() => {});
const closeHistory = histories.closeAll().catch(() => {});
const renders = [...inFlightRenders];
for (const [abortController] of renders) abortController.abort();
const { killTrackedProcesses, closeBrowserPool } = await import("@hyperframes/engine");
+6 -2
View File
@@ -210,8 +210,12 @@ export async function withOwner<T>(
} finally {
// This open filed the turn so far under its id; the turn goes on under a fresh one, from its last write.
const direct = owner.via === "direct" && turn;
const kept = direct && (await owner.list()).find((entry) => entry.id === turn.id);
await owner.close();
let kept: HistoryListItem | undefined;
try {
kept = direct ? (await owner.list()).find((entry) => entry.id === turn.id) : undefined;
} finally {
await owner.close();
}
if (direct && readTurn(projectDir)?.id === turn.id) {
const lastWriteAt = kept ? kept.endedAt : turn.lastWriteAt;
const parts = kept ? [...turn.parts, kept.id] : turn.parts;
@@ -1,5 +1,5 @@
import { createHash, randomUUID } from "node:crypto";
import { constants, createReadStream } from "node:fs";
import { constants, createReadStream, renameSync } from "node:fs";
import { copyFile, mkdir, readdir, readFile, rename, rm, stat } from "node:fs/promises";
import { dirname, join } from "node:path";
@@ -10,7 +10,7 @@ export interface BlobStore {
has(hash: string): boolean;
read(hash: string): Promise<Buffer>;
/** Writes the blob's bytes to `absPath` by clone-or-copy and rename, so a reader never sees half a file. */
writeTo(hash: string, absPath: string): Promise<void>;
writeTo(hash: string, absPath: string, beforeReplace?: () => void): Promise<void>;
bytes(): number;
size(hash: string): number;
prune(keep: ReadonlySet<string>): Promise<void>;
@@ -24,12 +24,13 @@ async function hashFile(path: string): Promise<string> {
return hash.digest("hex");
}
async function cloneOrCopy(from: string, to: string): Promise<void> {
async function cloneOrCopy(from: string, to: string, beforeReplace?: () => void): Promise<void> {
await mkdir(dirname(to), { recursive: true });
const temp = `${to}.${randomUUID()}.tmp`;
try {
await copyFile(from, temp, constants.COPYFILE_FICLONE);
await rename(temp, to);
beforeReplace?.();
renameSync(temp, to);
} catch (error) {
await rm(temp, { force: true });
throw error;
@@ -70,7 +71,8 @@ export async function openBlobStore(dir: string): Promise<BlobStore> {
},
has: (hash) => sizes.has(hash),
read: async (hash) => readFile(pathOf(hash)),
writeTo: async (hash, absPath) => cloneOrCopy(pathOf(hash), absPath),
writeTo: async (hash, absPath, beforeReplace) =>
cloneOrCopy(pathOf(hash), absPath, beforeReplace),
bytes: () => total,
size: (hash) => sizes.get(hash) ?? 0,
async prune(keep) {
@@ -0,0 +1,28 @@
import type { ProjectHistory } from "./projectHistory.js";
type Opened = Promise<ProjectHistory | null>;
/** A host's histories, one per project folder, opened on first use; a new project at the path gets its own. */
export function historyCache(open: (projectDir: string) => Opened) {
const histories = new Map<string, Opened>();
const get = async (projectDir: string): Promise<ProjectHistory | null> => {
let cached = histories.get(projectDir);
if (!cached) histories.set(projectDir, (cached = open(projectDir)));
const opened = await cached;
if (!opened?.replacedAtPath()) return opened;
const reopen = () => open(projectDir);
if (histories.get(projectDir) === cached)
histories.set(projectDir, opened.close().then(reopen, reopen));
return get(projectDir);
};
return {
get,
/** The history if one was opened, without opening it. */
peek: (projectDir: string) => histories.get(projectDir),
forget: (projectDir: string) => histories.delete(projectDir),
closeAll: () =>
Promise.all(
[...histories.values()].map((opened) => opened.then((history) => history?.close())),
),
};
}
+26 -11
View File
@@ -1,12 +1,13 @@
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { replaceFileAtomically } from "../helpers/atomicFile.js";
export const ID_PATH = join(".hyperframes", "history-id");
/** The only shape minted here; the id is project content and becomes a path, so nothing else is trusted. */
const ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
function readId(projectDir: string): string | null {
export function readId(projectDir: string): string | null {
try {
const id = readFileSync(join(projectDir, ID_PATH), "utf-8").trim();
return ID_SHAPE.test(id) ? id : null;
@@ -15,28 +16,42 @@ function readId(projectDir: string): string | null {
}
}
function recordedDir(historyDir: string): string | null {
export type FolderIdentity = { ino: number; birthtimeMs: number };
export const sameFolder = (a: FolderIdentity, b: FolderIdentity) =>
a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
export function isRecordedFolder(historyDir: string, folder: FolderIdentity): boolean {
try {
return JSON.parse(readFileSync(join(historyDir, "project.json"), "utf-8")).dir ?? null;
const was = JSON.parse(readFileSync(join(historyDir, "project.json"), "utf-8"));
return sameFolder({ ino: was.ino, birthtimeMs: was.born }, folder);
} catch {
return null;
return false;
}
}
/**
* The project's history id, kept in the project so a rename or move keeps its history. A folder whose id is
* still carried by the folder that history was recorded for is a copy, and gets an id of its own.
* The project's history id, kept in the project so a rename or move keeps its history. Where history exists under it,
* only the recorded folder keeps it: same inode and creation time, since an inode alone is reused after a delete.
*/
export function projectHistoryId(projectDir: string, historyRoot: string): string {
const dir = resolve(projectDir);
const folder = statSync(dir);
let id = readId(dir);
const was = id && recordedDir(join(historyRoot, id));
if (!id || (was && was !== dir && existsSync(was) && readId(was) === id)) {
if (
!id ||
(existsSync(join(historyRoot, id)) && !isRecordedFolder(join(historyRoot, id), folder))
) {
id = randomUUID();
mkdirSync(join(dir, ".hyperframes"), { recursive: true });
writeFileSync(join(dir, ID_PATH), `${id}\n`);
}
mkdirSync(join(historyRoot, id), { recursive: true });
writeFileSync(join(historyRoot, id, "project.json"), JSON.stringify({ dir }));
recordProject(join(historyRoot, id), dir, folder);
return id;
}
export function recordProject(historyDir: string, dir: string, folder: FolderIdentity): void {
const record = { dir, ino: folder.ino, born: folder.birthtimeMs };
mkdirSync(historyDir, { recursive: true });
replaceFileAtomically(join(historyDir, "project.json"), JSON.stringify(record), 0o644);
}
@@ -42,7 +42,7 @@ export interface HistoryLog {
pins: Set<string>;
}
type LogRecord =
export type LogRecord =
| { type: "baseline"; files: Record<string, string> }
| { type: "entry"; entry: HistoryEntry }
| { type: "pin"; id: string; pinned: boolean };
@@ -0,0 +1,214 @@
// @vitest-environment node
import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { START, type HistoryWho } from "./historyLog";
import { HistoryClosedError, openProjectHistory } from "./projectHistory";
// Lets a test swap the folder at an exact point inside the history's own copies.
const hooks = vi.hoisted(() => ({
afterWrite: null as null | (() => void),
beforeWrite: null as null | (() => void),
beforePut: null as null | (() => void),
onReceipt: null as null | ((path: string) => void),
recreated: null as null | string,
}));
vi.mock("node:fs", async (importOriginal) => {
const original = await importOriginal<typeof import("node:fs")>();
const statSync = ((path: string, options?: object) => {
const stat = original.statSync(path, options);
if (!stat || resolve(String(path)) !== hooks.recreated) return stat;
return new Proxy(stat, {
get: (target, key) =>
key === "birthtimeMs" ? target.birthtimeMs + 1000 : Reflect.get(target, key),
});
}) as typeof original.statSync;
return { ...original, statSync };
});
vi.mock("../helpers/fileVersion", async (importOriginal) => {
const original = await importOriginal<typeof import("../helpers/fileVersion")>();
return {
...original,
recordFileWriteReceipt: (...args: Parameters<typeof original.recordFileWriteReceipt>) => {
hooks.onReceipt?.(args[1].path);
return original.recordFileWriteReceipt(...args);
},
};
});
vi.mock("./blobStore", async (importOriginal) => {
const original = await importOriginal<typeof import("./blobStore")>();
return {
...original,
openBlobStore: async (dir: string) => {
const store = await original.openBlobStore(dir);
return {
...store,
put: async (path: string) => {
hooks.beforePut?.();
return store.put(path);
},
writeTo: async (hash: string, path: string, beforeReplace?: () => void) => {
hooks.beforeWrite?.();
await store.writeTo(hash, path, beforeReplace);
hooks.afterWrite?.();
},
};
},
};
});
const you: HistoryWho = { kind: "person", name: "You" };
const cleanup: Array<() => unknown> = [];
const files = ["a.html", "b.html", "c.html"];
afterEach(async () => {
hooks.afterWrite = null;
hooks.beforeWrite = null;
hooks.beforePut = null;
hooks.onReceipt = null;
hooks.recreated = null;
for (const step of cleanup.splice(0).reverse()) await step();
});
function tempDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
cleanup.push(() => rmSync(dir, { recursive: true, force: true }));
return dir;
}
function unopened() {
const root = tempDir("hf-history-swap-");
const projectDir = join(root, "project");
const moved = join(root, "moved");
mkdirSync(projectDir);
for (const path of files) writeFileSync(join(projectDir, path), "v1");
const historyRoot = tempDir("hf-history-swap-root-");
const swap = () => {
renameSync(projectDir, moved);
mkdirSync(projectDir);
for (const path of files) writeFileSync(join(projectDir, path), "new project");
};
const texts = (dir: string) => files.map((path) => readFileSync(join(dir, path), "utf-8"));
return { projectDir, historyRoot, moved, swap, texts };
}
async function swappable() {
const folders = unopened();
const history = await openProjectHistory(folders);
cleanup.push(() => history.close());
return { history, ...folders };
}
describe("a project folder swapped while the history is at work", () => {
it("stops a restore at the next file, so none of the old project's bytes land in the new one", async () => {
const { history, projectDir, moved, swap, texts } = await swappable();
const window = await history.beginWindow(you, "Edit");
for (const path of files) writeFileSync(join(projectDir, path), "edited");
await window.close();
hooks.afterWrite = () => {
hooks.afterWrite = null;
swap();
};
await expect(history.restore(START, you)).rejects.toThrow(HistoryClosedError);
expect(texts(projectDir)).toEqual(["new project", "new project", "new project"]);
expect(texts(moved).filter((text) => text === "edited")).toHaveLength(2);
});
it("refuses a restore write whose folder was swapped after its check, before the file is replaced", async () => {
const { history, projectDir, moved, swap, texts } = await swappable();
const window = await history.beginWindow(you, "Edit");
for (const path of files) writeFileSync(join(projectDir, path), "edited");
await window.close();
hooks.beforeWrite = () => {
hooks.beforeWrite = null;
swap();
};
await expect(history.restore(START, you)).rejects.toThrow(HistoryClosedError);
expect([texts(projectDir), texts(moved)]).toEqual([
["new project", "new project", "new project"],
["edited", "edited", "edited"],
]);
});
it("refuses a restore delete whose folder was swapped after its check, so the new project keeps the file", async () => {
const { history, projectDir, swap } = await swappable();
const window = await history.beginWindow(you, "Add");
writeFileSync(join(projectDir, "d.html"), "added");
await window.close();
hooks.onReceipt = (path) => {
if (path !== "d.html") return;
hooks.onReceipt = null;
swap();
writeFileSync(join(projectDir, "d.html"), "new project");
};
await expect(history.restore(START, you, { writeToken: "t" })).rejects.toThrow(
HistoryClosedError,
);
expect(readFileSync(join(projectDir, "d.html"), "utf-8")).toBe("new project");
});
it("opens with a first baseline that skips a file deleted while it was read", async () => {
const { projectDir, historyRoot } = unopened();
let puts = 0;
hooks.beforePut = () => {
if (++puts === 2) rmSync(join(projectDir, "b.html"));
};
const history = await openProjectHistory({ projectDir, historyRoot });
cleanup.push(() => history.close());
expect(history.list()).toEqual([]);
});
it("drops a first baseline read partly from a swapped folder, so the original never gets its bytes", async () => {
const { projectDir, historyRoot, moved, swap, texts } = unopened();
let puts = 0;
hooks.beforePut = () => {
if (++puts === 2) swap();
};
await expect(openProjectHistory({ projectDir, historyRoot })).rejects.toThrow(
HistoryClosedError,
);
hooks.beforePut = null;
const original = await openProjectHistory({ projectDir: moved, historyRoot });
cleanup.push(() => original.close());
await original.restore(START, you);
expect(texts(moved)).toEqual(["v1", "v1", "v1"]);
});
it("refuses, while open, a copy carrying its marker that came back on the recycled inode", async () => {
const { history, projectDir, texts } = await swappable();
const window = await history.beginWindow(you, "Edit");
for (const path of files) writeFileSync(join(projectDir, path), "edited");
await window.close();
hooks.recreated = resolve(projectDir);
expect(() => history.list()).toThrow(HistoryClosedError);
await expect(history.restore(START, you)).rejects.toThrow(HistoryClosedError);
expect(texts(projectDir)).toEqual(["edited", "edited", "edited"]);
});
it("stops a sweep at the next file, so the old history never files the new project's bytes", async () => {
const { history, projectDir, historyRoot, moved, swap } = await swappable();
for (const path of files) writeFileSync(join(projectDir, path), "edited");
let puts = 0;
hooks.beforePut = () => {
if (++puts === 2) swap();
};
await history.flush();
await history.close();
const again = await openProjectHistory({ projectDir: moved, historyRoot });
cleanup.push(() => again.close());
const filed: string[] = [];
for (const entry of again.list())
for (const file of entry.files)
if (file.after) filed.push(String(await again.readBlob(file.after)));
expect(filed).toContain("edited");
expect(filed).not.toContain("new project");
});
});
@@ -20,7 +20,7 @@ import { basename, dirname, join, relative } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { fileContentVersion, recordFileWriteReceipt } from "../helpers/fileVersion";
import { HistoryBusyError } from "./ownerLock";
import { openProjectHistory, type ProjectHistory } from "./projectHistory";
import { HistoryClosedError, openProjectHistory, type ProjectHistory } from "./projectHistory";
import { START, type HistoryWho } from "./historyLog";
const you: HistoryWho = { kind: "person", name: "You" };
@@ -542,6 +542,179 @@ describe("openProjectHistory", () => {
expect(readdirSync(dir)).toEqual([]);
});
it("refuses every call after close, even through a window begun before it", async () => {
const { history, write } = await project({ "index.html": "v1" });
const entry = await change(history, you, "Second", () => write("index.html", "v2"));
const window = await history.beginWindow(agent, "Turn");
await history.close();
await expect(window.close()).rejects.toThrow(HistoryClosedError);
await expect(history.claim(you, "Edit", ["index.html"])).rejects.toThrow(HistoryClosedError);
await expect(history.undo(entry.id, { who: you })).rejects.toThrow(HistoryClosedError);
await expect(history.restore(START, you)).rejects.toThrow(HistoryClosedError);
await expect(history.readBlob("a")).rejects.toThrow(HistoryClosedError);
expect(() => history.list()).toThrow(HistoryClosedError);
});
it("releases ownership only after every close has settled", async () => {
const { history } = await project({ "index.html": "v1" });
let firstDone = false;
const first = history.close().then(() => (firstDone = true));
await history.close();
expect(firstDone).toBe(true);
await first;
});
it("refuses once a new project takes the folder's path, leaving both projects' files alone", async () => {
const { history, write, read, projectDir, historyRoot } = await project({ "index.html": "v1" });
await change(history, you, "Second", () => write("index.html", "v2"));
const window = await history.beginWindow(agent, "Turn");
write("index.html", "v3");
history.noteChange("index.html");
await pause(100);
const moved = `${projectDir}-moved`;
renameSync(projectDir, moved);
cleanup.push(() => rmSync(moved, { recursive: true, force: true }));
mkdirSync(projectDir);
writeFileSync(join(projectDir, "index.html"), "new project");
expect(history.replacedAtPath()).toBe(true);
await expect(history.restore(START, you)).rejects.toThrow(HistoryClosedError);
expect(() => history.list()).toThrow(HistoryClosedError);
// What the turn wrote before the swap is still its entry; nothing of the new project is.
expect((await window.close())?.files.map((file) => file.path)).toEqual(["index.html"]);
await history.close();
expect([read("index.html"), readFileSync(join(moved, "index.html"), "utf-8")]).toEqual([
"new project",
"v3",
]);
const again = await open(moved, historyRoot);
expect(again.list().map((entry) => entry.label)).toEqual(["Second", "Turn"]);
});
it("refuses to write into a folder moved away, so the old path is not recreated", async () => {
const { history, write, projectDir } = await project({ "index.html": "v1" });
await change(history, you, "Second", () => write("index.html", "v2"));
renameSync(projectDir, `${projectDir}-moved`);
cleanup.push(() => rmSync(`${projectDir}-moved`, { recursive: true, force: true }));
await expect(history.restore(START, you)).rejects.toThrow("The project folder is gone");
expect(existsSync(projectDir)).toBe(false);
expect(history.replacedAtPath()).toBe(false);
});
it.each([
["a new project took the folder's path", "new"],
["a copy carrying its id took the folder's path", "copy"],
["the folder moved away", "none"],
])(
"refuses to open, so a server can retry, once %s while it waited for the history",
async (_, replacement) => {
const { history, projectDir, historyRoot } = await project({ "index.html": "v1" });
const waiting = openProjectHistory({ projectDir, historyRoot });
waiting.then((late) => cleanup.push(() => late.close())).catch(() => {});
await pause(50);
renameSync(projectDir, `${projectDir}-moved`);
cleanup.push(() => rmSync(`${projectDir}-moved`, { recursive: true, force: true }));
if (replacement === "new") mkdirSync(projectDir);
if (replacement === "copy") cpSync(`${projectDir}-moved`, projectDir, { recursive: true });
await history.close();
await expect(waiting).rejects.toThrow(HistoryClosedError);
},
);
it("refuses, so a server can retry, to open a folder that is not there", async () => {
const projectDir = join(tempDir("hf-history-gone-"), "project");
const historyRoot = tempDir("hf-history-root-");
await expect(openProjectHistory({ projectDir, historyRoot })).rejects.toThrow(
HistoryClosedError,
);
});
it("gives a folder whose history record cannot prove it is that folder a history of its own", async () => {
const { history, write, projectDir, historyRoot } = await project({ "index.html": "v1" });
await change(history, you, "Old change", () => write("index.html", "v2"));
await history.close();
writeFileSync(
join(historyRoot, history.projectId, "project.json"),
JSON.stringify({ dir: projectDir }),
);
const again = await open(projectDir, historyRoot);
expect([again.projectId === history.projectId, again.list()]).toEqual([false, []]);
});
it("leaves the id alone for a history root that has no history under it, so the open one keeps recording", async () => {
const { history, write, projectDir } = await project({ "index.html": "v1" });
const other = await open(projectDir, tempDir("hf-history-other-root-"));
await change(history, you, "Edit", () => write("index.html", "v2"));
expect([other.projectId, history.list().length]).toEqual([history.projectId, 1]);
});
it("gives a folder on a reused inode, created at another time, a history of its own", async () => {
const { history, write, projectDir, historyRoot } = await project({ "index.html": "v1" });
await change(history, you, "Old change", () => write("index.html", "v2"));
await history.close();
const record = join(historyRoot, history.projectId, "project.json");
const was = JSON.parse(readFileSync(record, "utf-8"));
writeFileSync(record, JSON.stringify({ ...was, born: was.born - 1000 }));
const again = await open(projectDir, historyRoot);
expect([again.projectId === history.projectId, again.list()]).toEqual([false, []]);
});
it("keeps the history when the disk's device number changed, as a remounted drive's does", async () => {
const { history, write, projectDir, historyRoot } = await project({ "index.html": "v1" });
await change(history, you, "Old change", () => write("index.html", "v2"));
await history.close();
const record = join(historyRoot, history.projectId, "project.json");
writeFileSync(
record,
JSON.stringify({ ...JSON.parse(readFileSync(record, "utf-8")), dev: -1 }),
);
const again = await open(projectDir, historyRoot);
expect(again.list().map((entry) => entry.label)).toEqual(["Old change"]);
});
it("counts a folder whose history id is gone as another project, as a recreated folder on a reused inode is", async () => {
const { history, write, read, projectDir } = await project({ "index.html": "v1" });
await change(history, you, "Second", () => write("index.html", "v2"));
rmSync(join(projectDir, ".hyperframes"), { recursive: true });
write("index.html", "new project");
expect(history.replacedAtPath()).toBe(true);
await expect(history.restore(START, you)).rejects.toThrow(HistoryClosedError);
expect(read("index.html")).toBe("new project");
});
it.each([["the copy"], ["the moved original"]])(
"gives a copy put where the original was its own history, and the moved original keeps its own, %s opened first",
async (first) => {
const { history, write, projectDir, historyRoot } = await project({ "index.html": "v1" });
await change(history, you, "Old change", () => write("index.html", "v2"));
await history.close();
const moved = `${projectDir}-moved`;
cpSync(projectDir, `${projectDir}-copy`, { recursive: true });
renameSync(projectDir, moved);
renameSync(`${projectDir}-copy`, projectDir);
cleanup.push(() => rmSync(moved, { recursive: true, force: true }));
const labels = async (dir: string) => {
const opened = await open(dir, historyRoot);
const list = opened.list().map((entry) => entry.label);
await opened.close();
return list;
};
const order = first === "the copy" ? [projectDir, moved] : [moved, projectDir];
const [a, b] = [await labels(order[0]!), await labels(order[1]!)];
const [copy, original] = first === "the copy" ? [a, b] : [b, a];
expect({ copy, original }).toEqual({ copy: [], original: ["Old change"] });
},
);
it("keeps the history across a move and a reopen, and a copy of the folder starts its own", async () => {
const { history, write, projectDir, historyRoot } = await project({ "index.html": "v1" });
write("index.html", "v2");
@@ -10,6 +10,7 @@ import {
readdirSync,
readFileSync,
realpathSync,
rmSync,
} from "node:fs";
import { homedir } from "node:os";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -25,7 +26,15 @@ import {
} from "../helpers/fileVersion.js";
import { affectsProjectSignature, listProjectFiles } from "../helpers/projectSignature.js";
import { openBlobStore, type BlobStore } from "./blobStore.js";
import { ID_PATH, projectHistoryId } from "./historyId.js";
import {
ID_PATH,
isRecordedFolder,
sameFolder,
projectHistoryId,
readId,
recordProject,
type FolderIdentity,
} from "./historyId.js";
import { takeHistoryOwnership } from "./ownerLock.js";
import {
START,
@@ -43,6 +52,7 @@ import {
type HistoryFileChange,
type HistoryLog,
type HistoryWho,
type LogRecord,
type Manifest,
} from "./historyLog.js";
@@ -149,6 +159,8 @@ export interface ProjectHistory {
onEntry(listener: (entry: HistoryEntry) => void): () => void;
/** Takes in every pending write and commits every open window and the outside group. */
flush(): Promise<void>;
/** True once another project stands at the folder's path: open that one's history instead. */
replacedAtPath(): boolean;
close(): Promise<void>;
}
@@ -255,6 +267,7 @@ function addChange(group: Group, path: string, before: string | null, after: str
class Engine {
readonly dir: string;
readonly foldsCase: boolean;
readonly folder: FolderIdentity & { dev: number };
readonly home: string;
log: HistoryLog = { baseline: new Map(), entries: [], pins: new Set() };
tracked = new Map<string, Tracked>();
@@ -268,6 +281,8 @@ class Engine {
notedTimer: NodeJS.Timeout | null = null;
listeners = new Set<(entry: HistoryEntry) => void>();
tail: Promise<unknown> = Promise.resolve();
closed = false;
closing: Promise<void> | undefined;
constructor(
readonly options: ProjectHistoryOptions,
@@ -277,6 +292,11 @@ class Engine {
this.dir = resolve(options.projectDir);
this.home = join(options.historyRoot, projectId);
this.foldsCase = ignoresCase(this.dir);
const folder = statSync(this.dir, { throwIfNoEntry: false });
// Checked after the ownership wait, so a folder swapped for a copy meanwhile is refused.
if (!folder || readId(this.dir) !== projectId || !isRecordedFolder(this.home, folder))
throw this.replaced();
this.folder = { dev: folder.dev, ino: folder.ino, birthtimeMs: folder.birthtimeMs };
}
/** A path as the project's disk compares names: folded where `A` and `a` are one name. */
@@ -294,11 +314,43 @@ class Engine {
/** One operation at a time, in order: sweeps, windows and undos never interleave. */
queue<T>(task: () => Promise<T>): Promise<T> {
if (this.closed)
return Promise.reject(new HistoryClosedError("This project's history is closed."));
const run = this.tail.then(task);
this.tail = run.catch(() => undefined);
return run;
}
/** Another project if the folder or its id changed, or the id is gone: a recreated folder may reuse the inode. */
whereFolder(): "here" | "gone" | "replaced" {
const now = statSync(this.dir, { throwIfNoEntry: false });
if (!now) return "gone";
const same = now.dev === this.folder.dev && sameFolder(now, this.folder);
return same && readId(this.dir) === this.projectId ? "here" : "replaced";
}
assertWritable(): void {
const folder = this.whereFolder();
if (folder === "gone") throw new Error(`The project folder is gone: ${this.dir}`);
if (folder === "replaced") throw this.replaced();
}
persistLog(record?: LogRecord): void {
if (record) saveRecord(this.logFile, this.log, record);
else writeLog(this.logFile, this.log);
if (!existsSync(join(this.home, "project.json")))
recordProject(this.home, this.dir, this.folder);
}
replaced(): HistoryClosedError {
return new HistoryClosedError(`${this.dir} is now another project.`);
}
assertOpen(): void {
if (this.closed) throw new HistoryClosedError("This project's history is closed.");
if (this.whereFolder() === "replaced") throw this.replaced();
}
async start(): Promise<void> {
const log = readLog(this.logFile, (line) =>
this.options.onError?.(
@@ -327,13 +379,13 @@ class Engine {
async firstOpen(): Promise<void> {
const sweptAt = Date.now();
for (const file of listProjectFiles(this.dir))
this.tracked.set(file.path, {
hash: await this.blobs.put(join(this.dir, file.path)),
stat: statKey(file, sweptAt),
});
for (const file of listProjectFiles(this.dir)) {
const hash = await this.storeIfPresent(file.path);
if (this.whereFolder() !== "here") throw this.replaced();
if (hash !== null) this.tracked.set(file.path, { hash, stat: statKey(file, sweptAt) });
}
this.log.baseline = this.manifest();
writeLog(this.logFile, this.log);
this.persistLog();
this.saveStatCache();
}
@@ -361,8 +413,8 @@ class Engine {
* project (a stat per file, a hash only when the stat moved); per-path sweeps if projects reach tens of thousands.
*/
async sweep(): Promise<void> {
// A missing project folder was moved or removed, not emptied: that is no change to its files.
if (!existsSync(this.dir)) return;
// A folder moved or removed was not emptied, and another project at its path is none of this history's.
if (this.whereFolder() !== "here") return;
const sweptAt = Date.now();
const changedAt = (file: { mtimeMs: number; ctimeMs: number }) =>
Math.min(sweptAt, Math.max(file.mtimeMs, file.ctimeMs));
@@ -414,7 +466,7 @@ class Engine {
const known = this.tracked.get(path) ?? { hash: null, stat: null };
if (stat && known.stat === stat) return false;
const hash = await this.storeIfPresent(path);
if (hash === null) return false;
if (hash === null || this.whereFolder() !== "here") return false;
this.tracked.set(path, { hash, stat });
if (known.hash !== hash) await this.record(path, known.hash, hash, at);
return known.hash !== hash || known.stat !== stat;
@@ -630,7 +682,7 @@ class Engine {
const entry: HistoryEntry = { ...pending, endedAt, ...extra };
this.log.entries.push(entry);
try {
saveRecord(this.logFile, this.log, { type: "entry", entry });
this.persistLog({ type: "entry", entry });
} catch (error) {
this.log.entries.pop();
throw error;
@@ -657,7 +709,7 @@ class Engine {
new Set([...referencedHashes(this.log, this.manifest()), ...this.pendingHashes()]),
);
}
if (folded) writeLog(this.logFile, this.log);
if (folded) this.persistLog();
}
/** Hashes only pending changes point at yet, such as a claim's stored cut. */
@@ -688,7 +740,9 @@ class Engine {
}
background(task: () => Promise<unknown>): void {
this.queue(task).catch((error) => this.options.onError?.(error));
this.queue(task).catch((error) => {
if (!(error instanceof HistoryClosedError)) this.options.onError?.(error);
});
}
beginWindow(who: HistoryWho, label: string, idleMs: number): Promise<HistoryWindow> {
@@ -770,6 +824,7 @@ class Engine {
target: Map<string, string | null>,
extra: Partial<HistoryEntry>,
): Promise<HistoryEntry | null> {
this.assertWritable();
const changes = [...target]
.filter(([path, hash]) => (this.tracked.get(path)?.hash ?? null) !== hash)
.sort(([, a], [, b]) => Number(a !== null) - Number(b !== null));
@@ -792,16 +847,19 @@ class Engine {
/** Writes one project file (null deletes it), first leaving the running operation's receipt for its echo. */
async writeProjectFile(path: string, hash: string | null): Promise<void> {
this.assertWritable();
const absPath = join(this.dir, path);
const { writeToken } = this;
if (writeToken) {
const version = hash === null ? DELETED_VERSION : hashVersion(hash);
recordFileWriteReceipt(absPath, { path, version, writeToken });
}
if (hash === null) await rm(absPath, { force: true });
else {
if (hash === null) {
this.assertWritable();
rmSync(absPath, { force: true });
} else {
await removeEmptyFolders(absPath);
await this.blobs.writeTo(hash, absPath);
await this.blobs.writeTo(hash, absPath, () => this.assertWritable());
}
}
@@ -905,8 +963,11 @@ class Engine {
this.beginWindow(who, label, options.idleMs ?? this.options.maxGroupMs ?? 30_000),
claim: (who, label, paths, options = {}) =>
this.queue(() => this.claimNow(who, label, paths, options)),
noteChange: (path) => this.noteChange(path),
noteChange: (path) => {
if (!this.closed) this.noteChange(path);
},
list: () => {
this.assertOpen();
const undone = undoneIds(this.log.entries);
return this.log.entries.map((entry) => ({
...entry,
@@ -926,6 +987,7 @@ class Engine {
this.restoreNow(point, who, `Restored: ${this.pointLabel(point)}`),
),
peek: (point) => {
this.assertOpen();
const files = manifestAt(this.log, point);
return files && Object.fromEntries(files);
},
@@ -947,23 +1009,34 @@ class Engine {
throw error;
}
}),
next: (direction, who) => this.next(direction, who),
readBlob: (hash) => this.blobs.read(hash),
next: (direction, who) => {
this.assertOpen();
return this.next(direction, who);
},
readBlob: async (hash) => {
this.assertOpen();
return this.blobs.read(hash);
},
pin: (id, pinned) => {
this.assertOpen();
this.entry(id);
if (pinned) this.log.pins.add(id);
else this.log.pins.delete(id);
saveRecord(this.logFile, this.log, { type: "pin", id, pinned });
this.persistLog({ type: "pin", id, pinned });
},
onEntry: (listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
},
flush: () => this.queue(() => this.settleAll()),
close: async () => {
if (this.notedTimer) clearTimeout(this.notedTimer);
await this.queue(() => this.settleAll());
},
replacedAtPath: () => this.whereFolder() === "replaced",
close: () =>
(this.closing ??= (async () => {
if (this.notedTimer) clearTimeout(this.notedTimer);
const settled = this.queue(() => this.settleAll());
this.closed = true;
await settled;
})()),
};
}
}
@@ -981,8 +1054,18 @@ function missingIsEmpty(error: NodeJS.ErrnoException): string[] {
throw error;
}
/** A call on a history that was closed, or whose folder is now another project. */
export class HistoryClosedError extends Error {
constructor(message: string) {
super(message);
this.name = "HistoryClosedError";
}
}
/** Opens a project's history: every write to its files becomes an entry that can be undone or restored. */
export async function openProjectHistory(options: ProjectHistoryOptions): Promise<ProjectHistory> {
if (!existsSync(options.projectDir))
throw new HistoryClosedError(`${options.projectDir} is gone or now another project.`);
const projectId = projectHistoryId(options.projectDir, options.historyRoot);
const release = await takeHistoryOwnership(
join(options.historyRoot, projectId),
+2
View File
@@ -13,8 +13,10 @@ export {
type HistoryListItem,
type HistoryResult,
type HistoryWindow,
HistoryClosedError,
} from "./history/projectHistory.js";
export { HistoryBusyError } from "./history/ownerLock.js";
export { historyCache } from "./history/historyCache.js";
export {
START as HISTORY_START,
type HistoryEntry,
+53 -2
View File
@@ -1,6 +1,15 @@
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import {
existsSync,
mkdtempSync,
mkdirSync,
realpathSync,
renameSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, win32 } from "node:path";
import { isValidProjectId } from "./src/utils/projectRouting";
@@ -52,6 +61,46 @@ describe("Studio's dev server keeps each project's history", () => {
await history?.close();
}
});
it("opens a new project's own history once it takes the folder's path", async () => {
const { data, adapter, app } = fixture();
mkdirSync(join(data, "demo"));
writeFileSync(join(data, "demo", "index.html"), "A");
const old = await adapter.history!(adapter.resolveProject("demo")!);
renameSync(join(data, "demo"), join(data, "demo-moved"));
mkdirSync(join(data, "demo"));
writeFileSync(join(data, "demo", "index.html"), "new");
const list = await app.request("http://localhost/projects/demo/history");
expect(list.status).toBe(200);
expect(existsSync(join(data, "demo", ".hyperframes", "history-id"))).toBe(true);
const fresh = await adapter.history!(adapter.resolveProject("demo")!);
expect(fresh?.projectId).not.toBe(old?.projectId);
await fresh?.close();
});
});
describe("Studio's dev server retries a history whose folder changed while it opened", () => {
it("opens it again on the next request", async () => {
const root = mkdtempSync(join(tmpdir(), "hf-project-history-retry-"));
roots.push(root);
mkdirSync(join(root, "demo"));
const history = { replacedAtPath: () => false } as unknown as ProjectHistory;
const openHistory = vi
.fn()
.mockRejectedValueOnce(
Object.assign(new Error("now another project"), { name: "HistoryClosedError" }),
)
.mockResolvedValue(history);
const adapter = createViteAdapter(
root,
{} as ViteDevServer,
createProjectSignatureCache({ compute: () => "test" }),
{ openHistory },
);
expect(await adapter.history!(adapter.resolveProject("demo")!)).toBeNull();
expect(await adapter.history!(adapter.resolveProject("demo")!)).toBe(history);
});
});
describe("Studio's dev server closes the histories it opened when it stops", () => {
@@ -61,7 +110,9 @@ describe("Studio's dev server closes the histories it opened when it stops", ()
mkdirSync(join(root, "demo"));
const httpServer = new EventEmitter();
const close = vi.fn(async () => {});
const openHistory = vi.fn(async () => ({ close }) as unknown as ProjectHistory);
const openHistory = vi.fn(
async () => ({ close, replacedAtPath: () => false }) as unknown as ProjectHistory,
);
const adapter = createViteAdapter(
root,
{ httpServer } as unknown as ViteDevServer,
+15 -16
View File
@@ -11,7 +11,7 @@ import {
copyFileSync,
unlinkSync,
} from "node:fs";
import { join, relative, resolve, isAbsolute, dirname, sep } from "node:path";
import { basename, join, relative, resolve, isAbsolute, dirname, sep } from "node:path";
import type { ViteDevServer } from "vite";
import {
type ResolvedProject,
@@ -24,7 +24,7 @@ import {
PREVIEW_BUNDLE_OPTIONS,
DEFAULT_HISTORY_ROOT,
openProjectHistory,
type ProjectHistory,
historyCache,
} from "@hyperframes/studio-server";
import type { RegistryItem } from "@hyperframes/core/registry";
import type { BundleOptions } from "@hyperframes/core/compiler";
@@ -109,11 +109,17 @@ export function createViteAdapter(
openHistory = openProjectHistory,
}: { historyRoot?: string; openHistory?: typeof openProjectHistory } = {},
): StudioApiAdapter {
const histories = new Map<string, Promise<ProjectHistory | null>>();
const histories = historyCache((projectDir) =>
openHistory({ projectDir, historyRoot }).catch((error: unknown) => {
console.warn(`[studio] Project history is off for ${basename(projectDir)}: ${String(error)}`);
// By name: the dev server's engine is its own module copy, so its error class is not this import's.
if (error instanceof Error && error.name === "HistoryClosedError")
histories.forget(projectDir);
return null;
}),
);
// Commits any open edit when the dev server stops, so it keeps its label.
server.httpServer?.on("close", () => {
for (const opened of histories.values()) void opened.then((history) => history?.close());
});
server.httpServer?.on("close", () => void histories.closeAll());
let _bundler: ((dir: string, options?: BundleOptions) => Promise<string>) | null = null;
let _producerModuleLoader:
| (() => Promise<{
@@ -212,17 +218,10 @@ export function createViteAdapter(
.sort((a, b) => (a.title ?? "").localeCompare(b.title ?? ""));
},
// Studio's undo runs on the project's history: opened once per project, and a failed open stays off.
// Studio's undo runs on the project's history: opened once per project; a failed open stays off unless the folder
// changed while it opened.
history(project: ResolvedProject) {
let opened = histories.get(project.dir);
if (!opened) {
opened = openHistory({ projectDir: project.dir, historyRoot }).catch((error: unknown) => {
console.warn(`[studio] Project history is off for ${project.id}: ${String(error)}`);
return null;
});
histories.set(project.dir, opened);
}
return opened;
return histories.get(project.dir);
},
// fallow-ignore-next-line complexity