Lumi/src/services/safe-files.js
2026-07-17 22:10:00 +02:00

184 lines
6.2 KiB
JavaScript

const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const { threadId } = require("worker_threads");
const TRANSIENT_CODES = new Set(["EACCES", "EBUSY", "EEXIST", "ENOTEMPTY", "EPERM"]);
const activeWrites = new Set();
const diagnostics = [];
const sleepArray = new Int32Array(new SharedArrayBuffer(4));
function writeJsonAtomicSync(target, value, options = {}) {
return writeTextAtomicSync(target, `${JSON.stringify(value, null, options.indent ?? 2)}\n`, options);
}
function writeTextAtomicSync(target, content, options = {}) {
if (!String(target || "").trim()) throw new Error("A target file is required.");
const file = path.resolve(String(target));
if (activeWrites.has(file)) {
const error = new Error(`A write to ${file} is already in progress.`);
error.code = "EWRITELOCKED";
recordDiagnostic(file, "write_locked", error, 0, "error", options);
throw error;
}
activeWrites.add(file);
fs.mkdirSync(path.dirname(file), { recursive: true });
cleanupStaleTempFilesSync(file, options);
const temporary = temporaryPath(file);
let descriptor = null;
try {
descriptor = fs.openSync(temporary, "wx", options.mode ?? 0o600);
fs.writeFileSync(descriptor, content, options.encoding || "utf8");
try { fs.fsyncSync(descriptor); } catch (error) {
recordDiagnostic(file, "fsync_unavailable", error, 0, "warning", options);
}
fs.closeSync(descriptor);
descriptor = null;
replaceFileSync(temporary, file, options);
if (options.mode != null) {
try { fs.chmodSync(file, options.mode); } catch {}
}
syncParentDirectory(file, options);
return file;
} catch (error) {
recordDiagnostic(file, "write_failed", error, 0, "error", options);
throw error;
} finally {
if (descriptor != null) {
try { fs.closeSync(descriptor); } catch {}
}
try { fs.rmSync(temporary, { force: true }); } catch {}
activeWrites.delete(file);
}
}
function replaceFileSync(temporary, target, options = {}) {
try {
retrySync(() => fs.renameSync(temporary, target), target, "rename", options);
return;
} catch (renameError) {
if (!fs.existsSync(target) || !TRANSIENT_CODES.has(renameError.code)) throw renameError;
}
const backup = `${target}.${process.pid}.${threadId}.${crypto.randomBytes(5).toString("hex")}.bak`;
let movedOriginal = false;
try {
retrySync(() => fs.renameSync(target, backup), target, "backup_existing", options);
movedOriginal = true;
retrySync(() => fs.renameSync(temporary, target), target, "install_replacement", options);
fs.rmSync(backup, { force: true });
recordDiagnostic(target, "backup_replace_succeeded", null, 0, "warning", options);
} catch (error) {
if (movedOriginal && fs.existsSync(backup) && !fs.existsSync(target)) {
try {
retrySync(() => fs.renameSync(backup, target), target, "restore_original", options);
} catch (restoreError) {
recordDiagnostic(target, "restore_failed", restoreError, 0, "error", options);
error.message = `${error.message} The original file remains at ${backup}.`;
}
}
throw error;
} finally {
if (fs.existsSync(backup) && fs.existsSync(target)) {
try { fs.rmSync(backup, { force: true }); } catch {}
}
}
}
function retrySync(operation, target, operationName, options = {}) {
const attempts = Math.max(1, Number(options.attempts) || 8);
const delayMs = Math.max(1, Number(options.delayMs) || 15);
let lastError = null;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return operation();
} catch (error) {
lastError = error;
if (!TRANSIENT_CODES.has(error.code) || attempt === attempts) throw error;
recordDiagnostic(target, operationName, error, attempt, "warning", options);
Atomics.wait(sleepArray, 0, 0, Math.min(250, delayMs * attempt));
}
}
throw lastError;
}
function runFileOperationWithRetries(operation, options = {}) {
if (typeof operation !== "function") throw new Error("A file operation function is required.");
return retrySync(
operation,
options.target || "file operation",
options.operation || "file_operation",
options
);
}
function cleanupStaleTempFilesSync(target, options = {}) {
const directory = path.dirname(target);
const basename = path.basename(target);
const maxAgeMs = Math.max(1000, Number(options.staleTempAgeMs) || 60 * 60 * 1000);
const now = Date.now();
let names = [];
try { names = fs.readdirSync(directory); } catch { return; }
for (const name of names) {
if (name !== `${basename}.tmp` && !(name.startsWith(`${basename}.`) && name.endsWith(".tmp"))) continue;
const candidate = path.join(directory, name);
try {
if (now - fs.statSync(candidate).mtimeMs < maxAgeMs) continue;
fs.rmSync(candidate, { force: true });
recordDiagnostic(target, "stale_temp_removed", null, 0, "warning", options);
} catch {}
}
}
function temporaryPath(target) {
return `${target}.${process.pid}.${threadId}.${crypto.randomBytes(7).toString("hex")}.tmp`;
}
function syncParentDirectory(target, options) {
let descriptor = null;
try {
descriptor = fs.openSync(path.dirname(target), "r");
fs.fsyncSync(descriptor);
} catch (error) {
if (process.platform !== "win32") {
recordDiagnostic(target, "directory_fsync_unavailable", error, 0, "warning", options);
}
} finally {
if (descriptor != null) try { fs.closeSync(descriptor); } catch {}
}
}
function recordDiagnostic(target, operation, error, attempt, level, options = {}) {
const entry = {
timestamp: new Date().toISOString(),
level,
operation,
target,
attempt,
code: error?.code || null,
message: error?.message || null
};
diagnostics.push(entry);
if (diagnostics.length > 100) diagnostics.splice(0, diagnostics.length - 100);
if (typeof options.onDiagnostic === "function") options.onDiagnostic(entry);
}
function getSafeFileDiagnostics() {
return diagnostics.map((entry) => ({ ...entry }));
}
function clearSafeFileDiagnostics() {
diagnostics.length = 0;
}
module.exports = {
TRANSIENT_CODES,
writeJsonAtomicSync,
writeTextAtomicSync,
cleanupStaleTempFilesSync,
runFileOperationWithRetries,
getSafeFileDiagnostics,
clearSafeFileDiagnostics
};