Skip to content

Commit 8455909

Browse files
KJ7LNWEric Wheelerdaniel-lxs
authored
fix: use safeWriteJson for all JSON file writes with race condition fix (#4733)
Co-authored-by: Eric Wheeler <[email protected]> Co-authored-by: Daniel Riccio <[email protected]>
1 parent 8ef359f commit 8455909

File tree

17 files changed

+886
-40
lines changed

17 files changed

+886
-40
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# JSON File Writing Must Be Atomic
2+
3+
- You MUST use `safeWriteJson(filePath: string, data: any): Promise<void>` from `src/utils/safeWriteJson.ts` instead of `JSON.stringify` with file-write operations
4+
- `safeWriteJson` will create parent directories if necessary, so do not call `mkdir` prior to `safeWriteJson`
5+
- `safeWriteJson` prevents data corruption via atomic writes with locking and streams the write to minimize memory footprint
6+
- Test files are exempt from this rule

pnpm-lock.yaml

Lines changed: 66 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/api/providers/fetchers/modelCache.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as path from "path"
22
import fs from "fs/promises"
33

44
import NodeCache from "node-cache"
5+
import { safeWriteJson } from "../../../utils/safeWriteJson"
56

67
import { ContextProxy } from "../../../core/config/ContextProxy"
78
import { getCacheDirectoryPath } from "../../../utils/storage"
@@ -22,7 +23,7 @@ const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 })
2223
async function writeModels(router: RouterName, data: ModelRecord) {
2324
const filename = `${router}_models.json`
2425
const cacheDir = await getCacheDirectoryPath(ContextProxy.instance.globalStorageUri.fsPath)
25-
await fs.writeFile(path.join(cacheDir, filename), JSON.stringify(data))
26+
await safeWriteJson(path.join(cacheDir, filename), data)
2627
}
2728

2829
async function readModels(router: RouterName): Promise<ModelRecord | undefined> {

src/api/providers/fetchers/modelEndpointCache.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as path from "path"
22
import fs from "fs/promises"
33

44
import NodeCache from "node-cache"
5+
import { safeWriteJson } from "../../../utils/safeWriteJson"
56
import sanitize from "sanitize-filename"
67

78
import { ContextProxy } from "../../../core/config/ContextProxy"
@@ -18,7 +19,7 @@ const getCacheKey = (router: RouterName, modelId: string) => sanitize(`${router}
1819
async function writeModelEndpoints(key: string, data: ModelRecord) {
1920
const filename = `${key}_endpoints.json`
2021
const cacheDir = await getCacheDirectoryPath(ContextProxy.instance.globalStorageUri.fsPath)
21-
await fs.writeFile(path.join(cacheDir, filename), JSON.stringify(data, null, 2))
22+
await safeWriteJson(path.join(cacheDir, filename), data)
2223
}
2324

2425
async function readModelEndpoints(key: string): Promise<ModelRecord | undefined> {

src/core/config/__tests__/importExport.spec.ts

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { importSettings, exportSettings } from "../importExport"
1212
import { ProviderSettingsManager } from "../ProviderSettingsManager"
1313
import { ContextProxy } from "../ContextProxy"
1414
import { CustomModesManager } from "../CustomModesManager"
15+
import { safeWriteJson } from "../../../utils/safeWriteJson"
1516

1617
import type { Mock } from "vitest"
1718

@@ -43,6 +44,8 @@ vi.mock("os", () => ({
4344
homedir: vi.fn(() => "/mock/home"),
4445
}))
4546

47+
vi.mock("../../../utils/safeWriteJson")
48+
4649
describe("importExport", () => {
4750
let mockProviderSettingsManager: ReturnType<typeof vi.mocked<ProviderSettingsManager>>
4851
let mockContextProxy: ReturnType<typeof vi.mocked<ContextProxy>>
@@ -384,11 +387,10 @@ describe("importExport", () => {
384387
expect(mockContextProxy.export).toHaveBeenCalled()
385388
expect(fs.mkdir).toHaveBeenCalledWith("/mock/path", { recursive: true })
386389

387-
expect(fs.writeFile).toHaveBeenCalledWith(
388-
"/mock/path/roo-code-settings.json",
389-
JSON.stringify({ providerProfiles: mockProviderProfiles, globalSettings: mockGlobalSettings }, null, 2),
390-
"utf-8",
391-
)
390+
expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/roo-code-settings.json", {
391+
providerProfiles: mockProviderProfiles,
392+
globalSettings: mockGlobalSettings,
393+
})
392394
})
393395

394396
it("should include globalSettings when allowedMaxRequests is null", async () => {
@@ -417,11 +419,10 @@ describe("importExport", () => {
417419
contextProxy: mockContextProxy,
418420
})
419421

420-
expect(fs.writeFile).toHaveBeenCalledWith(
421-
"/mock/path/roo-code-settings.json",
422-
JSON.stringify({ providerProfiles: mockProviderProfiles, globalSettings: mockGlobalSettings }, null, 2),
423-
"utf-8",
424-
)
422+
expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/roo-code-settings.json", {
423+
providerProfiles: mockProviderProfiles,
424+
globalSettings: mockGlobalSettings,
425+
})
425426
})
426427

427428
it("should handle errors during the export process", async () => {
@@ -436,7 +437,8 @@ describe("importExport", () => {
436437
})
437438

438439
mockContextProxy.export.mockResolvedValue({ mode: "code" })
439-
;(fs.writeFile as Mock).mockRejectedValue(new Error("Write error"))
440+
// Simulate an error during the safeWriteJson operation
441+
;(safeWriteJson as Mock).mockRejectedValueOnce(new Error("Safe write error"))
440442

441443
await exportSettings({
442444
providerSettingsManager: mockProviderSettingsManager,
@@ -447,8 +449,10 @@ describe("importExport", () => {
447449
expect(mockProviderSettingsManager.export).toHaveBeenCalled()
448450
expect(mockContextProxy.export).toHaveBeenCalled()
449451
expect(fs.mkdir).toHaveBeenCalledWith("/mock/path", { recursive: true })
450-
expect(fs.writeFile).toHaveBeenCalled()
452+
expect(safeWriteJson).toHaveBeenCalled() // safeWriteJson is called, but it will throw
451453
// The error is caught and the function exits silently.
454+
// Optionally, ensure no error message was shown if that's part of "silent"
455+
// expect(vscode.window.showErrorMessage).not.toHaveBeenCalled();
452456
})
453457

454458
it("should handle errors during directory creation", async () => {
@@ -474,7 +478,7 @@ describe("importExport", () => {
474478
expect(mockProviderSettingsManager.export).toHaveBeenCalled()
475479
expect(mockContextProxy.export).toHaveBeenCalled()
476480
expect(fs.mkdir).toHaveBeenCalled()
477-
expect(fs.writeFile).not.toHaveBeenCalled() // Should not be called since mkdir failed.
481+
expect(safeWriteJson).not.toHaveBeenCalled() // Should not be called since mkdir failed.
478482
})
479483

480484
it("should use the correct default save location", async () => {

src/core/config/importExport.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { safeWriteJson } from "../../utils/safeWriteJson"
12
import os from "os"
23
import * as path from "path"
34
import fs from "fs/promises"
@@ -116,6 +117,6 @@ export const exportSettings = async ({ providerSettingsManager, contextProxy }:
116117

117118
const dirname = path.dirname(uri.fsPath)
118119
await fs.mkdir(dirname, { recursive: true })
119-
await fs.writeFile(uri.fsPath, JSON.stringify({ providerProfiles, globalSettings }, null, 2), "utf-8")
120+
await safeWriteJson(uri.fsPath, { providerProfiles, globalSettings })
120121
} catch (e) {}
121122
}

src/core/context-tracking/FileContextTracker.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { safeWriteJson } from "../../utils/safeWriteJson"
12
import * as path from "path"
23
import * as vscode from "vscode"
34
import { getTaskDirectoryPath } from "../../utils/storage"
@@ -130,7 +131,7 @@ export class FileContextTracker {
130131
const globalStoragePath = this.getContextProxy()!.globalStorageUri.fsPath
131132
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
132133
const filePath = path.join(taskDir, GlobalFileNames.taskMetadata)
133-
await fs.writeFile(filePath, JSON.stringify(metadata, null, 2))
134+
await safeWriteJson(filePath, metadata)
134135
} catch (error) {
135136
console.error("Failed to save task metadata:", error)
136137
}

src/core/task-persistence/apiMessages.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { safeWriteJson } from "../../utils/safeWriteJson"
12
import * as path from "path"
23
import * as fs from "fs/promises"
34

@@ -78,5 +79,5 @@ export async function saveApiMessages({
7879
}) {
7980
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
8081
const filePath = path.join(taskDir, GlobalFileNames.apiConversationHistory)
81-
await fs.writeFile(filePath, JSON.stringify(messages))
82+
await safeWriteJson(filePath, messages)
8283
}

src/core/task-persistence/taskMessages.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { safeWriteJson } from "../../utils/safeWriteJson"
12
import * as path from "path"
23
import * as fs from "fs/promises"
34

@@ -37,5 +38,5 @@ export type SaveTaskMessagesOptions = {
3738
export async function saveTaskMessages({ messages, taskId, globalStoragePath }: SaveTaskMessagesOptions) {
3839
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
3940
const filePath = path.join(taskDir, GlobalFileNames.uiMessages)
40-
await fs.writeFile(filePath, JSON.stringify(messages))
41+
await safeWriteJson(filePath, messages)
4142
}

src/core/webview/__tests__/ClineProvider.spec.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { experimentDefault } from "../../../shared/experiments"
1313
import { setTtsEnabled } from "../../../utils/tts"
1414
import { ContextProxy } from "../../config/ContextProxy"
1515
import { Task, TaskOptions } from "../../task/Task"
16+
import { safeWriteJson } from "../../../utils/safeWriteJson"
1617

1718
import { ClineProvider } from "../ClineProvider"
1819

@@ -43,6 +44,8 @@ vi.mock("axios", () => ({
4344
post: vi.fn(),
4445
}))
4546

47+
vi.mock("../../../utils/safeWriteJson")
48+
4649
vi.mock("@modelcontextprotocol/sdk/types.js", () => ({
4750
CallToolResultSchema: {},
4851
ListResourcesResultSchema: {},
@@ -1989,11 +1992,8 @@ describe("Project MCP Settings", () => {
19891992
// Check that fs.mkdir was called with the correct path
19901993
expect(mockedFs.mkdir).toHaveBeenCalledWith("/test/workspace/.roo", { recursive: true })
19911994

1992-
// Check that fs.writeFile was called with default content
1993-
expect(mockedFs.writeFile).toHaveBeenCalledWith(
1994-
"/test/workspace/.roo/mcp.json",
1995-
JSON.stringify({ mcpServers: {} }, null, 2),
1996-
)
1995+
// Verify file was created with default content
1996+
expect(safeWriteJson).toHaveBeenCalledWith("/test/workspace/.roo/mcp.json", { mcpServers: {} })
19971997

19981998
// Check that openFile was called
19991999
expect(openFileSpy).toHaveBeenCalledWith("/test/workspace/.roo/mcp.json")

0 commit comments

Comments
 (0)