fix(usage-log): write via temp file and atomic rename to prevent corruption

appendRecord previously called fs.writeFile(token-usage.json, …) directly.
A process crash or SIGKILL during that write can leave the file truncated;
readJsonArray then throws (SyntaxError), and since attempt.ts swallows the
error with .catch(), that one interrupted write silently disables all future
token logging for the workspace until the file is manually repaired.

Fix: write the new content to a uniquely-named sibling temp file first, then
call fs.rename() to atomically replace the real file. rename(2) is atomic on
POSIX when src and dst share the same directory/filesystem, so readers always
see either the old complete file or the new complete file — never a partial
write. The temp file is unlinked on error to avoid leaving orphans.
This commit is contained in:
jiarung 2026-03-14 18:41:39 +00:00
parent 1a5489bf32
commit 8c162d0ba4

View File

@ -145,7 +145,18 @@ async function appendRecord(file: string, entry: TokenUsageRecord): Promise<void
await withFileLock(lockPath, async () => {
const records = await readJsonArray(file);
records.push(entry);
await fs.writeFile(file, JSON.stringify(records, null, 2));
// Write to a sibling temp file then atomically rename into place so that
// a crash or kill during the write never leaves token-usage.json truncated.
// rename(2) is atomic on POSIX when src and dst are on the same filesystem,
// which is guaranteed here because both paths share the same directory.
const tmp = `${file}.tmp.${randomBytes(4).toString("hex")}`;
try {
await fs.writeFile(tmp, JSON.stringify(records, null, 2));
await fs.rename(tmp, file);
} catch (err) {
await fs.unlink(tmp).catch(() => {});
throw err;
}
});
}