import * as fs from "node:fs"; import * as fsAsync from "node:fs/promises"; import { hasFsCode, isEexist, isEnoent, logger, toError } from "@oh-my-pi/pi-utils"; const RENAME_RETRY_BUDGET_MS = 2_000; const RENAME_RETRY_INITIAL_MS = 5; const RENAME_RETRY_MAX_MS = 100; /** * Publish a staged sibling without moving the old destination aside, so readers * never observe a missing file while transient Windows replacement denials clear. */ export async function replaceFileWithRetry(tempPath: string, targetPath: string): Promise { const deadline = Date.now() + RENAME_RETRY_BUDGET_MS; for (let failures = 0; ; failures++) { try { await fsAsync.rename(tempPath, targetPath); return; } catch (error) { if ( process.platform !== "win32" || (!hasFsCode(error, "EPERM") && !hasFsCode(error, "EACCES") && !isEexist(error)) ) { throw error; } const remaining = deadline - Date.now(); if (remaining <= 0) throw error; await Bun.sleep(Math.min(remaining, RENAME_RETRY_MAX_MS, RENAME_RETRY_INITIAL_MS * 2 ** failures)); } } } /** * Publish a staged sibling file atomically, preserving an existing destination * across Windows `EPERM`/`EEXIST` replacement failures. */ export async function replaceFileAtomically(tempPath: string, targetPath: string): Promise { try { await fs.promises.rename(tempPath, targetPath); return; } catch (error) { if (!hasFsCode(error, "EPERM") && !isEexist(error)) throw error; await replaceAfterWindowsRenameFailure(tempPath, targetPath, error); } } async function replaceAfterWindowsRenameFailure( tempPath: string, targetPath: string, renameError: unknown, ): Promise { const backupPath = `${targetPath}.${process.pid}.${crypto.randomUUID()}.bak`; try { await fs.promises.rename(targetPath, backupPath); } catch (error) { if (isEnoent(error)) { await fs.promises.rename(tempPath, targetPath); return; } throw renameError; } try { await fs.promises.rename(tempPath, targetPath); } catch (replaceError) { try { await fs.promises.rename(backupPath, targetPath); } catch (rollbackError) { throw new Error( `Failed to replace file after ${toError(renameError).message} (retry: ${ toError(replaceError).message }; rollback: ${toError(rollbackError).message})`, { cause: toError(renameError) }, ); } throw replaceError; } try { await fs.promises.rm(backupPath); } catch (error) { if (!isEnoent(error)) { logger.warn("Failed to remove atomic replacement backup", { path: targetPath, backupPath, error: toError(error).message, }); } } } /** * Write a file through a private temp sibling and publish it with a single * rename, so a reader never observes a half-written file and a failed write * leaves the destination untouched. * * The staged file is created with `mode` (0600 by default) and published with * that mode, so the destination stays readable only by its owner even when it * carries a secret. */ export async function writeFileAtomically(filePath: string, content: string, mode: number = 0o600): Promise { const tempPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`; let removeTemp = false; try { const handle = await fs.promises.open(tempPath, "wx", mode); removeTemp = true; try { await handle.writeFile(content, "utf8"); await handle.sync(); } finally { await handle.close(); } // The umask narrows the mode passed to open(); restore it explicitly. await fs.promises.chmod(tempPath, mode); await replaceFileAtomically(tempPath, filePath); removeTemp = false; } finally { if (removeTemp) { await fs.promises.rm(tempPath, { force: true }).catch(() => {}); } } } /** * Synchronous sibling of {@link writeFileAtomically} for callers that cannot * await, such as the boot-time config migration. */ export function writeFileAtomicallySync(filePath: string, content: string, mode: number = 0o600): void { const tempPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`; let fd: number | undefined; try { fd = fs.openSync(tempPath, "wx", mode); fs.writeFileSync(fd, content, "utf8"); fs.fsyncSync(fd); fs.closeSync(fd); fd = undefined; // The umask narrows the mode passed to open(); restore it explicitly. fs.chmodSync(tempPath, mode); fs.renameSync(tempPath, filePath); } catch (error) { if (fd !== undefined) { try { fs.closeSync(fd); } catch { // The descriptor is already gone. } } fs.rmSync(tempPath, { force: true }); throw error; } } /** * Move a live file across devices without exposing a partial destination. * The source remains authoritative while the copy is staged. Publication and * source removal are synchronous so in-process writers cannot land between them. */ export async function moveFileAcrossDevices(source: string, destination: string): Promise { const staging = `${destination}.${process.pid}.${crypto.randomUUID()}.move`; try { for (;;) { const before = fs.statSync(source, { bigint: true }); await fs.promises.copyFile(source, staging); const after = fs.statSync(source, { bigint: true }); if (before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs) continue; // Flush the completed copy before making it discoverable. Neither the // temporary copy nor an existing destination is ever a live write target. const fd = fs.openSync(staging, "r+"); try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } fs.linkSync(staging, destination); try { fs.unlinkSync(source); } catch (error) { fs.unlinkSync(destination); throw error; } return; } } finally { await fs.promises.unlink(staging).catch(error => { if (!isEnoent(error)) logger.warn("Failed to remove staged move copy", { staging, error: toError(error).message }); }); } } /** * Move a finished file across devices, replacing a same-named destination. The copy is staged beside the destination * and published in one rename, so a failure before publication leaves the destination as it was. Once published, a * source that cannot be removed is logged rather than failing the move. */ export async function replaceFileAcrossDevices(source: string, destination: string): Promise { const staging = `${destination}.${process.pid}.${crypto.randomUUID()}.move`; try { await fs.promises.copyFile(source, staging); const fd = fs.openSync(staging, "r+"); try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } await replaceFileAtomically(staging, destination); } catch (error) { await fs.promises.rm(staging, { force: true }); throw error; } await fs.promises.unlink(source).catch(error => { logger.warn("Failed to remove moved file's source", { source, error: toError(error).message }); }); }