From 11a038207b95f2f5bb9b5dd79012ae2f8d500f43 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 7 May 2026 05:08:20 -0700 Subject: [PATCH] fix(infra): support non-durable text writes --- src/infra/json-files.test.ts | 12 ++++++++++++ src/infra/json-files.ts | 27 ++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/infra/json-files.test.ts b/src/infra/json-files.test.ts index 3f65861a04f..06134e686ce 100644 --- a/src/infra/json-files.test.ts +++ b/src/infra/json-files.test.ts @@ -96,6 +96,18 @@ describe("json file helpers", () => { }); }); + it("can skip durable fsync work for hot state writes", async () => { + await withTempDir({ prefix: "openclaw-json-files-" }, async (base) => { + const filePath = path.join(base, "state.json"); + const openSpy = vi.spyOn(fs, "open"); + + await writeTextAtomic(filePath, "new", { durable: false }); + + expect(openSpy).not.toHaveBeenCalled(); + await expect(fs.readFile(filePath, "utf8")).resolves.toBe("new"); + }); + }); + it("preserves text when Windows rename reports EPERM", async () => { await withTempDir({ prefix: "openclaw-json-files-" }, async (base) => { const filePath = path.join(base, "state.json"); diff --git a/src/infra/json-files.ts b/src/infra/json-files.ts index ebeab29b0ed..fcd42076141 100644 --- a/src/infra/json-files.ts +++ b/src/infra/json-files.ts @@ -1,4 +1,6 @@ import "./fs-safe-defaults.js"; +import { replaceFileAtomic } from "./replace-file.js"; + export { JsonFileReadError, readJson, @@ -17,5 +19,28 @@ export { writeJson as writeJsonAtomic, writeJsonSync, } from "@openclaw/fs-safe/json"; -export { writeTextAtomic } from "@openclaw/fs-safe/atomic"; export { createAsyncLock } from "@openclaw/fs-safe/advanced"; + +export type WriteTextAtomicOptions = { + mode?: number; + dirMode?: number; + trailingNewline?: boolean; + durable?: boolean; +}; + +export async function writeTextAtomic( + filePath: string, + content: string, + options?: WriteTextAtomicOptions, +): Promise { + const payload = options?.trailingNewline && !content.endsWith("\n") ? `${content}\n` : content; + await replaceFileAtomic({ + filePath, + content: payload, + mode: options?.mode ?? 0o600, + dirMode: options?.dirMode ?? 0o777 & ~process.umask(), + copyFallbackOnPermissionError: true, + syncTempFile: options?.durable !== false, + syncParentDir: options?.durable !== false, + }); +}