mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(studio-server): history copies a changing file without stalling its sweep (#5627)
* fix(studio-server): history copies a changing file without stalling its sweep * fix(studio-server): a failed history copy leaves no partial file and keeps the mode * style(studio-server): name the history copy for what it does
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { constants, createReadStream, renameSync } from "node:fs";
|
||||
import { constants, createReadStream, createWriteStream, renameSync } from "node:fs";
|
||||
import { copyFile, mkdir, readdir, readFile, rename, rm, stat } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { atomicTempPath } from "@hyperframes/core/atomic-file";
|
||||
|
||||
/** File contents stored once by sha256, text and binary alike. */
|
||||
@@ -33,6 +34,15 @@ async function hashFile(path: string): Promise<string> {
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
async function cloneOrStream(from: string, to: string): Promise<void> {
|
||||
try {
|
||||
await copyFile(from, to, constants.COPYFILE_FICLONE_FORCE);
|
||||
} catch {
|
||||
const { mode } = await stat(from);
|
||||
await pipeline(createReadStream(from), createWriteStream(to, { mode }));
|
||||
}
|
||||
}
|
||||
|
||||
async function cloneOrCopy(from: string, to: string, beforeReplace?: () => void): Promise<void> {
|
||||
await mkdir(dirname(to), { recursive: true });
|
||||
const temp = atomicTempPath(to);
|
||||
@@ -61,7 +71,10 @@ export async function openBlobStore(dir: string): Promise<BlobStore> {
|
||||
const temp = join(dir, `incoming-${randomUUID()}`);
|
||||
// Recreated if removed while open, so a missing folder is never mistaken for a deleted project file.
|
||||
await mkdir(dir, { recursive: true });
|
||||
await copyFile(absPath, temp, constants.COPYFILE_FICLONE);
|
||||
await cloneOrStream(absPath, temp).catch(async (error) => {
|
||||
await rm(temp, { force: true });
|
||||
throw error;
|
||||
});
|
||||
const hash = await hashFile(temp);
|
||||
if (sizes.has(hash)) {
|
||||
await rm(temp, { force: true });
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import {
|
||||
appendFileSync,
|
||||
chmodSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
type ProjectHistory,
|
||||
} from "./projectHistory";
|
||||
import { START, type HistoryWho } from "./historyLog";
|
||||
import { openBlobStore } from "./blobStore";
|
||||
|
||||
// A test can hold a media copy, and run work after its blob is stored but before history records it.
|
||||
const mediaCopy = vi.hoisted(() => ({
|
||||
@@ -2586,3 +2588,64 @@ describe("idle windows", () => {
|
||||
await window.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a file shortened while the history copies it in", () => {
|
||||
/** Fails, instead of hanging the run, when `work` has not returned in 10 s. */
|
||||
const within = (work: Promise<unknown>, what: string) =>
|
||||
Promise.race([
|
||||
work,
|
||||
pause(10_000).then(() => {
|
||||
throw new Error(`${what} never returned`);
|
||||
}),
|
||||
]);
|
||||
|
||||
it("never stalls the sweep or the undo behind it", { timeout: 30_000 }, async () => {
|
||||
const { projectDir, historyRoot, history } = await project({ "index.html": "<h1>Hello</h1>" });
|
||||
const clip = join(projectDir, "clip.bin");
|
||||
writeFileSync(clip, Buffer.alloc(64 * 1024 * 1024, 1));
|
||||
let settled = false;
|
||||
const swept = history.flush().finally(() => (settled = true));
|
||||
// The clip's copy is under way once a temp copy passes 1 MB; a disk that clones may finish before it is seen.
|
||||
const copying = () =>
|
||||
readdirSync(historyRoot, { recursive: true }).some((name) => {
|
||||
const path = join(historyRoot, String(name));
|
||||
return (
|
||||
basename(path).startsWith("incoming-") &&
|
||||
(statSync(path, { throwIfNoEntry: false })?.size ?? 0) > 2 ** 20
|
||||
);
|
||||
});
|
||||
while (!settled && !copying()) await new Promise(setImmediate);
|
||||
writeFileSync(clip, "");
|
||||
await within(swept, "the sweep");
|
||||
await within(history.step("back", you), "the undo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the history's copy of a file", () => {
|
||||
it.skipIf(process.platform === "win32")("keeps the file's mode", async () => {
|
||||
const dir = tempDir("hf-history-blobs-");
|
||||
const store = await openBlobStore(join(dir, "blobs"));
|
||||
const script = join(dir, "build.sh");
|
||||
writeFileSync(script, "echo hi\n");
|
||||
chmodSync(script, 0o755);
|
||||
const hash = await store.put(script);
|
||||
const [stored] = readdirSync(join(dir, "blobs"), { recursive: true }).filter((name) =>
|
||||
String(name).endsWith(hash),
|
||||
);
|
||||
expect(statSync(join(dir, "blobs", String(stored))).mode & 0o777).toBe(0o755);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
||||
"leaves no partial copy in the store",
|
||||
async () => {
|
||||
const dir = tempDir("hf-history-blobs-");
|
||||
const store = await openBlobStore(join(dir, "blobs"));
|
||||
const locked = join(dir, "locked.html");
|
||||
writeFileSync(locked, "<h1>Hello</h1>");
|
||||
chmodSync(locked, 0o000);
|
||||
await expect(store.put(join(dir, "gone.html"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(store.put(locked)).rejects.toMatchObject({ code: "EACCES" });
|
||||
expect(readdirSync(join(dir, "blobs"))).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user