Files
2026-07-13 13:08:41 +08:00

233 lines
6.2 KiB
TypeScript

import crypto from "crypto";
import fs from "fs";
import path from "path";
type ConfigSnapshot<T extends object> = {
config: T;
primaryValid: boolean;
};
const LOCK_TIMEOUT_MS = 5000;
const LOCK_STALE_MS = 30000;
const RETRY_DELAY_MS = 50;
const MAX_IO_ATTEMPTS = 6;
function sleepSync(ms: number): void {
const end = Date.now() + ms;
while (Date.now() < end) {
// Keep lock acquisition synchronous without requiring newer lib typings.
}
}
function isRetryableFsError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
return code === "EPERM" || code === "EBUSY" || code === "EACCES";
}
function retrySync<T>(label: string, operation: () => T): T {
let lastError: unknown;
for (let attempt = 0; attempt < MAX_IO_ATTEMPTS; attempt += 1) {
try {
return operation();
} catch (error) {
lastError = error;
if (!isRetryableFsError(error) || attempt === MAX_IO_ATTEMPTS - 1) {
break;
}
sleepSync(RETRY_DELAY_MS * (attempt + 1));
}
}
const message = lastError instanceof Error ? lastError.message : String(lastError);
throw new Error(`Failed to ${label}: ${message}`);
}
function backupPath(configPath: string): string {
return `${configPath}.bak`;
}
function lockPath(configPath: string): string {
return `${configPath}.lock`;
}
function ensureParentDirectory(configPath: string): void {
retrySync("create user config directory", () => {
fs.mkdirSync(path.dirname(configPath), { recursive: true });
});
}
function readJsonIfValid<T extends object>(filePath: string): T | undefined {
try {
const content = retrySync(`read ${path.basename(filePath)}`, () =>
fs.readFileSync(filePath, "utf8")
);
const trimmed = content.trim();
if (!trimmed) {
return {} as T;
}
const parsed = JSON.parse(trimmed);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as T)
: undefined;
} catch {
return undefined;
}
}
function readSnapshot<T extends object>(configPath: string): ConfigSnapshot<T> {
const primary = readJsonIfValid<T>(configPath);
if (primary) {
return { config: primary, primaryValid: true };
}
const backup = readJsonIfValid<T>(backupPath(configPath));
return { config: backup ?? ({} as T), primaryValid: false };
}
function removeStaleLock(lockFilePath: string): void {
try {
const stat = fs.statSync(lockFilePath);
if (Date.now() - stat.mtimeMs >= LOCK_STALE_MS) {
retrySync("remove stale user config lock", () => {
fs.unlinkSync(lockFilePath);
});
}
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (code !== "ENOENT") {
throw error;
}
}
}
function acquireLock(configPath: string): () => void {
ensureParentDirectory(configPath);
const lockFilePath = lockPath(configPath);
const startedAt = Date.now();
while (true) {
try {
const fd = fs.openSync(lockFilePath, "wx");
try {
fs.writeSync(
fd,
JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })
);
} finally {
fs.closeSync(fd);
}
return () => {
try {
retrySync("release user config lock", () => {
fs.unlinkSync(lockFilePath);
});
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (code !== "ENOENT") {
console.warn("[Presenton] Failed to release user config lock", error);
}
}
};
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (code !== "EEXIST" && !isRetryableFsError(error)) {
throw error;
}
removeStaleLock(lockFilePath);
if (Date.now() - startedAt >= LOCK_TIMEOUT_MS) {
throw new Error(`Timed out waiting for user config lock: ${lockFilePath}`);
}
sleepSync(RETRY_DELAY_MS);
}
}
}
function writeFileDurably(filePath: string, content: string): void {
const fd = retrySync(`open ${path.basename(filePath)} for writing`, () =>
fs.openSync(filePath, "w")
);
try {
retrySync(`write ${path.basename(filePath)}`, () => {
fs.writeFileSync(fd, content, "utf8");
});
retrySync(`sync ${path.basename(filePath)}`, () => {
fs.fsyncSync(fd);
});
} finally {
fs.closeSync(fd);
}
}
function copyBackupIfPossible(configPath: string, primaryValid: boolean): void {
const configBackupPath = backupPath(configPath);
try {
if (primaryValid && fs.existsSync(configPath)) {
retrySync("write user config backup", () => {
fs.copyFileSync(configPath, configBackupPath);
});
} else if (!fs.existsSync(configBackupPath) && fs.existsSync(configPath)) {
retrySync("initialize user config backup", () => {
fs.copyFileSync(configPath, configBackupPath);
});
}
} catch (error) {
console.warn("[Presenton] Failed to update user config backup", error);
}
}
function writeAtomicJson<T extends object>(
configPath: string,
config: T,
primaryValid: boolean
): void {
ensureParentDirectory(configPath);
copyBackupIfPossible(configPath, primaryValid);
const tempPath = `${configPath}.${process.pid}.${Date.now()}.${crypto
.randomBytes(6)
.toString("hex")}.tmp`;
writeFileDurably(tempPath, JSON.stringify(config));
try {
retrySync("replace user config", () => {
fs.renameSync(tempPath, configPath);
});
copyBackupIfPossible(configPath, false);
} catch (error) {
try {
fs.unlinkSync(tempPath);
} catch {
/* Best-effort cleanup. */
}
throw error;
}
}
export function readUserConfigFile<T extends object>(configPath: string): T {
try {
ensureParentDirectory(configPath);
return readSnapshot<T>(configPath).config;
} catch {
return {} as T;
}
}
export function updateUserConfigFile<T extends object>(
configPath: string,
update: (existingConfig: T) => T
): T {
const releaseLock = acquireLock(configPath);
try {
const snapshot = readSnapshot<T>(configPath);
const nextConfig = update({ ...snapshot.config });
writeAtomicJson(configPath, nextConfig, snapshot.primaryValid);
return nextConfig;
} finally {
releaseLock();
}
}