184 lines
6.2 KiB
JavaScript
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
|
|
};
|