Skip to content

Commit 1be30fc

Browse files
KJ7LNWEric Wheelerdaniel-lxs
authored
fix: use safeWriteJson for all JSON file writes (RooCodeInc#3772)
* feat: Add safeWriteJson utility for atomic file operations Implements a robust JSON file writing utility that: - Prevents concurrent writes to the same file using in-memory locks - Ensures atomic operations with temporary file and backup strategies - Handles error cases with proper rollback mechanisms - Cleans up temporary files even when operations fail - Provides comprehensive test coverage for success and failure scenarios Signed-off-by: Eric Wheeler <[email protected]> * fix: use safeWriteJson for all JSON file writes This change refactors all direct JSON file writes to use the safeWriteJson utility, which implements atomic file writes to prevent data corruption during write operations. - Modified safeWriteJson to accept optional replacer and space arguments - Updated tests to verify correct behavior with the new implementation Fixes: RooCodeInc#722 Signed-off-by: Eric Wheeler <[email protected]> * feat: Implement inter-process file locking for safeWriteJson Replaces the previous in-memory lock in `safeWriteJson` with `proper-lockfile` to provide robust, cross-process advisory file locking. This enhances safety when multiple processes might attempt concurrent writes to the same JSON file. Key changes: - Added `proper-lockfile` and `@types/proper-lockfile` dependencies. - `safeWriteJson` now uses `proper-lockfile.lock()` with configured retries, staleness checks (31s), and lock update intervals (10s). - An `onCompromised` handler is included to manage scenarios where the lock state is unexpectedly altered. - Logging and comments within `safeWriteJson` have been refined for clarity, ensuring error logs include backtraces. - The test suite `safeWriteJson.test.ts` has been significantly updated to: - Use real timers (`jest.useRealTimers()`). - Employ a more comprehensive mock for `fs/promises`. - Correctly manage file pre-existence for various scenarios. - Simulate lock contention by mocking `proper-lockfile.lock()` using `jest.doMock` and a dynamic require for the SUT. - Verify lock release by checking for the absence of the `.lock` file. All tests are passing with these changes. Signed-off-by: Eric Wheeler <[email protected]> * feat: implement streaming JSON write in safeWriteJson Refactor safeWriteJson to use stream-json for memory-efficient JSON serialization: - Replace in-memory string creation with streaming pipeline - Add Disassembler and Stringer from stream-json library - Extract streaming logic to a dedicated helper function - Add proper-lockfile and stream-json dependencies This implementation reduces memory usage when writing large JSON objects. Signed-off-by: Eric Wheeler <[email protected]> * fix: improve safeWriteJson locking mechanism - Use file path itself for locking instead of separate lock file - Improve error handling and clarity of code - Enhance cleanup of temporary files Signed-off-by: Eric Wheeler <[email protected]> * test: fix safeWriteJson test failures - Ensure test file exists before locking - Add proper mocking for fs.createWriteStream - Fix test assertions to match expected behavior - Improve test comments to follow project guidelines Signed-off-by: Eric Wheeler <[email protected]> * test: update tests to work with safeWriteJson Updated tests to work with safeWriteJson instead of direct fs.writeFile calls: - Updated importExport.test.ts to expect safeWriteJson calls instead of fs.writeFile - Fixed McpHub.test.ts by properly mocking fs/promises module: - Moved jest.mock() to the top of the file before any imports - Added mock implementations for all fs functions used by safeWriteJson - Updated the test setup to work with the mocked fs module All tests now pass successfully. Signed-off-by: Eric Wheeler <[email protected]> * refactor: replace JSON.stringify with safeWriteJson for file operations Replace all non-test instances of JSON.stringify used for writing to JSON files with safeWriteJson to ensure safer file operations with proper locking, error handling, and atomic writes. - Updated src/services/mcp/McpHub.ts - Updated src/services/code-index/cache-manager.ts - Updated src/api/providers/fetchers/modelEndpointCache.ts - Updated src/api/providers/fetchers/modelCache.ts - Updated tests to match the new implementation Signed-off-by: Eric Wheeler <[email protected]> * docs: add rules for using safeWriteJson Add concise rules for using safeWriteJson instead of JSON.stringify with file operations to ensure atomic writes and prevent data corruption. Signed-off-by: Eric Wheeler <[email protected]> --------- Signed-off-by: Eric Wheeler <[email protected]> Co-authored-by: Eric Wheeler <[email protected]> Co-authored-by: Daniel <[email protected]>
1 parent 67d238a commit 1be30fc

File tree

18 files changed

+889
-45
lines changed

18 files changed

+889
-45
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# JSON File Writing Must Be Atomic
2+
3+
- MUST ALWAYS use `safeWriteJson(filePath: string, data: any): Promise<void>` from `src/utils/safeWriteJson.ts` instead of `JSON.stringify` with file-write operations
4+
- `safeWriteJson` prevents data corruption via atomic writes with locking and streams the write to minimize memory footprint
5+
- Test files are exempt from this rule

pnpm-lock.yaml

Lines changed: 66 additions & 0 deletions
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"
@@ -19,7 +20,7 @@ const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 })
1920
async function writeModels(router: RouterName, data: ModelRecord) {
2021
const filename = `${router}_models.json`
2122
const cacheDir = await getCacheDirectoryPath(ContextProxy.instance.globalStorageUri.fsPath)
22-
await fs.writeFile(path.join(cacheDir, filename), JSON.stringify(data))
23+
await safeWriteJson(path.join(cacheDir, filename), data)
2324
}
2425

2526
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.test.ts

Lines changed: 16 additions & 12 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
jest.mock("vscode", () => ({
1718
window: {
@@ -33,6 +34,8 @@ jest.mock("os", () => ({
3334
homedir: jest.fn(() => "/mock/home"),
3435
}))
3536

37+
jest.mock("../../../utils/safeWriteJson")
38+
3639
describe("importExport", () => {
3740
let mockProviderSettingsManager: jest.Mocked<ProviderSettingsManager>
3841
let mockContextProxy: jest.Mocked<ContextProxy>
@@ -372,11 +375,10 @@ describe("importExport", () => {
372375
expect(mockContextProxy.export).toHaveBeenCalled()
373376
expect(fs.mkdir).toHaveBeenCalledWith("/mock/path", { recursive: true })
374377

375-
expect(fs.writeFile).toHaveBeenCalledWith(
376-
"/mock/path/roo-code-settings.json",
377-
JSON.stringify({ providerProfiles: mockProviderProfiles, globalSettings: mockGlobalSettings }, null, 2),
378-
"utf-8",
379-
)
378+
expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/roo-code-settings.json", {
379+
providerProfiles: mockProviderProfiles,
380+
globalSettings: mockGlobalSettings,
381+
})
380382
})
381383

382384
it("should include globalSettings when allowedMaxRequests is null", async () => {
@@ -405,11 +407,10 @@ describe("importExport", () => {
405407
contextProxy: mockContextProxy,
406408
})
407409

408-
expect(fs.writeFile).toHaveBeenCalledWith(
409-
"/mock/path/roo-code-settings.json",
410-
JSON.stringify({ providerProfiles: mockProviderProfiles, globalSettings: mockGlobalSettings }, null, 2),
411-
"utf-8",
412-
)
410+
expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/roo-code-settings.json", {
411+
providerProfiles: mockProviderProfiles,
412+
globalSettings: mockGlobalSettings,
413+
})
413414
})
414415

415416
it("should handle errors during the export process", async () => {
@@ -424,7 +425,8 @@ describe("importExport", () => {
424425
})
425426

426427
mockContextProxy.export.mockResolvedValue({ mode: "code" })
427-
;(fs.writeFile as jest.Mock).mockRejectedValue(new Error("Write error"))
428+
// Simulate an error during the safeWriteJson operation
429+
;(safeWriteJson as jest.Mock).mockRejectedValueOnce(new Error("Safe write error"))
428430

429431
await exportSettings({
430432
providerSettingsManager: mockProviderSettingsManager,
@@ -435,8 +437,10 @@ describe("importExport", () => {
435437
expect(mockProviderSettingsManager.export).toHaveBeenCalled()
436438
expect(mockContextProxy.export).toHaveBeenCalled()
437439
expect(fs.mkdir).toHaveBeenCalledWith("/mock/path", { recursive: true })
438-
expect(fs.writeFile).toHaveBeenCalled()
440+
expect(safeWriteJson).toHaveBeenCalled() // safeWriteJson is called, but it will throw
439441
// The error is caught and the function exits silently.
442+
// Optionally, ensure no error message was shown if that's part of "silent"
443+
// expect(vscode.window.showErrorMessage).not.toHaveBeenCalled();
440444
})
441445

442446
it("should handle errors during directory creation", 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

@@ -46,5 +47,5 @@ export async function saveApiMessages({
4647
}) {
4748
const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId)
4849
const filePath = path.join(taskDir, GlobalFileNames.apiConversationHistory)
49-
await fs.writeFile(filePath, JSON.stringify(messages))
50+
await safeWriteJson(filePath, messages)
5051
}

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.test.ts

Lines changed: 4 additions & 4 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

@@ -41,6 +42,8 @@ jest.mock("axios", () => ({
4142
post: jest.fn(),
4243
}))
4344

45+
jest.mock("../../../utils/safeWriteJson")
46+
4447
jest.mock(
4548
"@modelcontextprotocol/sdk/types.js",
4649
() => ({
@@ -2001,10 +2004,7 @@ describe("Project MCP Settings", () => {
20012004
)
20022005

20032006
// Verify file was created with default content
2004-
expect(fs.writeFile).toHaveBeenCalledWith(
2005-
expect.stringContaining("mcp.json"),
2006-
JSON.stringify({ mcpServers: {} }, null, 2),
2007-
)
2007+
expect(safeWriteJson).toHaveBeenCalledWith(expect.stringContaining("mcp.json"), { mcpServers: {} })
20082008
})
20092009

20102010
test("handles openProjectMcpSettings when workspace is not open", async () => {

0 commit comments

Comments
 (0)