diff --git a/src/infra/json-files.ts b/src/infra/json-files.ts index 15830e9ad4e..5f4126cc13f 100644 --- a/src/infra/json-files.ts +++ b/src/infra/json-files.ts @@ -2,6 +2,10 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; +const FILE_LOCK_ERRORS = new Set(["EBUSY", "EACCES", "EPERM"]); +const MAX_RETRIES = 3; +const RETRY_DELAY_MS = 100; + export async function readJsonFile(filePath: string): Promise { try { const raw = await fs.readFile(filePath, "utf8"); @@ -36,24 +40,44 @@ export async function writeTextAtomic( if (typeof options?.ensureDirMode === "number") { mkdirOptions.mode = options.ensureDirMode; } - await fs.mkdir(path.dirname(filePath), mkdirOptions); - const tmp = `${filePath}.${randomUUID()}.tmp`; - try { - await fs.writeFile(tmp, payload, "utf8"); + + const attemptWrite = async (): Promise => { + await fs.mkdir(path.dirname(filePath), mkdirOptions); + const tmp = `${filePath}.${randomUUID()}.tmp`; try { - await fs.chmod(tmp, mode); - } catch { - // best-effort; ignore on platforms without chmod + await fs.writeFile(tmp, payload, "utf8"); + try { + await fs.chmod(tmp, mode); + } catch { + // best-effort; ignore on platforms without chmod + } + await fs.rename(tmp, filePath); + try { + await fs.chmod(filePath, mode); + } catch { + // best-effort; ignore on platforms without chmod + } + } finally { + await fs.rm(tmp, { force: true }).catch(() => undefined); } - await fs.rename(tmp, filePath); + }; + + let lastError: Error | undefined; + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { try { - await fs.chmod(filePath, mode); - } catch { - // best-effort; ignore on platforms without chmod + await attemptWrite(); + return; + } catch (err) { + lastError = err as Error; + const errWithCode = err as { code?: string }; + if (attempt < MAX_RETRIES - 1 && FILE_LOCK_ERRORS.has(errWithCode.code ?? "")) { + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS * (attempt + 1))); + continue; + } + throw err; } - } finally { - await fs.rm(tmp, { force: true }).catch(() => undefined); } + throw lastError; } export function createAsyncLock() {