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

@@ -1,6 +1,25 @@
const fs = require("fs");
const path = require("path");
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// A file that was just written is sometimes still briefly held open by antivirus/cloud-sync
// scanning on Windows, so a rename right after can fail with EBUSY/EPERM even though nothing
// in this process still has it open. A short retry clears that up without surfacing a false
// failure to the user.
async function renameWithRetry(source, destination, attempts = 5, delayMs = 200) {
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
fs.renameSync(source, destination);
return;
} catch (error) {
const retryable = error && (error.code === "EBUSY" || error.code === "EPERM");
if (!retryable || attempt === attempts) throw error;
await pause(delayMs);
}
}
}
function safeSegment(value, fallback = "backup") {
const normalized = String(value || "")
.trim()
@@ -14,7 +33,21 @@ function safeSegment(value, fallback = "backup") {
function createBackupService({ db, dataDir, maxBackups = 30, now = () => new Date() }) {
if (!db || !dataDir) throw new Error("Backup service requires db and dataDir");
const backupDir = path.join(dataDir, "backups");
fs.mkdirSync(backupDir, { recursive: true });
// Creating the backups directory can fail on a real install (no write permission,
// a path beyond Windows' MAX_PATH, a cloud-sync folder like OneDrive locking it,
// antivirus interference). That must never take down the whole server at startup -
// backups become unavailable, but timing and everything else keeps working.
function ensureBackupDir() {
try {
fs.mkdirSync(backupDir, { recursive: true });
return true;
} catch (error) {
console.error(`[backup] Could not create backup directory ${backupDir}: ${error.message}`);
return false;
}
}
ensureBackupDir();
function metadataPath(fileName) {
return path.join(backupDir, fileName.replace(/\.sqlite$/i, ".json"));
@@ -28,8 +61,16 @@ function createBackupService({ db, dataDir, maxBackups = 30, now = () => new Dat
}
}
function backupDirReady() {
try {
return fs.statSync(backupDir).isDirectory();
} catch {
return false;
}
}
function listBackups() {
if (!fs.existsSync(backupDir)) return [];
if (!backupDirReady()) return [];
return fs.readdirSync(backupDir)
.filter((fileName) => fileName.endsWith(".sqlite"))
.map((fileName) => {
@@ -56,6 +97,9 @@ function createBackupService({ db, dataDir, maxBackups = 30, now = () => new Dat
}
async function createBackupNow({ reason = "manual", sessionId = "", eventId = "" } = {}) {
if (!backupDirReady() && !ensureBackupDir()) {
throw new Error(`Backup directory is unavailable: ${backupDir}`);
}
const createdAt = now().toISOString();
const timestamp = createdAt.replace(/[:.]/g, "-");
const stem = `${timestamp}__${safeSegment(reason)}__${safeSegment(sessionId, "all")}`;
@@ -69,7 +113,7 @@ function createBackupService({ db, dataDir, maxBackups = 30, now = () => new Dat
const temporaryPath = `${finalPath}.tmp`;
try {
await db.backup(temporaryPath);
fs.renameSync(temporaryPath, finalPath);
await renameWithRetry(temporaryPath, finalPath);
const metadata = {
fileName,
createdAt,