Skip to content

Commit f11da30

Browse files
authored
fix(benchmark): create the benchmarks directory before writing into it (#117)
1 parent 64e3820 commit f11da30

4 files changed

Lines changed: 164 additions & 25 deletions

File tree

src/commands/benchmark.tsx

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,4 @@
1-
import {
2-
existsSync,
3-
mkdirSync,
4-
readFileSync,
5-
renameSync,
6-
rmSync,
7-
writeFileSync,
8-
} from 'node:fs';
1+
import {existsSync, mkdirSync, readFileSync, renameSync, rmSync} from 'node:fs';
92
import {dirname, join} from 'node:path';
103
import {Spinner, StatusMessage} from '@inkjs/ui';
114
import {Box, Text, useApp} from 'ink';
@@ -28,10 +21,11 @@ import {
2821
} from '../lib/benchmark-utils.js';
2922
import {
3023
configExists,
24+
ensureBenchmarksDir,
3125
findLatestGGUF,
32-
getBenchmarksDir,
3326
loadConfig,
3427
resolveContextMessage,
28+
writeFileAtomic,
3529
} from '../lib/config.js';
3630
import {
3731
callJudge,
@@ -299,7 +293,7 @@ export function BenchmarkCommand({options}: Props) {
299293
} catch {
300294
// Minimal config (e.g., external benchmark runner) — no context message needed
301295
}
302-
const benchmarksDir = getBenchmarksDir();
296+
const benchmarksDir = ensureBenchmarksDir();
303297

304298
if (options.model && options.base) {
305299
setError(
@@ -461,7 +455,7 @@ export function BenchmarkCommand({options}: Props) {
461455
match: 'startsWith',
462456
},
463457
];
464-
writeFileSync(datasetPath, JSON.stringify(tests, null, 2));
458+
writeFileAtomic(datasetPath, JSON.stringify(tests, null, 2));
465459
setError(
466460
`No benchmark dataset found. Created sample at ${datasetPath}`,
467461
);
@@ -841,13 +835,13 @@ export function BenchmarkCommand({options}: Props) {
841835
.toISOString()
842836
.replace(/[:.]/g, '-')}.json`;
843837
const resultPath = join(benchmarksDir, resultFilename);
844-
writeFileSync(resultPath, JSON.stringify(finalResult, null, 2));
838+
writeFileAtomic(resultPath, JSON.stringify(finalResult, null, 2));
845839

846840
// Save human-readable markdown report
847841
const reportFilename = resultFilename.replace('.json', '.md');
848842
const reportPath = join(benchmarksDir, reportFilename);
849843
const report = generateMarkdownReport(finalResult, contextMsg);
850-
writeFileSync(reportPath, report);
844+
writeFileAtomic(reportPath, report);
851845

852846
setResults(finalResult);
853847
setStatus('done');

src/commands/benchmark/compare.tsx

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
1-
import {
2-
existsSync,
3-
mkdirSync,
4-
realpathSync,
5-
statSync,
6-
writeFileSync,
7-
} from 'node:fs';
1+
import {realpathSync, statSync, writeFileSync} from 'node:fs';
82
import {join} from 'node:path';
93
import {StatusMessage} from '@inkjs/ui';
104
import {Box, Text, useApp} from 'ink';
@@ -25,8 +19,8 @@ import {
2519
generateComparisonMarkdown,
2620
} from '../../lib/benchmark-compare.js';
2721
import {
22+
ensureBenchmarksDir,
2823
findLatestBenchmark,
29-
getBenchmarksDir,
3024
listBenchmarks,
3125
loadBenchmark,
3226
resolveBenchmarkPath,
@@ -135,10 +129,7 @@ export function BenchmarkCompareCommand({fileA, fileB}: Props) {
135129
const result = compareBenchmarks(before, after);
136130

137131
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
138-
const benchmarksDir = getBenchmarksDir();
139-
if (!existsSync(benchmarksDir)) {
140-
mkdirSync(benchmarksDir, {recursive: true});
141-
}
132+
const benchmarksDir = ensureBenchmarksDir();
142133
const jsonPath = join(benchmarksDir, `compare-${timestamp}.json`);
143134
const markdownPath = join(benchmarksDir, `compare-${timestamp}.md`);
144135
writeFileSync(jsonPath, JSON.stringify(result, null, 2));

src/lib/config.spec.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import {
2+
existsSync,
23
mkdirSync,
4+
readdirSync,
5+
readFileSync,
36
rmSync,
47
utimesSync,
58
writeFileSync,
@@ -9,13 +12,15 @@ import test from "ava";
912
import { ConfigSchema, TrainingConfigSchema } from "../types/index.js";
1013
import {
1114
createDefaultConfig,
15+
ensureBenchmarksDir,
1216
findLatestGGUF,
1317
formatConfigIssues,
1418
listBenchmarks,
1519
resolveBenchmarkPath,
1620
findUnknownConfigKeys,
1721
loadConfig,
1822
resolveContextMessage,
23+
writeFileAtomic,
1924
} from "./config.js";
2025

2126
const TEST_DIR = join(process.cwd(), ".test-nanotune");
@@ -675,3 +680,118 @@ test.serial("loadConfig prints each unknown key once across loads", (t) => {
675680
rmSync(WARN_TEST_DIR, { recursive: true, force: true });
676681
}
677682
});
683+
684+
685+
// ── ensureBenchmarksDir ───────────────────────────────────────────────
686+
//
687+
// setupBenchTest leaves a project with no benchmarks directory — exactly the
688+
// state `git clone` produces, since .nanotune/.gitignore lists benchmarks/ and
689+
// only `nanotune init` ever creates it.
690+
691+
test.serial("ensureBenchmarksDir creates the directory when it is missing", (t) => {
692+
setupBenchTest();
693+
try {
694+
t.false(existsSync(BENCH_DIR));
695+
const dir = ensureBenchmarksDir();
696+
t.is(dir, BENCH_DIR);
697+
t.true(existsSync(dir));
698+
} finally {
699+
teardownBenchTest();
700+
}
701+
});
702+
703+
test.serial("ensureBenchmarksDir leaves an existing directory and its contents alone", (t) => {
704+
setupBenchTest();
705+
try {
706+
mkdirSync(BENCH_DIR, { recursive: true });
707+
writeFileSync(join(BENCH_DIR, "tests.json"), "[]");
708+
709+
t.is(ensureBenchmarksDir(), BENCH_DIR);
710+
t.is(readFileSync(join(BENCH_DIR, "tests.json"), "utf-8"), "[]");
711+
} finally {
712+
teardownBenchTest();
713+
}
714+
});
715+
716+
test.serial("results saved into a cloned project are discoverable afterwards", (t) => {
717+
setupBenchTest();
718+
try {
719+
// The tail of a real run: both reports land under a benchmarks directory
720+
// the clone never had, and `benchmark compare` still finds them there.
721+
const dir = ensureBenchmarksDir();
722+
const resultPath = join(dir, "benchmark-2026-01-01T00-00-00-000Z.json");
723+
writeFileAtomic(resultPath, JSON.stringify({ summary: { total: 2 } }));
724+
writeFileAtomic(resultPath.replace(".json", ".md"), "# Benchmark Report");
725+
726+
t.deepEqual(
727+
listBenchmarks().map((b) => b.filename),
728+
["benchmark-2026-01-01T00-00-00-000Z.json"],
729+
);
730+
t.is(resolveBenchmarkPath("2026-01-01T00-00-00-000Z"), resultPath);
731+
} finally {
732+
teardownBenchTest();
733+
}
734+
});
735+
736+
// ── writeFileAtomic ───────────────────────────────────────────────────
737+
738+
test.serial("writeFileAtomic writes the contents and leaves no temp file behind", (t) => {
739+
setupBenchTest();
740+
try {
741+
const dir = ensureBenchmarksDir();
742+
const path = join(dir, "report.md");
743+
writeFileAtomic(path, "# Benchmark Report");
744+
745+
t.is(readFileSync(path, "utf-8"), "# Benchmark Report");
746+
t.deepEqual(readdirSync(dir), ["report.md"]);
747+
} finally {
748+
teardownBenchTest();
749+
}
750+
});
751+
752+
test.serial("writeFileAtomic replaces an existing file", (t) => {
753+
setupBenchTest();
754+
try {
755+
const dir = ensureBenchmarksDir();
756+
const path = join(dir, "tests.json");
757+
writeFileAtomic(path, "[1]");
758+
writeFileAtomic(path, "[1,2]");
759+
760+
t.is(readFileSync(path, "utf-8"), "[1,2]");
761+
t.deepEqual(readdirSync(dir), ["tests.json"]);
762+
} finally {
763+
teardownBenchTest();
764+
}
765+
});
766+
767+
test.serial("writeFileAtomic cleans up its temp file when the rename fails", (t) => {
768+
setupBenchTest();
769+
try {
770+
const dir = ensureBenchmarksDir();
771+
// Renaming onto a non-empty directory fails, so the write never lands.
772+
// The point is that it leaves no half-written sibling behind either — a
773+
// stray temp is how a partial write gets mistaken for a finished run.
774+
const blocked = join(dir, "blocked");
775+
mkdirSync(blocked, { recursive: true });
776+
writeFileSync(join(blocked, "keep.txt"), "keep");
777+
778+
t.throws(() => writeFileAtomic(blocked, "should not land"));
779+
t.deepEqual(readdirSync(dir), ["blocked"]);
780+
t.is(readFileSync(join(blocked, "keep.txt"), "utf-8"), "keep");
781+
} finally {
782+
teardownBenchTest();
783+
}
784+
});
785+
786+
test.serial("writeFileAtomic surfaces a missing parent rather than inventing one", (t) => {
787+
setupBenchTest();
788+
try {
789+
// `--dataset` can point anywhere, so a typo'd path must still fail loudly
790+
// instead of quietly creating directories outside the project.
791+
const missing = join(BENCH_TEST_DIR, "nope", "tests.json");
792+
t.throws(() => writeFileAtomic(missing, "[]"), { code: "ENOENT" });
793+
t.false(existsSync(join(BENCH_TEST_DIR, "nope")));
794+
} finally {
795+
teardownBenchTest();
796+
}
797+
});

src/lib/config.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {
33
mkdirSync,
44
readdirSync,
55
readFileSync,
6+
renameSync,
7+
rmSync,
68
statSync,
79
writeFileSync,
810
} from 'node:fs';
@@ -42,6 +44,20 @@ export function getBenchmarksDir(): string {
4244
return join(getProjectDir(), 'benchmarks');
4345
}
4446

47+
/**
48+
* Return the project's benchmarks directory, creating it if it is missing.
49+
* `.nanotune/.gitignore` lists `benchmarks/`, so `initializeProjectDirs` during
50+
* `nanotune init` is the only thing that ever creates it — a project obtained
51+
* by cloning has no benchmarks directory at all, and every write under it dies
52+
* with a bare ENOENT. Use this rather than `getBenchmarksDir` anywhere you are
53+
* about to write.
54+
*/
55+
export function ensureBenchmarksDir(): string {
56+
const dir = getBenchmarksDir();
57+
mkdirSync(dir, {recursive: true});
58+
return dir;
59+
}
60+
4561
export function getChatsDir(): string {
4662
return join(getProjectDir(), 'chats');
4763
}
@@ -191,6 +207,24 @@ export function saveConfig(config: Config): void {
191207
writeFileSync(path, JSON.stringify(config, null, 2));
192208
}
193209

210+
/**
211+
* Write `contents` to `path` via a sibling temp file renamed into place.
212+
* rename(2) is atomic, so an interrupted or failed write leaves either the
213+
* previous file or the complete new one — never a truncated file that a later
214+
* read mistakes for a whole one. The temp carries the pid so concurrent runs
215+
* cannot scribble over each other's, and the `finally` clears it on the paths
216+
* where the rename never happened.
217+
*/
218+
export function writeFileAtomic(path: string, contents: string): void {
219+
const tmp = `${path}.tmp-${process.pid}`;
220+
try {
221+
writeFileSync(tmp, contents);
222+
renameSync(tmp, path);
223+
} finally {
224+
rmSync(tmp, {force: true});
225+
}
226+
}
227+
194228
const GITIGNORE_CONTENTS = `# Nanotune project artifacts
195229
adapters/
196230
models/

0 commit comments

Comments
 (0)