Backup directory failures must not crash the whole server

A fresh Windows install reported the server failing around backups.
createBackupService created the backups subdirectory once, synchronously,
at startup with no error handling - any failure there (permissions, a
path beyond Windows' MAX_PATH, a OneDrive/cloud-sync lock, antivirus)
crashed the entire Node process before it started listening, taking
down timing with it instead of just leaving backups unavailable.

Directory creation is now retried lazily and non-fatally on each backup
attempt, and a post-backup file rename retries briefly on EBUSY/EPERM
since a freshly written file is sometimes momentarily locked by
antivirus/cloud-sync scanning on Windows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-09 10:59:39 +02:00
parent 2846b4c0fe
commit c53f31bf35
6 changed files with 77 additions and 7 deletions

View File

@@ -2,7 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { chmodSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { tmpdir } from "node:os";
@@ -102,6 +102,24 @@ test("local restore validates and replaces a stopped test database with a safety
}
});
test("an unwritable backups directory never crashes server startup, and surfaces a clear error on backup instead", async () => {
const dataDir = mkdtempSync(join(tmpdir(), "racecore-backup-blocked-"));
const db = createTestDb(dataDir);
try {
chmodSync(dataDir, 0o500); // read/execute only: mkdir("backups") inside it fails with EACCES
let service;
assert.doesNotThrow(() => {
service = createBackupService({ db, dataDir, maxBackups: 3 });
}, "constructing the service must not throw even if the backups directory cannot be created");
assert.deepEqual(service.listBackups(), [], "listing degrades to empty instead of throwing");
await assert.rejects(() => service.createBackup({ reason: "manual" }), /backup directory/i);
} finally {
chmodSync(dataDir, 0o700);
db.close();
rmSync(dataDir, { recursive: true, force: true });
}
});
test("backup file segments are bounded and filesystem safe", () => {
assert.equal(safeSegment("A final / ../ unsafe"), "a-final-unsafe");
assert.equal(safeSegment(""), "backup");