From 41c2e2a126a4ab9c8ca844f6129ac01db3f0e813 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 24 Sep 2026 17:14:16 -0400 Subject: [PATCH] fix(studio-server): one process at a time keeps a project's history open --- packages/cli/src/server/studioServer.test.ts | 15 ++++- packages/cli/src/server/studioServer.ts | 9 ++- .../studio-server/src/history/ownerLock.ts | 62 +++++++++++++++++++ .../src/history/projectHistory.test.ts | 21 +++++++ .../src/history/projectHistory.ts | 28 +++++++-- packages/studio-server/src/index.ts | 2 + 6 files changed, 127 insertions(+), 10 deletions(-) create mode 100644 packages/studio-server/src/history/ownerLock.ts diff --git a/packages/cli/src/server/studioServer.test.ts b/packages/cli/src/server/studioServer.test.ts index 8ee2c0781e..e6a7dd8de1 100644 --- a/packages/cli/src/server/studioServer.test.ts +++ b/packages/cli/src/server/studioServer.test.ts @@ -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 { diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index e05ee9f8a7..c95d081500 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -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 | 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) => { diff --git a/packages/studio-server/src/history/ownerLock.ts b/packages/studio-server/src/history/ownerLock.ts new file mode 100644 index 0000000000..8d0124a918 --- /dev/null +++ b/packages/studio-server/src/history/ownerLock.ts @@ -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)); + } +} diff --git a/packages/studio-server/src/history/projectHistory.test.ts b/packages/studio-server/src/history/projectHistory.test.ts index 9fa2d354af..9fe4f2d4a0 100644 --- a/packages/studio-server/src/history/projectHistory.test.ts +++ b/packages/studio-server/src/history/projectHistory.test.ts @@ -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")); diff --git a/packages/studio-server/src/history/projectHistory.ts b/packages/studio-server/src/history/projectHistory.ts index 3668ca6b1e..0d89577a8c 100644 --- a/packages/studio-server/src/history/projectHistory.ts +++ b/packages/studio-server/src/history/projectHistory.ts @@ -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 { 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; + } } diff --git a/packages/studio-server/src/index.ts b/packages/studio-server/src/index.ts index 670301c401..43627eee04 100644 --- a/packages/studio-server/src/index.ts +++ b/packages/studio-server/src/index.ts @@ -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,