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:
Miguel Ángel
2026-10-15 03:16:32 +00:00
committed by GitHub
parent bb49858b8a
commit 916ec37755
2 changed files with 78 additions and 2 deletions
@@ -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([]);
},
);
});