mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 05:13:47 +00:00
fix(studio-server): one process at a time keeps a project's history open
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user