Skip to content

Commit 7d23a8d

Browse files
Eric Wheelerdaniel-lxs
authored andcommitted
fix: race condition in safeWriteJson directory handling
Fix race condition where safeWriteJson would fail with ENOENT errors during lock acquisition when the parent directory was just created. The issue occurred when the directory creation hadn't fully synchronized with the filesystem before attempting to acquire a lock. This happened primarily when Task.saveApiConversationHistory() called the function immediately after creating the task directory. The fix ensures directories exist and are fully synchronized before lock acquisition by: - Creating directories with fs.mkdir({ recursive: true }) - Verifying access to created directories - Setting realpath: false in lock options to allow locking non-existent files Added comprehensive tests for directory creation capabilities. Fixes: #4468 See-also: #4471, #3772, #722 Signed-off-by: Eric Wheeler <[email protected]>
1 parent 4d56689 commit 7d23a8d

File tree

2 files changed

+156
-25
lines changed

2 files changed

+156
-25
lines changed

src/utils/__tests__/safeWriteJson.test.ts

Lines changed: 139 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const originalFsPromisesRename = actualFsPromises.rename
33
const originalFsPromisesUnlink = actualFsPromises.unlink
44
const originalFsPromisesWriteFile = actualFsPromises.writeFile
55
const _originalFsPromisesAccess = actualFsPromises.access
6+
const originalFsPromisesMkdir = actualFsPromises.mkdir
67

78
jest.mock("fs/promises", () => {
89
const actual = jest.requireActual("fs/promises")
@@ -21,6 +22,7 @@ jest.mock("fs/promises", () => {
2122
mockedFs.mkdtemp = jest.fn(actual.mkdtemp)
2223
mockedFs.rm = jest.fn(actual.rm)
2324
mockedFs.readdir = jest.fn(actual.readdir)
25+
mockedFs.mkdir = jest.fn(actual.mkdir)
2426
// fs.stat and fs.lstat will be available via { ...actual }
2527

2628
return mockedFs
@@ -44,6 +46,30 @@ import { safeWriteJson } from "../safeWriteJson"
4446
import { Writable } from "stream" // For typing mock stream
4547

4648
describe("safeWriteJson", () => {
49+
let originalConsoleError: typeof console.error
50+
51+
beforeAll(() => {
52+
// Store original console.error
53+
originalConsoleError = console.error
54+
55+
// Replace with filtered version that suppresses output from the module
56+
console.error = function (...args) {
57+
// Check if call originated from safeWriteJson.ts
58+
if (new Error().stack?.includes("safeWriteJson.ts")) {
59+
// Suppress output but allow spy recording
60+
return
61+
}
62+
63+
// Pass through all other calls (from tests)
64+
return originalConsoleError.apply(console, args)
65+
}
66+
})
67+
68+
afterAll(() => {
69+
// Restore original behavior
70+
console.error = originalConsoleError
71+
})
72+
4773
jest.useRealTimers() // Use real timers for this test suite
4874

4975
let tempTestDir: string = ""
@@ -77,6 +103,7 @@ describe("safeWriteJson", () => {
77103
;(fs.mkdtemp as jest.Mock).mockImplementation(actualFsPromises.mkdtemp)
78104
;(fs.rm as jest.Mock).mockImplementation(actualFsPromises.rm)
79105
;(fs.readdir as jest.Mock).mockImplementation(actualFsPromises.readdir)
106+
;(fs.mkdir as jest.Mock).mockImplementation(actualFsPromises.mkdir)
80107
// Ensure all mocks are reset after each test
81108
jest.restoreAllMocks()
82109
})
@@ -199,11 +226,9 @@ describe("safeWriteJson", () => {
199226
const oldPathStr = oldPath.toString()
200227
const newPathStr = newPath.toString()
201228
renameCallCountTest1++
202-
console.log(`[TEST 1] fs.rename spy call #${renameCallCountTest1}: ${oldPathStr} -> ${newPathStr}`)
203229

204230
// First rename call by safeWriteJson (if target exists) is target -> .bak
205231
if (renameCallCountTest1 === 1 && !oldPathStr.includes(".new_") && newPathStr.includes(".bak_")) {
206-
console.log("[TEST 1] Spy: Call #1 (target->backup), executing original rename.")
207232
return originalFsPromisesRename(oldPath, newPath)
208233
}
209234
// Second rename call by safeWriteJson is .new -> target
@@ -212,7 +237,6 @@ describe("safeWriteJson", () => {
212237
oldPathStr.includes(".new_") &&
213238
path.resolve(newPathStr) === path.resolve(currentTestFilePath)
214239
) {
215-
console.log("[TEST 1] Spy: Call #2 (.new->target), THROWING SIMULATED ERROR.")
216240
throw new Error("Simulated FS Error: rename tempNewFilePath to filePath")
217241
}
218242
// Fallback for unexpected calls or if the target file didn't exist (only one rename: .new -> target)
@@ -223,14 +247,8 @@ describe("safeWriteJson", () => {
223247
) {
224248
// This case handles if the initial file didn't exist, so only one rename happens.
225249
// For this specific test, we expect two renames.
226-
console.warn(
227-
"[TEST 1] Spy: Call #1 was .new->target, (unexpected for this test scenario, but handling)",
228-
)
229250
throw new Error("Simulated FS Error: rename tempNewFilePath to filePath")
230251
}
231-
console.warn(
232-
`[TEST 1] Spy: Unexpected call #${renameCallCountTest1} or paths. Defaulting to original rename. ${oldPathStr} -> ${newPathStr}`,
233-
)
234252
return originalFsPromisesRename(oldPath, newPath)
235253
})
236254

@@ -249,6 +267,118 @@ describe("safeWriteJson", () => {
249267
renameSpy.mockRestore()
250268
})
251269

270+
// Tests for directory creation functionality
271+
test("should create parent directory if it doesn't exist", async () => {
272+
// Create a path in a non-existent subdirectory of the temp dir
273+
const nonExistentDir = path.join(tempTestDir, "non-existent-dir")
274+
const filePath = path.join(nonExistentDir, "test-data.json")
275+
const data = { message: "Hello from new directory" }
276+
277+
// Verify the directory doesn't exist yet
278+
const dirAccessError = await fs.access(nonExistentDir).catch((e) => e)
279+
expect(dirAccessError).toBeDefined()
280+
expect(dirAccessError.code).toBe("ENOENT")
281+
282+
// safeWriteJson should now create directories and initialize an empty file automatically
283+
284+
// safeWriteJson should write the file
285+
await safeWriteJson(filePath, data)
286+
287+
// Verify file was written correctly
288+
const writtenData = await readJsonFile(filePath)
289+
expect(writtenData).toEqual(data)
290+
291+
// Verify no temp files remain
292+
const tempFiles = await listTempFiles(nonExistentDir, "test-data.json")
293+
expect(tempFiles.length).toBe(0)
294+
})
295+
296+
test("should handle multi-level directory creation", async () => {
297+
// Create a new non-existent subdirectory path with multiple levels
298+
const newDir = path.join(tempTestDir, "new-test-dir", "subdir", "deeper")
299+
const filePath = path.join(newDir, "new-file.json")
300+
const data = { message: "New directory test" }
301+
302+
// Verify directories don't exist initially
303+
const dirAccessError = await fs.access(newDir).catch((e) => e)
304+
expect(dirAccessError).toBeDefined()
305+
expect(dirAccessError.code).toBe("ENOENT")
306+
307+
// Don't create any directories - safeWriteJson should handle it all
308+
309+
// Call safeWriteJson - it should create all missing directories and the file
310+
await safeWriteJson(filePath, data)
311+
312+
// Verify all directory levels now exist
313+
const dirExists = await fs
314+
.access(newDir)
315+
.then(() => true)
316+
.catch(() => false)
317+
expect(dirExists).toBe(true)
318+
319+
// Verify file was written correctly
320+
const writtenData = await readJsonFile(filePath)
321+
expect(writtenData).toEqual(data)
322+
323+
// Check that no temp files remain
324+
const tempFiles = await listTempFiles(newDir, "new-file.json")
325+
expect(tempFiles.length).toBe(0)
326+
})
327+
328+
test("should handle directory creation permission errors", async () => {
329+
// Mock mkdir to simulate a permission error
330+
const mkdirSpy = jest.spyOn(fs, "mkdir")
331+
mkdirSpy.mockImplementationOnce(async () => {
332+
const permError = new Error("EACCES: permission denied") as NodeJS.ErrnoException
333+
permError.code = "EACCES"
334+
throw permError
335+
})
336+
337+
// Create test file path in a directory that will fail with permission error
338+
const nonExistentDir = path.join(tempTestDir, "permission-denied-dir")
339+
const filePath = path.join(nonExistentDir, "test-data.json")
340+
const testData = { message: "Should not be written due to permission error" }
341+
342+
// Expect the function to fail with the permission error
343+
await expect(safeWriteJson(filePath, testData)).rejects.toThrow(/EACCES/)
344+
345+
// Verify the file was not created
346+
const fileExists = await fs
347+
.access(filePath)
348+
.then(() => true)
349+
.catch(() => false)
350+
expect(fileExists).toBe(false)
351+
352+
mkdirSpy.mockRestore()
353+
})
354+
355+
test("should successfully write to a non-existent file in an existing directory", async () => {
356+
// Create directory but not the file
357+
const existingDir = path.join(tempTestDir, "existing-dir")
358+
await fs.mkdir(existingDir, { recursive: true })
359+
360+
const filePath = path.join(existingDir, "non-existent-file.json")
361+
const data = { message: "Creating new file" }
362+
363+
// Verify file doesn't exist before the operation
364+
const accessError = await fs.access(filePath).catch((e) => e)
365+
expect(accessError).toBeDefined()
366+
expect(accessError.code).toBe("ENOENT")
367+
368+
// safeWriteJson should automatically create the empty file for lock acquisition
369+
370+
// Write to the file
371+
await safeWriteJson(filePath, data)
372+
373+
// Verify file was created with correct content
374+
const writtenData = await readJsonFile(filePath)
375+
expect(writtenData).toEqual(data)
376+
377+
// Verify no temp files remain
378+
const tempFiles = await listTempFiles(existingDir, "non-existent-file.json")
379+
expect(tempFiles.length).toBe(0)
380+
})
381+
252382
test("should handle failure when deleting tempBackupFilePath (filePath exists, all renames succeed)", async () => {
253383
const initialData = { message: "Initial content" }
254384
await fs.writeFile(currentTestFilePath, JSON.stringify(initialData)) // Use mocked fs for setup
@@ -259,10 +389,8 @@ describe("safeWriteJson", () => {
259389
unlinkSpy.mockImplementationOnce(async (filePath: any) => {
260390
const filePathStr = filePath.toString()
261391
if (filePathStr.includes(".bak_")) {
262-
console.log("[TEST unlink bak] Mock: Simulating failure for unlink backup.")
263392
throw new Error("Simulated FS Error: delete tempBackupFilePath")
264393
}
265-
console.log("[TEST unlink bak] Mock: Condition NOT MET. Using originalFsPromisesUnlink.")
266394
return originalFsPromisesUnlink(filePath)
267395
})
268396

@@ -438,40 +566,26 @@ describe("safeWriteJson", () => {
438566
const resolvedOldPath = path.resolve(oldPathStr)
439567
const resolvedNewPath = path.resolve(newPathStr)
440568
const resolvedCurrentTFP = path.resolve(currentTestFilePath)
441-
console.log(
442-
`[TEST 2] fs.promises.rename call #${renameCallCountTest2}: oldPath=${oldPathStr} (resolved: ${resolvedOldPath}), newPath=${newPathStr} (resolved: ${resolvedNewPath}), currentTFP (resolved: ${resolvedCurrentTFP})`,
443-
)
444569

445570
if (renameCallCountTest2 === 1) {
446571
// Call 1: Original -> Backup (Succeeds)
447572
if (resolvedOldPath === resolvedCurrentTFP && newPathStr.includes(".bak_")) {
448-
console.log("[TEST 2] Call #1 (Original->Backup): Condition MET. originalFsPromisesRename.")
449573
return originalFsPromisesRename(oldPath, newPath)
450574
}
451-
console.error("[TEST 2] Call #1: UNEXPECTED args.")
452575
throw new Error("Unexpected args for rename call #1 in test")
453576
} else if (renameCallCountTest2 === 2) {
454577
// Call 2: New -> Original (Fails - this is the "original error")
455578
if (oldPathStr.includes(".new_") && resolvedNewPath === resolvedCurrentTFP) {
456-
console.log(
457-
'[TEST 2] Call #2 (New->Original): Condition MET. Throwing "Simulated FS Error: new to original".',
458-
)
459579
throw new Error("Simulated FS Error: new to original")
460580
}
461-
console.error("[TEST 2] Call #2: UNEXPECTED args.")
462581
throw new Error("Unexpected args for rename call #2 in test")
463582
} else if (renameCallCountTest2 === 3) {
464583
// Call 3: Backup -> Original (Rollback attempt - Fails)
465584
if (oldPathStr.includes(".bak_") && resolvedNewPath === resolvedCurrentTFP) {
466-
console.log(
467-
'[TEST 2] Call #3 (Backup->Original Rollback): Condition MET. Throwing "Simulated FS Error: backup to original (rollback)".',
468-
)
469585
throw new Error("Simulated FS Error: backup to original (rollback)")
470586
}
471-
console.error("[TEST 2] Call #3: UNEXPECTED args.")
472587
throw new Error("Unexpected args for rename call #3 in test")
473588
}
474-
console.error(`[TEST 2] Unexpected fs.promises.rename call count: ${renameCallCountTest2}`)
475589
return originalFsPromisesRename(oldPath, newPath)
476590
})
477591

src/utils/safeWriteJson.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Stringer from "stream-json/Stringer"
77

88
/**
99
* Safely writes JSON data to a file.
10+
* - Creates parent directories if they don't exist
1011
* - Uses 'proper-lockfile' for inter-process advisory locking to prevent concurrent writes to the same path.
1112
* - Writes to a temporary file first.
1213
* - If the target file exists, it's backed up before being replaced.
@@ -21,11 +22,27 @@ async function safeWriteJson(filePath: string, data: any): Promise<void> {
2122
const absoluteFilePath = path.resolve(filePath)
2223
let releaseLock = async () => {} // Initialized to a no-op
2324

25+
// For directory creation
26+
const dirPath = path.dirname(absoluteFilePath)
27+
28+
// Ensure directory structure exists with improved reliability
29+
try {
30+
// Create directory with recursive option
31+
await fs.mkdir(dirPath, { recursive: true })
32+
33+
// Verify directory exists after creation attempt
34+
await fs.access(dirPath)
35+
} catch (dirError: any) {
36+
console.error(`Failed to create or access directory for ${absoluteFilePath}:`, dirError)
37+
throw dirError
38+
}
39+
2440
// Acquire the lock before any file operations
2541
try {
2642
releaseLock = await lockfile.lock(absoluteFilePath, {
2743
stale: 31000, // Stale after 31 seconds
2844
update: 10000, // Update mtime every 10 seconds to prevent staleness if operation is long
45+
realpath: false, // the file may not exist yet, which is acceptable
2946
retries: {
3047
// Configuration for retrying lock acquisition
3148
retries: 5, // Number of retries after the initial attempt

0 commit comments

Comments
 (0)