Skip to content

Commit 47072be

Browse files
authored
Merge pull request #102 from Wibias/codex/retry-windows-atomic-rename
fix: retry transient Windows atomic renames
2 parents 5dadf6d + 21e58b0 commit 47072be

2 files changed

Lines changed: 60 additions & 2 deletions

File tree

src/config.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,44 @@ import { providerDestinationConfigError } from "./lib/destination-policy";
88
import type { OcxConfig } from "./types";
99

1010
let _atomicSeq = 0;
11+
12+
interface AtomicRenameIO {
13+
platform: NodeJS.Platform;
14+
rename: (source: string, destination: string) => void;
15+
sleep: (milliseconds: number) => void;
16+
}
17+
18+
export function renameAtomicFile(
19+
source: string,
20+
destination: string,
21+
io: AtomicRenameIO = {
22+
platform: process.platform,
23+
rename: renameSync,
24+
sleep: Bun.sleepSync,
25+
},
26+
): void {
27+
for (let attempt = 0; ; attempt += 1) {
28+
try {
29+
io.rename(source, destination);
30+
return;
31+
} catch (error) {
32+
const code = (error as NodeJS.ErrnoException).code;
33+
const transientWindowsError = io.platform === "win32"
34+
&& (code === "EBUSY" || code === "EPERM" || code === "EACCES");
35+
if (!transientWindowsError || attempt >= 2) throw error;
36+
io.sleep(25 * (attempt + 1));
37+
}
38+
}
39+
}
40+
1141
/**
1242
* Write a file atomically (temp + rename) so concurrent writers — e.g. `ocx stop` and the
1343
* proxy's own shutdown handler both restoring Codex — can never leave a half-written file.
1444
*/
1545
export function atomicWriteFile(path: string, content: string): void {
1646
const tmp = `${path}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
1747
writeFileSync(tmp, content, { encoding: "utf-8", mode: 0o600 });
18-
renameSync(tmp, path);
48+
renameAtomicFile(tmp, path);
1949
}
2050

2151
/**

tests/config.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
} from "../src/config";
2222

2323
import * as windowsAcl from "../src/lib/windows-secret-acl";
24-
import { hardenConfigDir, hardenExistingSecret, saveConfig } from "../src/config";
24+
import { hardenConfigDir, hardenExistingSecret, renameAtomicFile, saveConfig } from "../src/config";
2525
let testDir = "";
2626

2727
beforeEach(() => {
@@ -48,6 +48,34 @@ function writeConfig(content: unknown): void {
4848
}
4949

5050
describe("opencodex config defaults", () => {
51+
test("atomic rename retries transient Windows sharing violations", () => {
52+
const sleeps: number[] = [];
53+
let attempts = 0;
54+
renameAtomicFile("source.tmp", "config.json", {
55+
platform: "win32",
56+
rename: () => {
57+
attempts += 1;
58+
if (attempts < 3) throw Object.assign(new Error("locked"), { code: "EPERM" });
59+
},
60+
sleep: ms => sleeps.push(ms),
61+
});
62+
63+
expect(attempts).toBe(3);
64+
expect(sleeps).toEqual([25, 50]);
65+
});
66+
67+
test("atomic rename does not retry non-transient errors", () => {
68+
let attempts = 0;
69+
expect(() => renameAtomicFile("source.tmp", "config.json", {
70+
platform: "win32",
71+
rename: () => {
72+
attempts += 1;
73+
throw Object.assign(new Error("invalid"), { code: "EINVAL" });
74+
},
75+
sleep: () => {},
76+
})).toThrow("invalid");
77+
expect(attempts).toBe(1);
78+
});
5179
test("Codex autostart is enabled by default", () => {
5280
expect(getDefaultConfig().codexAutoStart).toBe(true);
5381
expect(codexAutoStartEnabled({})).toBe(true);

0 commit comments

Comments
 (0)