Skip to content

Commit d59ad10

Browse files
committed
fix(snapshot): write the snapshot through a staging file renamed into place
A write that failed partway (disk full, an interrupted run) truncated a previously valid snapshot at the destination while the run reported failed, so the documented backup was lost. The document now goes to a sibling <path>.tmp and is renamed over the destination, which is atomic for a regular file; the staging file is removed when the write fails, and a cleanup that fails itself does not replace the write's error. Both destination forms write through the one helper. Tests fail the staging write and read the previous snapshot back intact, and fail the rename under the dir form to see the staging file gone and the other target written.
1 parent 661550c commit d59ad10

2 files changed

Lines changed: 92 additions & 4 deletions

File tree

‎src/flows/snapshot.ts‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* on disk while nothing about it is printed.
1111
*/
1212

13-
import { mkdirSync, writeFileSync } from "node:fs";
13+
import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
1414
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
1515
import { err, ok, type Result, ResultAsync } from "neverthrow";
1616
import type { RepoRef, Target } from "../discovery/targets.js";
@@ -228,8 +228,7 @@ async function snapshotTarget(ctx: {
228228
};
229229
}
230230
try {
231-
mkdirSync(dirname(path), { recursive: true });
232-
writeFileSync(
231+
writeReplacing(
233232
path,
234233
renderSnapshotYaml(result, { schemaUrl: SNAPSHOT_SCHEMA_URL, timestamp: ctx.timestamp }),
235234
);
@@ -253,6 +252,28 @@ async function snapshotTarget(ctx: {
253252
};
254253
}
255254

255+
/**
256+
* Write `text` to `path` through a sibling staging file renamed into place, so
257+
* a write that fails partway (disk full, an interrupted run) leaves the
258+
* previous snapshot at `path` intact instead of a truncated one; the rename is
259+
* atomic for a regular file on POSIX and Windows alike. The staging file is
260+
* removed when the write fails.
261+
*/
262+
function writeReplacing(path: string, text: string): void {
263+
mkdirSync(dirname(path), { recursive: true });
264+
const staging = `${path}.tmp`;
265+
try {
266+
writeFileSync(staging, text);
267+
renameSync(staging, path);
268+
} catch (error) {
269+
// The write's error is the one reported: a directory at the staging path fails both the write and this rm.
270+
try {
271+
rmSync(staging, { force: true });
272+
} catch {}
273+
throw error;
274+
}
275+
}
276+
256277
/**
257278
* A target's file under the snapshot directory, in the repos-dir layout so the
258279
* directory can later serve as one. SLUG_RE admits "." and "..", which GitHub

‎test/flows/snapshot.test.ts‎

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
1111
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1212
import { tmpdir } from "node:os";
13-
import { join } from "node:path";
13+
import { dirname, join } from "node:path";
1414
import { parse as parseYaml } from "yaml";
1515
import { parseRepoSlug } from "../../src/discovery/targets.js";
1616
import { SectionSelection } from "../../src/engine/section-selection.js";
@@ -241,6 +241,73 @@ function disjointRefusal(snapshotDir: string, reposDir: string): string {
241241
);
242242
}
243243

244+
describe("runSnapshot writes through a staging file", () => {
245+
const unwritable = (path: string, input: "snapshot-file" | "snapshot-dir", os: string) => ({
246+
level: "error" as const,
247+
line: `cannot write the snapshot to ${path}: Error: ${os}. Check that the "${input}" input names a writable path`,
248+
});
249+
250+
test("a staging write that fails leaves the previous snapshot intact and reports the write's error", async () => {
251+
const api = new MockApi(labelsRoute("o/r", [BUG]));
252+
const cfg = fileCfg();
253+
const staging = `${cfg.snapshotFile}.tmp`;
254+
mkdirSync(dirname(cfg.snapshotFile), { recursive: true });
255+
writeFileSync(cfg.snapshotFile, "labels: []\n");
256+
// A directory at the staging path fails the write before the rename; the run must not remove it either.
257+
mkdirSync(staging);
258+
const collected = collectingIo();
259+
expect(await run(api, cfg, collected.io)).toBe(1);
260+
expect(collected.outputs.result).toBe("failed");
261+
expect(collected.lines[0]).toEqual(
262+
unwritable(
263+
cfg.snapshotFile,
264+
"snapshot-file",
265+
`EISDIR: illegal operation on a directory, open '${staging}'`,
266+
),
267+
);
268+
expect(readFileSync(cfg.snapshotFile, "utf8")).toBe("labels: []\n");
269+
rmSync(staging, { recursive: true });
270+
expect(await run(api, cfg, collectingIo().io)).toBe(0);
271+
expect(parseYaml(readFileSync(cfg.snapshotFile, "utf8"))).toEqual(doc(BUG));
272+
expect(existsSync(staging)).toBe(false);
273+
});
274+
275+
test("a rename that fails removes the staging file, fails only that target, and leaves the destination as it was", async () => {
276+
const api = new MockApi({
277+
"GET /repos/o/a": { data: { private: false } },
278+
"GET /repos/o/b": { data: { private: false } },
279+
...labelsRoute("o/a", [BUG]),
280+
...labelsRoute("o/b", [DOCS]),
281+
});
282+
const cfg = dirCfg();
283+
const fileA = join(cfg.snapshotDir, "o", "a.yml");
284+
const fileB = join(cfg.snapshotDir, "o", "b.yml");
285+
// A directory holding a file at o/a's destination lets the staging write succeed and the rename fail.
286+
mkdirSync(fileA, { recursive: true });
287+
writeFileSync(join(fileA, "keep"), "authored\n");
288+
const collected = collectingIo();
289+
expect(await run(api, cfg, collected.io)).toBe(1);
290+
expect(collected.outputs).toEqual({
291+
"skipped-sections": "",
292+
result: "failed",
293+
"repos-result": JSON.stringify({
294+
"o/a": { result: "failed", source: "remote", skippedSections: [] },
295+
"o/b": { result: "snapshot", source: "remote", skippedSections: [] },
296+
}),
297+
});
298+
const { level, line } = unwritable(
299+
fileA,
300+
"snapshot-dir",
301+
`EISDIR: illegal operation on a directory, rename '${fileA}.tmp' -> '${fileA}'`,
302+
);
303+
expect(collected.lines[0]).toEqual({ level, line: `o/a: ${line}` });
304+
expect(existsSync(`${fileA}.tmp`)).toBe(false);
305+
expect(readFileSync(join(fileA, "keep"), "utf8")).toBe("authored\n");
306+
expect(parseYaml(readFileSync(fileB, "utf8"))).toEqual(doc(DOCS));
307+
expect(existsSync(`${fileB}.tmp`)).toBe(false);
308+
});
309+
});
310+
244311
describe("runSnapshot refuses a destination that would overwrite an authored file", () => {
245312
test.each([
246313
[

0 commit comments

Comments
 (0)