Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,7 @@
"packages/engine/src/services/extractionCache.test.ts",
"packages/studio-server/src/routes/preview.test.ts",
"packages/studio-server/src/routes/projects.test.ts",
"packages/studio-server/src/helpers/applyFileMutations.test.ts",
"packages/studio-server/src/helpers/backupJournal.test.ts",
"packages/studio-server/src/helpers/finiteMutation.test.ts",
"packages/studio-server/src/helpers/hfIdPersist.test.ts",
Expand Down
75 changes: 74 additions & 1 deletion packages/studio-server/src/helpers/applyFileMutations.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, it } from "vitest";
Expand Down Expand Up @@ -171,4 +179,69 @@ describe("applyFileMutations", () => {
rmSync(projectDir, { recursive: true, force: true });
}
});

it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"reports the write error, not a failed rollback, when a later file was never written",
() => {
resetFileWriteReceipts();
const projectDir = mkdtempSync(join(tmpdir(), "hf-mutation-readonly-"));
const first = join(projectDir, "first.html");
const lockedDir = join(projectDir, "locked");
const second = join(lockedDir, "second.html");
try {
mkdirSync(lockedDir);
writeFileSync(first, "first-before", "utf8");
writeFileSync(second, "second-before", "utf8");
chmodSync(lockedDir, 0o555);
let thrown: unknown;
try {
applyFileMutations(projectDir, [
{ sourceFile: "first.html", absPath: first, after: "first-after" },
{ sourceFile: "locked/second.html", absPath: second, after: "second-after" },
]);
} catch (error) {
thrown = error;
}
expect(thrown).not.toBeInstanceOf(AggregateError);
expect(thrown).toMatchObject({ code: "EACCES" });
expect(readFileSync(first, "utf8")).toBe("first-before");
expect(readFileSync(second, "utf8")).toBe("second-before");
expect(identifyFileWrite(first, fileContentVersion("first-after"))).toBeNull();
} finally {
if (existsSync(lockedDir)) chmodSync(lockedDir, 0o755);
resetFileWriteReceipts();
rmSync(projectDir, { recursive: true, force: true });
}
},
);

it("still reports an incomplete rollback when a written file cannot be restored", () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-mutation-restore-"));
try {
for (const name of ["first", "second"])
writeFileSync(join(projectDir, `${name}.html`), `${name}-before`, "utf8");
let writes = 0;
expect(() =>
applyFileMutations(
projectDir,
["first", "second"].map((name) => ({
sourceFile: `${name}.html`,
absPath: join(projectDir, `${name}.html`),
after: `${name}-after`,
})),
undefined,
(path, content, encoding) => {
writes += 1;
if (writes === 2) throw new Error("second write failed");
if (writes === 3) throw new Error("first restore failed");
writeFileSync(path, content, encoding);
},
),
).toThrow("File mutation failed and rollback did not complete");
expect(readFileSync(join(projectDir, "first.html"), "utf8")).toBe("first-after");
} finally {
resetFileWriteReceipts();
rmSync(projectDir, { recursive: true, force: true });
}
});
});
42 changes: 26 additions & 16 deletions packages/studio-server/src/helpers/applyFileMutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,15 @@ export function applyFileMutations(
projectDir: string,
mutations: readonly FileMutationInput[],
requestToken?: string,
writeFile: (path: string, content: string, encoding: "utf-8") => void = (path, content) =>
writeFile: WriteFile = (path, content) =>
replaceFileAtomically(path, content, statSync(path).mode),
): AppliedFileMutation[] {
const prepared = mutations.map((mutation) => ({
...mutation,
before: mutation.before ?? readFileSync(mutation.absPath, "utf-8"),
}));
const results: AppliedFileMutation[] = [];
const attempted: Array<
PreparedMutation & { version: string; writeToken: string; written: boolean }
> = [];
const attempted: AttemptedMutation[] = [];
for (const mutation of prepared)
assertExpectedVersion(mutation, readFileSync(mutation.absPath, "utf-8"));
try {
Expand All @@ -50,16 +48,7 @@ export function applyFileMutations(
}
return results;
} catch (error) {
const rollbackErrors: unknown[] = [];
for (const mutation of attempted.reverse()) {
try {
if (!mutation.written || readFileSync(mutation.absPath, "utf-8") === mutation.after)
writeFile(mutation.absPath, mutation.before, "utf-8");
clearFileWriteReceipt(mutation.absPath, mutation.version, mutation.writeToken);
} catch (rollbackError) {
rollbackErrors.push(rollbackError);
}
}
const rollbackErrors = rollBack(attempted, writeFile);
if (rollbackErrors.length > 0) {
throw new AggregateError(
[error, ...rollbackErrors],
Expand All @@ -71,13 +60,34 @@ export function applyFileMutations(
}

type PreparedMutation = FileMutationInput & { before: string };
type WriteFile = (path: string, content: string, encoding: "utf-8") => void;
type AttemptedMutation = PreparedMutation & {
version: string;
writeToken: string;
written: boolean;
};

function rollBack(attempted: AttemptedMutation[], writeFile: WriteFile): unknown[] {
const errors: unknown[] = [];
for (const mutation of attempted.reverse()) {
try {
const current = readFileSync(mutation.absPath, "utf-8");
if (mutation.written ? current === mutation.after : current !== mutation.before)
writeFile(mutation.absPath, mutation.before, "utf-8");
clearFileWriteReceipt(mutation.absPath, mutation.version, mutation.writeToken);
} catch (rollbackError) {
errors.push(rollbackError);
}
}
return errors;
}

function applyOneMutation(
projectDir: string,
mutation: PreparedMutation,
requestToken: string | undefined,
writeFile: (path: string, content: string, encoding: "utf-8") => void,
attempted: Array<PreparedMutation & { version: string; writeToken: string; written: boolean }>,
writeFile: WriteFile,
attempted: AttemptedMutation[],
): AppliedFileMutation {
const current = readFileSync(mutation.absPath, "utf-8");
assertExpectedVersion(mutation, current);
Expand Down
Loading