Skip to content

Commit 300fb36

Browse files
authored
infra: atomically replace sync JSON writes (#60589)
Merged via squash. Prepared head SHA: cb8ed77 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras
1 parent 628c711 commit 300fb36

3 files changed

Lines changed: 227 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ Docs: https://docs.openclaw.ai
104104
- WhatsApp/watchdog: reset watchdog timeout after reconnect so quiet channels no longer enter a tight reconnect loop from stale message timestamps carried across connection runs. (#60007) Thanks @MonkeyLeeT.
105105
- Agents/fallback: persist selected fallback overrides before retry attempts start, prefer persisted overrides during live-session reconciliation, and keep provider-scoped auth-profile failover from snapping retries back to stale primary selections.
106106
- Agents/MCP: sort MCP tools deterministically by name so the tools block in API requests is stable across turns, preventing unnecessary prompt-cache busting from non-deterministic `listTools()` order. (#58037) Thanks @bcherny.
107+
- Infra/json-file: preserve symlink-backed JSON stores and Windows overwrite fallback when atomically saving small sync JSON state files. (#60589) Thanks @gumadeiras.
107108

108109
## 2026.4.2
109110

src/infra/json-file.test.ts

Lines changed: 111 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
import fs from "node:fs";
22
import path from "node:path";
3-
import { describe, expect, it } from "vitest";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
44
import { withTempDir } from "../test-helpers/temp-dir.js";
55
import { loadJsonFile, saveJsonFile } from "./json-file.js";
66

7+
const SAVED_PAYLOAD = { enabled: true, count: 2 };
8+
const PREVIOUS_JSON = '{"enabled":false}\n';
9+
10+
function escapeRegExp(value: string): string {
11+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
12+
}
13+
14+
function writeExistingJson(pathname: string) {
15+
fs.writeFileSync(pathname, PREVIOUS_JSON, "utf8");
16+
}
17+
718
async function withJsonPath<T>(
819
run: (params: { root: string; pathname: string }) => Promise<T> | T,
920
): Promise<T> {
@@ -13,6 +24,10 @@ async function withJsonPath<T>(
1324
}
1425

1526
describe("json-file helpers", () => {
27+
afterEach(() => {
28+
vi.restoreAllMocks();
29+
});
30+
1631
it.each([
1732
{
1833
name: "missing files",
@@ -40,11 +55,11 @@ describe("json-file helpers", () => {
4055
it("creates parent dirs, writes a trailing newline, and loads the saved object", async () => {
4156
await withTempDir({ prefix: "openclaw-json-file-" }, async (root) => {
4257
const pathname = path.join(root, "nested", "config.json");
43-
saveJsonFile(pathname, { enabled: true, count: 2 });
58+
saveJsonFile(pathname, SAVED_PAYLOAD);
4459

4560
const raw = fs.readFileSync(pathname, "utf8");
4661
expect(raw.endsWith("\n")).toBe(true);
47-
expect(loadJsonFile(pathname)).toEqual({ enabled: true, count: 2 });
62+
expect(loadJsonFile(pathname)).toEqual(SAVED_PAYLOAD);
4863

4964
const fileMode = fs.statSync(pathname).mode & 0o777;
5065
const dirMode = fs.statSync(path.dirname(pathname)).mode & 0o777;
@@ -64,15 +79,103 @@ describe("json-file helpers", () => {
6479
},
6580
{
6681
name: "existing JSON files",
67-
setup: (pathname: string) => {
68-
fs.writeFileSync(pathname, '{"enabled":false}\n', "utf8");
69-
},
82+
setup: writeExistingJson,
7083
},
7184
])("writes the latest payload for $name", async ({ setup }) => {
7285
await withJsonPath(({ pathname }) => {
7386
setup(pathname);
74-
saveJsonFile(pathname, { enabled: true, count: 2 });
75-
expect(loadJsonFile(pathname)).toEqual({ enabled: true, count: 2 });
87+
saveJsonFile(pathname, SAVED_PAYLOAD);
88+
expect(loadJsonFile(pathname)).toEqual(SAVED_PAYLOAD);
89+
});
90+
});
91+
92+
it("writes through a sibling temp file before replacing the destination", async () => {
93+
await withJsonPath(({ pathname }) => {
94+
writeExistingJson(pathname);
95+
const renameSpy = vi.spyOn(fs, "renameSync");
96+
97+
saveJsonFile(pathname, SAVED_PAYLOAD);
98+
99+
const renameCall = renameSpy.mock.calls.find(([, target]) => target === pathname);
100+
expect(renameCall?.[0]).toMatch(new RegExp(`^${escapeRegExp(pathname)}\\..+\\.tmp$`));
101+
expect(renameSpy).toHaveBeenCalledWith(renameCall?.[0], pathname);
102+
expect(loadJsonFile(pathname)).toEqual(SAVED_PAYLOAD);
103+
});
104+
});
105+
106+
it.runIf(process.platform !== "win32")(
107+
"preserves symlink destinations when replacing existing JSON files",
108+
async () => {
109+
await withTempDir({ prefix: "openclaw-json-file-" }, async (root) => {
110+
const targetDir = path.join(root, "target");
111+
const targetPath = path.join(targetDir, "config.json");
112+
const linkPath = path.join(root, "config-link.json");
113+
fs.mkdirSync(targetDir, { recursive: true });
114+
writeExistingJson(targetPath);
115+
fs.symlinkSync(targetPath, linkPath);
116+
117+
saveJsonFile(linkPath, SAVED_PAYLOAD);
118+
119+
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
120+
expect(loadJsonFile(targetPath)).toEqual(SAVED_PAYLOAD);
121+
expect(loadJsonFile(linkPath)).toEqual(SAVED_PAYLOAD);
122+
});
123+
},
124+
);
125+
126+
it.runIf(process.platform !== "win32")(
127+
"creates a missing target file through an existing symlink",
128+
async () => {
129+
await withTempDir({ prefix: "openclaw-json-file-" }, async (root) => {
130+
const targetDir = path.join(root, "target");
131+
const targetPath = path.join(targetDir, "config.json");
132+
const linkPath = path.join(root, "config-link.json");
133+
fs.mkdirSync(targetDir, { recursive: true });
134+
fs.symlinkSync(targetPath, linkPath);
135+
136+
saveJsonFile(linkPath, SAVED_PAYLOAD);
137+
138+
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
139+
expect(loadJsonFile(targetPath)).toEqual(SAVED_PAYLOAD);
140+
expect(loadJsonFile(linkPath)).toEqual(SAVED_PAYLOAD);
141+
});
142+
},
143+
);
144+
145+
it.runIf(process.platform !== "win32")(
146+
"does not create missing target directories through an existing symlink",
147+
async () => {
148+
await withTempDir({ prefix: "openclaw-json-file-" }, async (root) => {
149+
const missingTargetDir = path.join(root, "missing-target");
150+
const targetPath = path.join(missingTargetDir, "config.json");
151+
const linkPath = path.join(root, "config-link.json");
152+
fs.symlinkSync(targetPath, linkPath);
153+
154+
expect(() => saveJsonFile(linkPath, SAVED_PAYLOAD)).toThrow(
155+
expect.objectContaining({ code: "ENOENT" }),
156+
);
157+
expect(fs.existsSync(missingTargetDir)).toBe(false);
158+
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
159+
});
160+
},
161+
);
162+
163+
it("falls back to copy when rename-based overwrite fails", async () => {
164+
await withJsonPath(({ root, pathname }) => {
165+
writeExistingJson(pathname);
166+
const copySpy = vi.spyOn(fs, "copyFileSync");
167+
const renameSpy = vi.spyOn(fs, "renameSync").mockImplementationOnce(() => {
168+
const err = new Error("EPERM") as NodeJS.ErrnoException;
169+
err.code = "EPERM";
170+
throw err;
171+
});
172+
173+
saveJsonFile(pathname, SAVED_PAYLOAD);
174+
175+
expect(renameSpy).toHaveBeenCalledOnce();
176+
expect(copySpy).toHaveBeenCalledOnce();
177+
expect(loadJsonFile(pathname)).toEqual(SAVED_PAYLOAD);
178+
expect(fs.readdirSync(root)).toEqual(["config.json"]);
76179
});
77180
});
78181
});

src/infra/json-file.ts

Lines changed: 115 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,129 @@
1+
import { randomUUID } from "node:crypto";
12
import fs from "node:fs";
23
import path from "node:path";
34

4-
export function loadJsonFile(pathname: string): unknown {
5+
const JSON_FILE_MODE = 0o600;
6+
const JSON_DIR_MODE = 0o700;
7+
8+
function trySetSecureMode(pathname: string) {
9+
try {
10+
fs.chmodSync(pathname, JSON_FILE_MODE);
11+
} catch {
12+
// best-effort on platforms without chmod support
13+
}
14+
}
15+
16+
function trySyncDirectory(pathname: string) {
17+
let fd: number | undefined;
18+
try {
19+
fd = fs.openSync(path.dirname(pathname), "r");
20+
fs.fsyncSync(fd);
21+
} catch {
22+
// best-effort; some platforms/filesystems do not support syncing directories.
23+
} finally {
24+
if (fd !== undefined) {
25+
try {
26+
fs.closeSync(fd);
27+
} catch {
28+
// best-effort cleanup
29+
}
30+
}
31+
}
32+
}
33+
34+
function readSymlinkTargetPath(linkPath: string): string {
35+
const target = fs.readlinkSync(linkPath);
36+
return path.resolve(path.dirname(linkPath), target);
37+
}
38+
39+
function resolveJsonWriteTarget(pathname: string): { targetPath: string; followsSymlink: boolean } {
40+
let currentPath = pathname;
41+
const visited = new Set<string>();
42+
let followsSymlink = false;
43+
44+
for (;;) {
45+
let stat: fs.Stats;
46+
try {
47+
stat = fs.lstatSync(currentPath);
48+
} catch (error) {
49+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
50+
throw error;
51+
}
52+
return { targetPath: currentPath, followsSymlink };
53+
}
54+
55+
if (!stat.isSymbolicLink()) {
56+
return { targetPath: currentPath, followsSymlink };
57+
}
58+
59+
if (visited.has(currentPath)) {
60+
const err = new Error(
61+
`Too many symlink levels while resolving ${pathname}`,
62+
) as NodeJS.ErrnoException;
63+
err.code = "ELOOP";
64+
throw err;
65+
}
66+
67+
visited.add(currentPath);
68+
followsSymlink = true;
69+
currentPath = readSymlinkTargetPath(currentPath);
70+
}
71+
}
72+
73+
function renameJsonFileWithFallback(tmpPath: string, pathname: string) {
574
try {
6-
if (!fs.existsSync(pathname)) {
7-
return undefined;
75+
fs.renameSync(tmpPath, pathname);
76+
return;
77+
} catch (error) {
78+
const code = (error as NodeJS.ErrnoException).code;
79+
// Windows does not reliably support rename-based overwrite for existing files.
80+
if (code === "EPERM" || code === "EEXIST") {
81+
fs.copyFileSync(tmpPath, pathname);
82+
fs.rmSync(tmpPath, { force: true });
83+
return;
884
}
85+
throw error;
86+
}
87+
}
88+
89+
function writeTempJsonFile(pathname: string, payload: string) {
90+
const fd = fs.openSync(pathname, "w", JSON_FILE_MODE);
91+
try {
92+
fs.writeFileSync(fd, payload, "utf8");
93+
fs.fsyncSync(fd);
94+
} finally {
95+
fs.closeSync(fd);
96+
}
97+
}
98+
99+
export function loadJsonFile<T = unknown>(pathname: string): T | undefined {
100+
try {
9101
const raw = fs.readFileSync(pathname, "utf8");
10-
return JSON.parse(raw) as unknown;
102+
return JSON.parse(raw) as T;
11103
} catch {
12104
return undefined;
13105
}
14106
}
15107

16108
export function saveJsonFile(pathname: string, data: unknown) {
17-
const dir = path.dirname(pathname);
18-
if (!fs.existsSync(dir)) {
19-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
109+
const { targetPath, followsSymlink } = resolveJsonWriteTarget(pathname);
110+
const tmpPath = `${targetPath}.${randomUUID()}.tmp`;
111+
const payload = `${JSON.stringify(data, null, 2)}\n`;
112+
113+
if (!followsSymlink) {
114+
fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: JSON_DIR_MODE });
115+
}
116+
try {
117+
writeTempJsonFile(tmpPath, payload);
118+
trySetSecureMode(tmpPath);
119+
renameJsonFileWithFallback(tmpPath, targetPath);
120+
trySetSecureMode(targetPath);
121+
trySyncDirectory(targetPath);
122+
} finally {
123+
try {
124+
fs.rmSync(tmpPath, { force: true });
125+
} catch {
126+
// best-effort cleanup when rename does not happen
127+
}
20128
}
21-
fs.writeFileSync(pathname, `${JSON.stringify(data, null, 2)}\n`, "utf8");
22-
fs.chmodSync(pathname, 0o600);
23129
}

0 commit comments

Comments
 (0)