Skip to content

Commit c905172

Browse files
committed
fix(studio-server): history refuses once closed and reopens when a new project takes its folder
1 parent 162de1a commit c905172

14 files changed

Lines changed: 711 additions & 79 deletions

‎packages/cli/src/commands/history.test.ts‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
// @vitest-environment node
22
import { spawn } from "node:child_process";
3-
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3+
import {
4+
mkdirSync,
5+
mkdtempSync,
6+
readdirSync,
7+
readFileSync,
8+
renameSync,
9+
rmSync,
10+
writeFileSync,
11+
} from "node:fs";
412
import type { AddressInfo } from "node:net";
513
import { tmpdir } from "node:os";
614
import { join, resolve } from "node:path";
@@ -16,7 +24,7 @@ import { runCommand } from "citty";
1624
import { Hono } from "hono";
1725
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
1826
import { consumeCommandResult } from "../utils/commandResult.js";
19-
import { historyDeps } from "../utils/historyOwner.js";
27+
import { historyDeps, withOwner } from "../utils/historyOwner.js";
2028
import historyCommand from "./history.js";
2129

2230
const pause = (ms: number) => new Promise((settle) => setTimeout(settle, ms));
@@ -247,6 +255,24 @@ describe.each(["direct", "preview"])("hyperframes history (%s)", (mode) => {
247255
});
248256

249257
describe("hyperframes history, one owner", () => {
258+
it("lets go of the history when a new project takes the folder's path mid-command", async () => {
259+
const { dir, hf } = project();
260+
await hf("begin", "--who", "claude", "--label", "Retitle");
261+
const moved = `${dir}-moved`;
262+
onTestFinished(() => rmSync(moved, { recursive: true, force: true }));
263+
const swap = async () => {
264+
renameSync(dir, moved);
265+
mkdirSync(dir);
266+
};
267+
await expect(withOwner(dir, swap)).rejects.toThrow("now another project");
268+
const reopened = await openProjectHistory({
269+
projectDir: moved,
270+
historyRoot: historyDeps.historyRoot,
271+
ownerWaitMs: 200,
272+
});
273+
await reopened.close();
274+
});
275+
250276
it("goes through a running preview, so the log keeps one baseline and every entry once", async () => {
251277
const { dir, json, turn } = project();
252278
const held = await preview(dir);
@@ -400,6 +426,7 @@ describe("hyperframes history, one owner", () => {
400426
env: {
401427
...process.env,
402428
HOME: home,
429+
USERPROFILE: home,
403430
HYPERFRAMES_SKIP_UPDATE_CHECK: "1",
404431
HYPERFRAMES_NO_TELEMETRY: "1",
405432
},

‎packages/cli/src/server/studioServer.test.ts‎

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
import { EventEmitter } from "node:events";
22
import { afterEach, describe, expect, it, vi } from "vitest";
3-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3+
import {
4+
existsSync,
5+
mkdirSync,
6+
mkdtempSync,
7+
readFileSync,
8+
renameSync,
9+
rmSync,
10+
writeFileSync,
11+
} from "node:fs";
412
import { tmpdir } from "node:os";
513
import { basename, join } from "node:path";
614
import {
715
createProjectSignature,
816
fileContentVersion,
917
HistoryBusyError,
18+
HistoryClosedError,
1019
} from "@hyperframes/studio-server";
1120
import { loadHyperframeRuntimeSource } from "@hyperframes/core";
1221
import { loadRuntimeSource } from "./runtimeSource.js";
@@ -156,16 +165,38 @@ describe("createStudioServer project history (D-491)", () => {
156165
await server.shutdown();
157166
});
158167

159-
it("tries a history another process was holding again on the next request, instead of turning it off", async () => {
160-
historyState.open = async () => {
161-
historyState.open = null;
162-
throw new HistoryBusyError(1);
163-
};
168+
it.each([
169+
["another process was holding", new HistoryBusyError(1)],
170+
["whose folder changed while it opened", new HistoryClosedError("now another project")],
171+
])(
172+
"tries a history %s again on the next request, instead of turning it off",
173+
async (_, refusal) => {
174+
historyState.open = async () => {
175+
historyState.open = null;
176+
throw refusal;
177+
};
178+
const projectDir = tmpProject();
179+
server = createStudioServer({ projectDir, historyRoot: tmpProject() });
180+
const historyUrl = `/api/projects/${encodeURIComponent(basename(projectDir))}/history`;
181+
expect((await server.app.request(historyUrl)).status).toBe(404);
182+
expect((await server.app.request(historyUrl)).status).toBe(200);
183+
await server.shutdown();
184+
},
185+
);
186+
187+
it("opens a new project's own history once it takes the folder's path", async () => {
164188
const projectDir = tmpProject();
189+
writeFileSync(join(projectDir, "index.html"), "<html>before</html>");
165190
server = createStudioServer({ projectDir, historyRoot: tmpProject() });
166191
const historyUrl = `/api/projects/${encodeURIComponent(basename(projectDir))}/history`;
167-
expect((await server.app.request(historyUrl)).status).toBe(404);
168192
expect((await server.app.request(historyUrl)).status).toBe(200);
193+
renameSync(projectDir, `${projectDir}-moved`);
194+
dirs.push(`${projectDir}-moved`);
195+
mkdirSync(projectDir);
196+
writeFileSync(join(projectDir, "index.html"), "<html>new</html>");
197+
198+
expect((await server.app.request(historyUrl)).status).toBe(200);
199+
expect(existsSync(join(projectDir, ".hyperframes", "history-id"))).toBe(true);
169200
await server.shutdown();
170201
});
171202

‎packages/cli/src/server/studioServer.ts‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ import {
5656
DEFAULT_HISTORY_ROOT,
5757
openProjectHistory,
5858
HistoryBusyError,
59-
type ProjectHistory,
59+
HistoryClosedError,
60+
historyCache,
6061
} from "@hyperframes/studio-server";
6162
import { resolveAutoProxy } from "../utils/projectConfig.js";
6263
import { getElementScreenshotClip } from "@hyperframes/studio-server/screenshot-clip";
@@ -429,18 +430,20 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
429430

430431
// Opened on first use, so a server that never serves Studio's history never writes one. A failed open stays off
431432
// for this run; one another process was holding is tried again on the next request.
432-
let history: Promise<ProjectHistory | null> | undefined;
433-
const projectHistory = () =>
434-
(history ??= openProjectHistory({
433+
const histories = historyCache(() =>
434+
openProjectHistory({
435435
projectDir,
436436
historyRoot: options.historyRoot ?? DEFAULT_HISTORY_ROOT,
437437
}).catch((error: unknown) => {
438438
console.warn(`[studio] Project history is off: ${String(error)}`);
439-
if (error instanceof HistoryBusyError) history = undefined;
439+
if (error instanceof HistoryBusyError || error instanceof HistoryClosedError)
440+
histories.forget(projectDir);
440441
return null;
441-
}));
442+
}),
443+
);
444+
const projectHistory = () => histories.get(projectDir);
442445
watcher.addListener((changedPath) => {
443-
void history?.then((opened) => opened?.noteChange(changedPath));
446+
void histories.peek(projectDir)?.then((opened) => opened?.noteChange(changedPath));
444447
});
445448

446449
const inFlightRenders = new Map<AbortController, Promise<void>>();
@@ -1098,7 +1101,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
10981101
const shutdown = async (): Promise<void> => {
10991102
shuttingDown = true;
11001103
// Commits any open edit window; bounded with the renders below, so a history still opening cannot hold exit.
1101-
const closeHistory = history?.then((opened) => opened?.close()).catch(() => {});
1104+
const closeHistory = histories.closeAll().catch(() => {});
11021105
const renders = [...inFlightRenders];
11031106
for (const [abortController] of renders) abortController.abort();
11041107
const { killTrackedProcesses, closeBrowserPool } = await import("@hyperframes/engine");

‎packages/cli/src/utils/historyOwner.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,12 @@ export async function withOwner<T>(
210210
} finally {
211211
// This open filed the turn so far under its id; the turn goes on under a fresh one, from its last write.
212212
const direct = owner.via === "direct" && turn;
213-
const kept = direct && (await owner.list()).find((entry) => entry.id === turn.id);
214-
await owner.close();
213+
let kept: HistoryListItem | undefined;
214+
try {
215+
kept = direct ? (await owner.list()).find((entry) => entry.id === turn.id) : undefined;
216+
} finally {
217+
await owner.close();
218+
}
215219
if (direct && readTurn(projectDir)?.id === turn.id) {
216220
const lastWriteAt = kept ? kept.endedAt : turn.lastWriteAt;
217221
const parts = kept ? [...turn.parts, kept.id] : turn.parts;

‎packages/studio-server/src/history/blobStore.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createHash, randomUUID } from "node:crypto";
2-
import { constants, createReadStream } from "node:fs";
2+
import { constants, createReadStream, renameSync } from "node:fs";
33
import { copyFile, mkdir, readdir, readFile, rename, rm, stat } from "node:fs/promises";
44
import { dirname, join } from "node:path";
55

@@ -10,7 +10,7 @@ export interface BlobStore {
1010
has(hash: string): boolean;
1111
read(hash: string): Promise<Buffer>;
1212
/** Writes the blob's bytes to `absPath` by clone-or-copy and rename, so a reader never sees half a file. */
13-
writeTo(hash: string, absPath: string): Promise<void>;
13+
writeTo(hash: string, absPath: string, beforeReplace?: () => void): Promise<void>;
1414
bytes(): number;
1515
size(hash: string): number;
1616
prune(keep: ReadonlySet<string>): Promise<void>;
@@ -24,12 +24,13 @@ async function hashFile(path: string): Promise<string> {
2424
return hash.digest("hex");
2525
}
2626

27-
async function cloneOrCopy(from: string, to: string): Promise<void> {
27+
async function cloneOrCopy(from: string, to: string, beforeReplace?: () => void): Promise<void> {
2828
await mkdir(dirname(to), { recursive: true });
2929
const temp = `${to}.${randomUUID()}.tmp`;
3030
try {
3131
await copyFile(from, temp, constants.COPYFILE_FICLONE);
32-
await rename(temp, to);
32+
beforeReplace?.();
33+
renameSync(temp, to);
3334
} catch (error) {
3435
await rm(temp, { force: true });
3536
throw error;
@@ -70,7 +71,8 @@ export async function openBlobStore(dir: string): Promise<BlobStore> {
7071
},
7172
has: (hash) => sizes.has(hash),
7273
read: async (hash) => readFile(pathOf(hash)),
73-
writeTo: async (hash, absPath) => cloneOrCopy(pathOf(hash), absPath),
74+
writeTo: async (hash, absPath, beforeReplace) =>
75+
cloneOrCopy(pathOf(hash), absPath, beforeReplace),
7476
bytes: () => total,
7577
size: (hash) => sizes.get(hash) ?? 0,
7678
async prune(keep) {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { ProjectHistory } from "./projectHistory.js";
2+
3+
type Opened = Promise<ProjectHistory | null>;
4+
5+
/** A host's histories, one per project folder, opened on first use; a new project at the path gets its own. */
6+
export function historyCache(open: (projectDir: string) => Opened) {
7+
const histories = new Map<string, Opened>();
8+
const get = async (projectDir: string): Promise<ProjectHistory | null> => {
9+
let cached = histories.get(projectDir);
10+
if (!cached) histories.set(projectDir, (cached = open(projectDir)));
11+
const opened = await cached;
12+
if (!opened?.replacedAtPath()) return opened;
13+
const reopen = () => open(projectDir);
14+
if (histories.get(projectDir) === cached)
15+
histories.set(projectDir, opened.close().then(reopen, reopen));
16+
return get(projectDir);
17+
};
18+
return {
19+
get,
20+
/** The history if one was opened, without opening it. */
21+
peek: (projectDir: string) => histories.get(projectDir),
22+
forget: (projectDir: string) => histories.delete(projectDir),
23+
closeAll: () =>
24+
Promise.all(
25+
[...histories.values()].map((opened) => opened.then((history) => history?.close())),
26+
),
27+
};
28+
}
Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { randomUUID } from "node:crypto";
2-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
33
import { join, resolve } from "node:path";
4+
import { replaceFileAtomically } from "../helpers/atomicFile.js";
45

56
export const ID_PATH = join(".hyperframes", "history-id");
67
/** The only shape minted here; the id is project content and becomes a path, so nothing else is trusted. */
78
const ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
89

9-
function readId(projectDir: string): string | null {
10+
export function readId(projectDir: string): string | null {
1011
try {
1112
const id = readFileSync(join(projectDir, ID_PATH), "utf-8").trim();
1213
return ID_SHAPE.test(id) ? id : null;
@@ -15,28 +16,42 @@ function readId(projectDir: string): string | null {
1516
}
1617
}
1718

18-
function recordedDir(historyDir: string): string | null {
19+
export type FolderIdentity = { ino: number; birthtimeMs: number };
20+
21+
export const sameFolder = (a: FolderIdentity, b: FolderIdentity) =>
22+
a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
23+
24+
export function isRecordedFolder(historyDir: string, folder: FolderIdentity): boolean {
1925
try {
20-
return JSON.parse(readFileSync(join(historyDir, "project.json"), "utf-8")).dir ?? null;
26+
const was = JSON.parse(readFileSync(join(historyDir, "project.json"), "utf-8"));
27+
return sameFolder({ ino: was.ino, birthtimeMs: was.born }, folder);
2128
} catch {
22-
return null;
29+
return false;
2330
}
2431
}
2532

2633
/**
27-
* The project's history id, kept in the project so a rename or move keeps its history. A folder whose id is
28-
* still carried by the folder that history was recorded for is a copy, and gets an id of its own.
34+
* The project's history id, kept in the project so a rename or move keeps its history. Where history exists under it,
35+
* only the recorded folder keeps it: same inode and creation time, since an inode alone is reused after a delete.
2936
*/
3037
export function projectHistoryId(projectDir: string, historyRoot: string): string {
3138
const dir = resolve(projectDir);
39+
const folder = statSync(dir);
3240
let id = readId(dir);
33-
const was = id && recordedDir(join(historyRoot, id));
34-
if (!id || (was && was !== dir && existsSync(was) && readId(was) === id)) {
41+
if (
42+
!id ||
43+
(existsSync(join(historyRoot, id)) && !isRecordedFolder(join(historyRoot, id), folder))
44+
) {
3545
id = randomUUID();
3646
mkdirSync(join(dir, ".hyperframes"), { recursive: true });
3747
writeFileSync(join(dir, ID_PATH), `${id}\n`);
3848
}
39-
mkdirSync(join(historyRoot, id), { recursive: true });
40-
writeFileSync(join(historyRoot, id, "project.json"), JSON.stringify({ dir }));
49+
recordProject(join(historyRoot, id), dir, folder);
4150
return id;
4251
}
52+
53+
export function recordProject(historyDir: string, dir: string, folder: FolderIdentity): void {
54+
const record = { dir, ino: folder.ino, born: folder.birthtimeMs };
55+
mkdirSync(historyDir, { recursive: true });
56+
replaceFileAtomically(join(historyDir, "project.json"), JSON.stringify(record), 0o644);
57+
}

‎packages/studio-server/src/history/historyLog.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export interface HistoryLog {
4242
pins: Set<string>;
4343
}
4444

45-
type LogRecord =
45+
export type LogRecord =
4646
| { type: "baseline"; files: Record<string, string> }
4747
| { type: "entry"; entry: HistoryEntry }
4848
| { type: "pin"; id: string; pinned: boolean };

0 commit comments

Comments
 (0)