fix(studio-server): one process at a time keeps a project's history open

This commit is contained in:
Miguel Angel Simon Sierra
2026-09-24 21:18:02 -04:00
parent b8dd023a97
commit 41c2e2a126
6 changed files with 127 additions and 10 deletions
+14 -1
View File
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { fileContentVersion } from "@hyperframes/studio-server";
import { fileContentVersion, HistoryBusyError } from "@hyperframes/studio-server";
import { loadHyperframeRuntimeSource } from "@hyperframes/core";
import { loadRuntimeSource } from "./runtimeSource.js";
import { findFFmpeg, findFFprobe } from "../browser/ffmpeg.js";
@@ -151,6 +151,19 @@ 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);
};
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("shutdown returns within preview's exit watchdog while the history is still opening", async () => {
historyState.open = () => new Promise(() => {});
try {
+4 -5
View File
@@ -9,7 +9,6 @@ import { Hono, type Context } from "hono";
import { streamSSE } from "hono/streaming";
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
import { resolve, join, basename } from "node:path";
import { homedir } from "node:os";
import { readBundleFile } from "./readBundleFile.js";
import {
createProjectWatcher,
@@ -48,6 +47,8 @@ import {
type RenderJobState,
type BackgroundRemovalRender,
openProjectHistory,
DEFAULT_HISTORY_ROOT,
HistoryBusyError,
type ProjectHistory,
} from "@hyperframes/studio-server";
import { resolveAutoProxy } from "../utils/projectConfig.js";
@@ -67,9 +68,6 @@ import {
const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json";
/** Where `hyperframes preview` keeps project histories: outside every project, so no tidy-up takes one away. */
const DEFAULT_HISTORY_ROOT = join(homedir(), ".cache", "hyperframes", "history");
// Under preview.ts's 3s process-exit watchdog, so shutdown() always returns
// before that watchdog can fire and skip this file's browser cleanup.
const RENDER_SHUTDOWN_WAIT_MS = 2_000;
@@ -414,7 +412,7 @@ 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.
// 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({
@@ -422,6 +420,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
historyRoot: options.historyRoot ?? DEFAULT_HISTORY_ROOT,
}).catch((error: unknown) => {
console.warn(`[studio] Project history is off: ${String(error)}`);
if (error instanceof HistoryBusyError) history = undefined;
return null;
}));
watcher.addListener((changedPath) => {
@@ -0,0 +1,62 @@
import { randomUUID } from "node:crypto";
import { linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
export class HistoryBusyError extends Error {
constructor(readonly pid: number) {
super(`This project's history is open in another process (pid ${pid}).`);
this.name = "HistoryBusyError";
}
}
function alive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === "EPERM";
}
}
function ownerOf(file: string): number | null {
try {
return Number(readFileSync(file, "utf-8"));
} catch {
return null;
}
}
/**
* One process at a time keeps a project's history open (a second opener would fork the log). Waits up to `waitMs`
* for the owner to close, takes over a dead owner's lock. ponytail: two takeovers of one dead owner can both win.
*/
export async function takeHistoryOwnership(home: string, waitMs: number): Promise<() => void> {
const file = join(home, "owner.pid");
const deadline = Date.now() + waitMs;
mkdirSync(home, { recursive: true });
for (;;) {
// Written aside and linked in, so a reader never sees the file without its pid.
const draft = join(home, `owner-${randomUUID()}.tmp`);
writeFileSync(draft, String(process.pid));
try {
linkSync(draft, file);
let held = true;
return () => {
if (held) rmSync(file, { force: true });
held = false;
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
} finally {
rmSync(draft, { force: true });
}
const pid = ownerOf(file);
if (pid === null) continue;
if (!alive(pid)) {
rmSync(file, { force: true });
continue;
}
if (Date.now() >= deadline) throw new HistoryBusyError(pid);
await new Promise((settle) => setTimeout(settle, 50));
}
}
@@ -10,6 +10,7 @@ import {
rmSync,
writeFileSync,
} from "node:fs";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { dirname, join, relative } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -190,6 +191,26 @@ describe("openProjectHistory", () => {
expect(copied.list()).toEqual([]);
});
it("lets one process at a time hold a project's history, so a second opener cannot fork its log", async () => {
const { history, projectDir, historyRoot } = await project({ "index.html": "v1" });
await expect(openProjectHistory({ projectDir, historyRoot, ownerWaitMs: 0 })).rejects.toThrow(
`pid ${process.pid}`,
);
const waiting = open(projectDir, historyRoot, { ownerWaitMs: 5000 });
await history.close();
expect((await waiting).projectId).toBe(history.projectId);
});
it("takes over the lock of an owner that died without closing", async () => {
const { history, projectDir, historyRoot } = await project({ "index.html": "v1" });
await history.close();
const dead = spawnSync(process.execPath, ["-e", "process.stdout.write(String(process.pid))"]);
writeFileSync(join(historyRoot, history.projectId, "owner.pid"), dead.stdout);
expect((await open(projectDir, historyRoot, { ownerWaitMs: 0 })).projectId).toBe(
history.projectId,
);
});
it("rewrites a history folder removed while open, so a reopen still has the change", async () => {
const { history, write, projectDir, historyRoot } = await project({ "index.html": "v1" });
const earlier = await change(history, you, "Second", () => write("index.html", "v2"));
@@ -1,11 +1,13 @@
import { randomUUID } from "node:crypto";
import { rm } from "node:fs/promises";
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, relative, resolve, sep } from "node:path";
import { replaceFileAtomically } from "../helpers/atomicFile.js";
import { affectsProjectSignature, listProjectFiles } from "../helpers/projectSignature.js";
import { openBlobStore, type BlobStore } from "./blobStore.js";
import { projectHistoryId } from "./historyId.js";
import { takeHistoryOwnership } from "./ownerLock.js";
import {
START,
foldOldest,
@@ -36,8 +38,13 @@ export interface ProjectHistoryOptions {
budgetBytes?: number;
/** A sweep or commit that a watcher or timer started failed. */
onError?: (error: unknown) => void;
/** How long an open waits for another process to close the same history (default 5 s). */
ownerWaitMs?: number;
}
/** Where HyperFrames keeps project histories: outside every project, so no tidy-up takes one away. */
export const DEFAULT_HISTORY_ROOT = join(homedir(), ".cache", "hyperframes", "history");
export interface HistoryListItem extends HistoryEntry {
pinned: boolean;
undone: boolean;
@@ -604,8 +611,21 @@ class Engine {
/** 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> {
const projectId = projectHistoryId(options.projectDir, options.historyRoot);
const blobs = await openBlobStore(join(options.historyRoot, projectId, "blobs"));
const engine = new Engine(options, projectId, blobs);
await engine.queue(() => engine.start());
return engine.api();
const release = await takeHistoryOwnership(
join(options.historyRoot, projectId),
options.ownerWaitMs ?? 5000,
);
try {
const blobs = await openBlobStore(join(options.historyRoot, projectId, "blobs"));
const engine = new Engine(options, projectId, blobs);
await engine.queue(() => engine.start());
const api = engine.api();
return {
...api,
close: () => api.close().finally(release),
};
} catch (error) {
release();
throw error;
}
}
+2
View File
@@ -3,12 +3,14 @@ export { createProjectSignature, affectsProjectSignature } from "./helpers/proje
export { compositionsAffectedBy } from "./helpers/compositionInputs.js";
export {
openProjectHistory,
DEFAULT_HISTORY_ROOT,
type ProjectHistory,
type ProjectHistoryOptions,
type HistoryListItem,
type HistoryResult,
type HistoryWindow,
} from "./history/projectHistory.js";
export { HistoryBusyError } from "./history/ownerLock.js";
export {
START as HISTORY_START,
type HistoryEntry,