Skip to content

Commit 19e86ab

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

11 files changed

Lines changed: 405 additions & 58 deletions

File tree

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

Lines changed: 28 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);

‎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;
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: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
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";
44

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

9-
function readId(projectDir: string): string | null {
9+
export function readId(projectDir: string): string | null {
1010
try {
1111
const id = readFileSync(join(projectDir, ID_PATH), "utf-8").trim();
1212
return ID_SHAPE.test(id) ? id : null;
@@ -15,28 +15,36 @@ function readId(projectDir: string): string | null {
1515
}
1616
}
1717

18-
function recordedDir(historyDir: string): string | null {
18+
type Recorded = { dir?: string; ino?: number };
19+
20+
function recorded(historyDir: string): Recorded | null {
1921
try {
20-
return JSON.parse(readFileSync(join(historyDir, "project.json"), "utf-8")).dir ?? null;
22+
return JSON.parse(readFileSync(join(historyDir, "project.json"), "utf-8"));
2123
} catch {
2224
return null;
2325
}
2426
}
2527

28+
function isCopy(was: Recorded, id: string, dir: string, ino: number): boolean {
29+
if (!was.dir || was.ino === ino) return false;
30+
return was.dir === dir ? was.ino !== undefined : existsSync(was.dir) && readId(was.dir) === id;
31+
}
32+
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. A folder carrying the id of
35+
* a recorded folder that still has it, or standing where another recorded folder was, is a copy: it gets its own id.
2936
*/
3037
export function projectHistoryId(projectDir: string, historyRoot: string): string {
3138
const dir = resolve(projectDir);
39+
const { ino } = 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+
const was = id && recorded(join(historyRoot, id));
42+
if (!id || (was && isCopy(was, id, dir, ino))) {
3543
id = randomUUID();
3644
mkdirSync(join(dir, ".hyperframes"), { recursive: true });
3745
writeFileSync(join(dir, ID_PATH), `${id}\n`);
3846
}
3947
mkdirSync(join(historyRoot, id), { recursive: true });
40-
writeFileSync(join(historyRoot, id, "project.json"), JSON.stringify({ dir }));
48+
writeFileSync(join(historyRoot, id, "project.json"), JSON.stringify({ dir, ino }));
4149
return id;
4250
}

0 commit comments

Comments
 (0)