From 3eef1f1f1b7d2b493ac46a8c3f8f207b3a907844 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 24 May 2026 13:08:46 +0100 Subject: [PATCH] Retry transient Windows state-file replaces --- ...le-file-messagepack-backing-store.d.ts.map | 2 +- dist/single-file-messagepack-backing-store.js | 49 ++++++++++++++- src/single-file-messagepack-backing-store.ts | 59 ++++++++++++++++++- 3 files changed, 106 insertions(+), 4 deletions(-) diff --git a/dist/single-file-messagepack-backing-store.d.ts.map b/dist/single-file-messagepack-backing-store.d.ts.map index 172b69a..7d2c93f 100644 --- a/dist/single-file-messagepack-backing-store.d.ts.map +++ b/dist/single-file-messagepack-backing-store.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"single-file-messagepack-backing-store.d.ts","sourceRoot":"","sources":["../src/single-file-messagepack-backing-store.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAmBpF,qBAAa,iCAAkC,YAAW,iBAAiB;;IACzE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;gBAId,QAAQ,EAAE,MAAM;IAItB,OAAO,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC;IAiCvC,IAAI,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAW7C,MAAM,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAU/C,OAAO,CAAC,OAAO,EAAE,iBAAiB,EAAE,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;CAiDzF"} \ No newline at end of file +{"version":3,"file":"single-file-messagepack-backing-store.d.ts","sourceRoot":"","sources":["../src/single-file-messagepack-backing-store.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAoBpF,qBAAa,iCAAkC,YAAW,iBAAiB;;IACzE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;gBAId,QAAQ,EAAE,MAAM;IAItB,OAAO,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC;IAiCvC,IAAI,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAW7C,MAAM,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAU/C,OAAO,CAAC,OAAO,EAAE,iBAAiB,EAAE,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;CAiDzF"} \ No newline at end of file diff --git a/dist/single-file-messagepack-backing-store.js b/dist/single-file-messagepack-backing-store.js index 38023b4..7ef66b7 100644 --- a/dist/single-file-messagepack-backing-store.js +++ b/dist/single-file-messagepack-backing-store.js @@ -18,6 +18,7 @@ const legacyEnvelopeArraySchema = zod_1.z.array(zod_1.z.object({ payload: zod_1.z.unknown(), storedAt: zod_1.z.string().min(1), })); +const transientReplaceErrorCodes = new Set(["EBUSY", "EPERM", "EACCES"]); class SingleFileMessagePackBackingStore { filePath; #writeQueue = Promise.resolve(); @@ -101,7 +102,7 @@ class SingleFileMessagePackBackingStore { .slice(2)}`; try { await (0, promises_1.writeFile)(tempPath, (0, msgpack_1.encode)(entries)); - await (0, promises_1.rename)(tempPath, this.filePath); + await replaceFileWithRetry(tempPath, this.filePath); } catch (error) { await (0, promises_1.rm)(tempPath, { force: true }).catch(() => undefined); @@ -110,6 +111,49 @@ class SingleFileMessagePackBackingStore { } } exports.SingleFileMessagePackBackingStore = SingleFileMessagePackBackingStore; +async function replaceFileWithRetry(sourcePath, targetPath) { + const delays = [25, 75, 150, 300, 600]; + let lastError; + for (let attempt = 0; attempt <= delays.length; attempt += 1) { + try { + await (0, promises_1.rename)(sourcePath, targetPath); + return; + } + catch (error) { + lastError = error; + if (!isTransientReplaceError(error) || attempt === delays.length) { + break; + } + await delay(delays[attempt] ?? 0); + } + } + if (isTransientReplaceError(lastError)) { + try { + await (0, promises_1.copyFile)(sourcePath, targetPath); + await (0, promises_1.rm)(sourcePath, { force: true }); + return; + } + catch (copyError) { + throw annotateReplaceError(copyError, sourcePath, targetPath); + } + } + throw annotateReplaceError(lastError, sourcePath, targetPath); +} +function isTransientReplaceError(error) { + return isErrnoException(error) && + typeof error.code === "string" && + transientReplaceErrorCodes.has(error.code); +} +function annotateReplaceError(error, sourcePath, targetPath) { + if (error instanceof Error) { + error.message = `CultCache failed to replace ${targetPath} from ${sourcePath}: ${error.message}`; + return error; + } + return new Error(`CultCache failed to replace ${targetPath} from ${sourcePath}: ${String(error)}`); +} +function delay(milliseconds) { + return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)); +} function normalizePayload(payload) { if (payload instanceof Uint8Array) { return payload; @@ -128,3 +172,6 @@ function normalizePayload(payload) { function isObject(value) { return typeof value === "object" && value !== null; } +function isErrnoException(error) { + return typeof error === "object" && error !== null && "code" in error; +} diff --git a/src/single-file-messagepack-backing-store.ts b/src/single-file-messagepack-backing-store.ts index eed4229..2a05af0 100644 --- a/src/single-file-messagepack-backing-store.ts +++ b/src/single-file-messagepack-backing-store.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { decode, encode } from "@msgpack/msgpack"; @@ -22,6 +22,7 @@ const legacyEnvelopeArraySchema = z.array( storedAt: z.string().min(1), }), ); +const transientReplaceErrorCodes = new Set(["EBUSY", "EPERM", "EACCES"]); export class SingleFileMessagePackBackingStore implements CacheBackingStore { readonly filePath: string; @@ -129,7 +130,7 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { try { await writeFile(tempPath, encode(entries)); - await rename(tempPath, this.filePath); + await replaceFileWithRetry(tempPath, this.filePath); } catch (error) { await rm(tempPath, { force: true }).catch(() => undefined); throw error; @@ -137,6 +138,56 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { } } +async function replaceFileWithRetry(sourcePath: string, targetPath: string): Promise { + const delays = [25, 75, 150, 300, 600]; + let lastError: unknown; + + for (let attempt = 0; attempt <= delays.length; attempt += 1) { + try { + await rename(sourcePath, targetPath); + return; + } catch (error) { + lastError = error; + if (!isTransientReplaceError(error) || attempt === delays.length) { + break; + } + + await delay(delays[attempt] ?? 0); + } + } + + if (isTransientReplaceError(lastError)) { + try { + await copyFile(sourcePath, targetPath); + await rm(sourcePath, { force: true }); + return; + } catch (copyError) { + throw annotateReplaceError(copyError, sourcePath, targetPath); + } + } + + throw annotateReplaceError(lastError, sourcePath, targetPath); +} + +function isTransientReplaceError(error: unknown): boolean { + return isErrnoException(error) && + typeof error.code === "string" && + transientReplaceErrorCodes.has(error.code); +} + +function annotateReplaceError(error: unknown, sourcePath: string, targetPath: string): Error { + if (error instanceof Error) { + error.message = `CultCache failed to replace ${targetPath} from ${sourcePath}: ${error.message}`; + return error; + } + + return new Error(`CultCache failed to replace ${targetPath} from ${sourcePath}: ${String(error)}`); +} + +function delay(milliseconds: number): Promise { + return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)); +} + function normalizePayload(payload: unknown): Uint8Array { if (payload instanceof Uint8Array) { return payload; @@ -161,3 +212,7 @@ function normalizePayload(payload: unknown): Uint8Array { function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null; } + +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return typeof error === "object" && error !== null && "code" in error; +}