mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 05:14:33 +00:00
fix(cron): retry rename on EBUSY and fall back to copyFile on Windows
Landed from contributor PR #16932 with additional changelog alignment and verification.
This commit is contained in:
@@ -92,3 +92,56 @@ describe("cron store", () => {
|
||||
await store.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveCronStore", () => {
|
||||
const dummyStore: CronStoreFile = { version: 1, jobs: [] };
|
||||
|
||||
it("persists and round-trips a store file", async () => {
|
||||
const { storePath, cleanup } = await makeStorePath();
|
||||
await saveCronStore(storePath, dummyStore);
|
||||
const loaded = await loadCronStore(storePath);
|
||||
expect(loaded).toEqual(dummyStore);
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("retries rename on EBUSY then succeeds", async () => {
|
||||
const { storePath, cleanup } = await makeStorePath();
|
||||
|
||||
const origRename = fs.rename.bind(fs);
|
||||
let ebusyCount = 0;
|
||||
const spy = vi.spyOn(fs, "rename").mockImplementation(async (src, dest) => {
|
||||
if (ebusyCount < 2) {
|
||||
ebusyCount++;
|
||||
const err = new Error("EBUSY") as NodeJS.ErrnoException;
|
||||
err.code = "EBUSY";
|
||||
throw err;
|
||||
}
|
||||
return origRename(src, dest);
|
||||
});
|
||||
|
||||
await saveCronStore(storePath, dummyStore);
|
||||
expect(ebusyCount).toBe(2);
|
||||
const loaded = await loadCronStore(storePath);
|
||||
expect(loaded).toEqual(dummyStore);
|
||||
|
||||
spy.mockRestore();
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("falls back to copyFile on EPERM (Windows)", async () => {
|
||||
const { storePath, cleanup } = await makeStorePath();
|
||||
|
||||
const spy = vi.spyOn(fs, "rename").mockImplementation(async () => {
|
||||
const err = new Error("EPERM") as NodeJS.ErrnoException;
|
||||
err.code = "EPERM";
|
||||
throw err;
|
||||
});
|
||||
|
||||
await saveCronStore(storePath, dummyStore);
|
||||
const loaded = await loadCronStore(storePath);
|
||||
expect(loaded).toEqual(dummyStore);
|
||||
|
||||
spy.mockRestore();
|
||||
await cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,5 +71,30 @@ export async function saveCronStore(storePath: string, store: CronStoreFile) {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
await fs.promises.rename(tmp, storePath);
|
||||
await renameWithRetry(tmp, storePath);
|
||||
}
|
||||
|
||||
const RENAME_MAX_RETRIES = 3;
|
||||
const RENAME_BASE_DELAY_MS = 50;
|
||||
|
||||
async function renameWithRetry(src: string, dest: string): Promise<void> {
|
||||
for (let attempt = 0; attempt <= RENAME_MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
await fs.promises.rename(src, dest);
|
||||
return;
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
if (code === "EBUSY" && attempt < RENAME_MAX_RETRIES) {
|
||||
await new Promise((resolve) => setTimeout(resolve, RENAME_BASE_DELAY_MS * 2 ** attempt));
|
||||
continue;
|
||||
}
|
||||
// Windows doesn't reliably support atomic replace via rename when dest exists.
|
||||
if (code === "EPERM" || code === "EEXIST") {
|
||||
await fs.promises.copyFile(src, dest);
|
||||
await fs.promises.unlink(src).catch(() => {});
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user