Skip to content

Commit 1bca55e

Browse files
Eric Wheelerdaniel-lxs
authored andcommitted
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]>
1 parent 4b774b9 commit 1bca55e

File tree

2 files changed

+106
-53
lines changed

2 files changed

+106
-53
lines changed

src/utils/__tests__/safeWriteJson.test.ts

Lines changed: 66 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,35 @@ const _originalFsPromisesAccess = actualFsPromises.access
66

77
jest.mock("fs/promises", () => {
88
const actual = jest.requireActual("fs/promises")
9-
return {
10-
// Explicitly mock functions used by the SUT and tests, defaulting to actual implementations
11-
writeFile: jest.fn(actual.writeFile),
12-
readFile: jest.fn(actual.readFile),
13-
rename: jest.fn(actual.rename),
14-
unlink: jest.fn(actual.unlink),
15-
access: jest.fn(actual.access),
16-
mkdtemp: jest.fn(actual.mkdtemp),
17-
rm: jest.fn(actual.rm),
18-
readdir: jest.fn(actual.readdir),
19-
// Ensure all functions from 'fs/promises' that might be called are explicitly mocked
20-
// or ensure that the SUT and tests only call functions defined here.
21-
// For any function not listed, calls like fs.someOtherFunc would be undefined.
22-
}
9+
// Start with all actual implementations.
10+
const mockedFs = { ...actual }
11+
12+
// Selectively wrap functions with jest.fn() if they are spied on
13+
// or have their implementations changed in tests.
14+
// This ensures that other fs.promises functions used by the SUT
15+
// (like proper-lockfile's internals) will use their actual implementations.
16+
mockedFs.writeFile = jest.fn(actual.writeFile)
17+
mockedFs.readFile = jest.fn(actual.readFile)
18+
mockedFs.rename = jest.fn(actual.rename)
19+
mockedFs.unlink = jest.fn(actual.unlink)
20+
mockedFs.access = jest.fn(actual.access)
21+
mockedFs.mkdtemp = jest.fn(actual.mkdtemp)
22+
mockedFs.rm = jest.fn(actual.rm)
23+
mockedFs.readdir = jest.fn(actual.readdir)
24+
// fs.stat and fs.lstat will be available via { ...actual }
25+
26+
return mockedFs
2327
})
2428

2529
import * as fs from "fs/promises" // This will now be the mocked version
2630
import * as path from "path"
2731
import * as os from "os"
28-
import { safeWriteJson, activeLocks } from "../safeWriteJson"
32+
// import * as lockfile from 'proper-lockfile' // No longer directly used in tests
33+
import { safeWriteJson } from "../safeWriteJson"
2934

3035
describe("safeWriteJson", () => {
36+
jest.useRealTimers() // Use real timers for this test suite
37+
3138
let tempTestDir: string = ""
3239
let currentTestFilePath = ""
3340

@@ -36,15 +43,15 @@ describe("safeWriteJson", () => {
3643
const tempDirPrefix = path.join(os.tmpdir(), "safeWriteJson-test-")
3744
tempTestDir = await fs.mkdtemp(tempDirPrefix)
3845
currentTestFilePath = path.join(tempTestDir, "test-data.json")
39-
activeLocks.clear()
46+
// Individual tests will now handle creation of currentTestFilePath if needed.
4047
})
4148

4249
afterEach(async () => {
4350
if (tempTestDir) {
4451
await fs.rm(tempTestDir, { recursive: true, force: true })
4552
tempTestDir = ""
4653
}
47-
activeLocks.clear()
54+
// activeLocks is no longer used
4855

4956
// Explicitly reset mock implementations to default (actual) behavior
5057
// This helps prevent state leakage between tests if spy.mockRestore() isn't fully effective
@@ -102,6 +109,13 @@ describe("safeWriteJson", () => {
102109

103110
// Failure Scenarios
104111
test("should handle failure when writing to tempNewFilePath", async () => {
112+
// Ensure the target file does not exist for this test.
113+
try {
114+
await fs.unlink(currentTestFilePath)
115+
} catch (e: any) {
116+
if (e.code !== "ENOENT") throw e
117+
}
118+
105119
const data = { message: "This should not be written" }
106120
const writeFileSpy = jest.spyOn(fs, "writeFile")
107121
// Make the first call to writeFile (for tempNewFilePath) fail
@@ -261,6 +275,13 @@ describe("safeWriteJson", () => {
261275
})
262276

263277
test("should handle failure when renaming tempNewFilePath to filePath (filePath does not exist)", async () => {
278+
// Ensure the target file does not exist for this test.
279+
try {
280+
await fs.unlink(currentTestFilePath)
281+
} catch (e: any) {
282+
if (e.code !== "ENOENT") throw e
283+
}
284+
264285
const data = { message: "This should not be written" }
265286
const renameSpy = jest.spyOn(fs, "rename")
266287
// The rename from tempNew to target fails
@@ -285,18 +306,30 @@ describe("safeWriteJson", () => {
285306
renameSpy.mockRestore()
286307
})
287308

288-
test("should throw an error if a lock is already held for the filePath", async () => {
309+
test("should throw an error if an inter-process lock is already held for the filePath", async () => {
310+
jest.resetModules() // Clear module cache to ensure fresh imports for this test
311+
289312
const data = { message: "test lock" }
290-
// Manually acquire lock for testing purposes
291-
activeLocks.add(path.resolve(currentTestFilePath))
313+
// Ensure the resource file exists.
314+
await fs.writeFile(currentTestFilePath, "{}", "utf8")
292315

293-
await expect(safeWriteJson(currentTestFilePath, data)).rejects.toThrow(
294-
`File operation already in progress for this path: ${path.resolve(currentTestFilePath)}`,
295-
)
316+
// Temporarily mock proper-lockfile for this test only
317+
jest.doMock("proper-lockfile", () => ({
318+
...jest.requireActual("proper-lockfile"),
319+
lock: jest.fn().mockRejectedValueOnce(new Error("Failed to get lock.")),
320+
}))
321+
322+
// Re-require safeWriteJson so it picks up the mocked proper-lockfile
323+
const { safeWriteJson: safeWriteJsonWithMockedLock } =
324+
require("../safeWriteJson") as typeof import("../safeWriteJson")
296325

297-
// Ensure lock is still there (safeWriteJson shouldn't release if it didn't acquire)
298-
expect(activeLocks.has(path.resolve(currentTestFilePath))).toBe(true)
299-
activeLocks.delete(path.resolve(currentTestFilePath)) // Manual cleanup for this test
326+
try {
327+
await expect(safeWriteJsonWithMockedLock(currentTestFilePath, data)).rejects.toThrow(
328+
/Failed to get lock.|Lock file is already being held/i,
329+
)
330+
} finally {
331+
jest.unmock("proper-lockfile") // Ensure the mock is removed after this test
332+
}
300333
})
301334
test("should release lock even if an error occurs mid-operation", async () => {
302335
const data = { message: "test lock release on error" }
@@ -306,7 +339,9 @@ describe("safeWriteJson", () => {
306339

307340
await expect(safeWriteJson(currentTestFilePath, data)).rejects.toThrow("Simulated FS Error during writeFile")
308341

309-
expect(activeLocks.has(path.resolve(currentTestFilePath))).toBe(false) // Lock should be released
342+
// Lock should be released, meaning the .lock file should not exist
343+
const lockPath = `${path.resolve(currentTestFilePath)}.lock`
344+
await expect(fs.access(lockPath)).rejects.toThrow(expect.objectContaining({ code: "ENOENT" }))
310345

311346
writeFileSpy.mockRestore()
312347
})
@@ -321,7 +356,10 @@ describe("safeWriteJson", () => {
321356

322357
await expect(safeWriteJson(currentTestFilePath, data)).rejects.toThrow("Simulated EACCES Error")
323358

324-
expect(activeLocks.has(path.resolve(currentTestFilePath))).toBe(false) // Lock should be released
359+
// Lock should be released, meaning the .lock file should not exist
360+
const lockPath = `${path.resolve(currentTestFilePath)}.lock`
361+
await expect(fs.access(lockPath)).rejects.toThrow(expect.objectContaining({ code: "ENOENT" }))
362+
325363
const tempFiles = await listTempFiles(tempTestDir, "test-data.json")
326364
// .new file might have been created before access check, should be cleaned up
327365
expect(tempFiles.filter((f: string) => f.includes(".new_")).length).toBe(0)

src/utils/safeWriteJson.ts

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import * as fs from "fs/promises"
22
import * as path from "path"
3-
4-
const activeLocks = new Set<string>()
3+
import * as lockfile from "proper-lockfile"
54

65
/**
76
* Safely writes JSON data to a file.
8-
* - Uses an in-memory advisory lock to prevent concurrent writes to the same path.
7+
* - Uses 'proper-lockfile' for inter-process advisory locking to prevent concurrent writes to the same path.
98
* - Writes to a temporary file first.
109
* - If the target file exists, it's backed up before being replaced.
1110
* - Attempts to roll back and clean up in case of errors.
@@ -21,13 +20,35 @@ async function safeWriteJson(
2120
space: string | number = 2,
2221
): Promise<void> {
2322
const absoluteFilePath = path.resolve(filePath)
23+
const lockPath = `${absoluteFilePath}.lock`
24+
let releaseLock = async () => {} // Initialized to a no-op
2425

25-
if (activeLocks.has(absoluteFilePath)) {
26-
throw new Error(`File operation already in progress for this path: ${absoluteFilePath}`)
26+
// Acquire the lock before any file operations
27+
try {
28+
releaseLock = await lockfile.lock(lockPath, {
29+
stale: 31000, // Stale after 31 seconds
30+
update: 10000, // Update mtime every 10 seconds to prevent staleness if operation is long
31+
retries: {
32+
// Configuration for retrying lock acquisition
33+
retries: 5, // Number of retries after the initial attempt
34+
factor: 2, // Exponential backoff factor (e.g., 100ms, 200ms, 400ms, ...)
35+
minTimeout: 100, // Minimum time to wait before the first retry (in ms)
36+
maxTimeout: 1000, // Maximum time to wait for any single retry (in ms)
37+
},
38+
realpath: false, // Skip realpath check as we've already resolved absoluteFilePath
39+
onCompromised: (err) => {
40+
console.error(`Lock at ${lockPath} was compromised:`, err)
41+
throw err
42+
},
43+
})
44+
} catch (lockError) {
45+
// If lock acquisition fails, we throw immediately.
46+
// The releaseLock remains a no-op, so the finally block in the main file operations
47+
// try-catch-finally won't try to release an unacquired lock if this path is taken.
48+
console.error(`Failed to acquire lock for ${lockPath}:`, lockError)
49+
throw lockError // Propagate the lock acquisition error
2750
}
2851

29-
activeLocks.add(absoluteFilePath)
30-
3152
// Variables to hold the actual paths of temp files if they are created.
3253
let actualTempNewFilePath: string | null = null
3354
let actualTempBackupFilePath: string | null = null
@@ -65,22 +86,20 @@ async function safeWriteJson(
6586

6687
// If we reach here, the new file is successfully in place.
6788
// The original actualTempNewFilePath is now the main file, so we shouldn't try to clean it up as "temp".
68-
// const _successfullyMovedNewFile = actualTempNewFilePath; // This variable is unused
6989
actualTempNewFilePath = null // Mark as "used" or "committed"
7090

7191
// Step 4: If a backup was created, attempt to delete it.
7292
if (actualTempBackupFilePath) {
7393
try {
7494
await fs.unlink(actualTempBackupFilePath)
75-
// console.log(`Successfully deleted backup file: ${actualTempBackupFilePath}`);
7695
actualTempBackupFilePath = null // Mark backup as handled
7796
} catch (unlinkBackupError) {
7897
// Log this error, but do not re-throw. The main operation was successful.
98+
// actualTempBackupFilePath remains set, indicating an orphaned backup.
7999
console.error(
80100
`Successfully wrote ${absoluteFilePath}, but failed to clean up backup ${actualTempBackupFilePath}:`,
81101
unlinkBackupError,
82102
)
83-
// actualTempBackupFilePath remains set, indicating an orphaned backup.
84103
}
85104
}
86105
} catch (originalError) {
@@ -92,30 +111,21 @@ async function safeWriteJson(
92111
// Attempt rollback if a backup was made
93112
if (backupFileToRollbackOrCleanupWithinCatch) {
94113
try {
95-
// Inner try for rollback
96-
console.log(
97-
`[Catch] Attempting to restore backup ${backupFileToRollbackOrCleanupWithinCatch} to ${absoluteFilePath}`,
98-
)
99114
await fs.rename(backupFileToRollbackOrCleanupWithinCatch, absoluteFilePath)
100-
console.log(
101-
`[Catch] Successfully restored backup ${backupFileToRollbackOrCleanupWithinCatch} to ${absoluteFilePath}.`,
102-
)
103115
actualTempBackupFilePath = null // Mark as handled, prevent later unlink of this path
104116
} catch (rollbackError) {
117+
// actualTempBackupFilePath (outer scope) remains pointing to backupFileToRollbackOrCleanupWithinCatch
105118
console.error(
106119
`[Catch] Failed to restore backup ${backupFileToRollbackOrCleanupWithinCatch} to ${absoluteFilePath}:`,
107120
rollbackError,
108121
)
109-
// actualTempBackupFilePath (outer scope) remains pointing to backupFileToRollbackOrCleanupWithinCatch
110122
}
111123
}
112124

113125
// Cleanup the .new file if it exists
114126
if (newFileToCleanupWithinCatch) {
115127
try {
116-
// Inner try for new file cleanup
117128
await fs.unlink(newFileToCleanupWithinCatch)
118-
console.log(`[Catch] Cleaned up temporary new file: ${newFileToCleanupWithinCatch}`)
119129
} catch (cleanupError) {
120130
console.error(
121131
`[Catch] Failed to clean up temporary new file ${newFileToCleanupWithinCatch}:`,
@@ -126,11 +136,8 @@ async function safeWriteJson(
126136

127137
// Cleanup the .bak file if it still needs to be (i.e., wasn't successfully restored)
128138
if (actualTempBackupFilePath) {
129-
// Checks outer scope var, which is null if rollback succeeded
130139
try {
131-
// Inner try for backup file cleanup
132140
await fs.unlink(actualTempBackupFilePath)
133-
console.log(`[Catch] Cleaned up temporary backup file: ${actualTempBackupFilePath}`)
134141
} catch (cleanupError) {
135142
console.error(
136143
`[Catch] Failed to clean up temporary backup file ${actualTempBackupFilePath}:`,
@@ -140,8 +147,16 @@ async function safeWriteJson(
140147
}
141148
throw originalError // This MUST be the error that rejects the promise.
142149
} finally {
143-
activeLocks.delete(absoluteFilePath)
150+
// Release the lock in the main finally block.
151+
try {
152+
// releaseLock will be the actual unlock function if lock was acquired,
153+
// or the initial no-op if acquisition failed.
154+
await releaseLock()
155+
} catch (unlockError) {
156+
// Do not re-throw here, as the originalError from the try/catch (if any) is more important.
157+
console.error(`Failed to release lock for ${lockPath}:`, unlockError)
158+
}
144159
}
145160
}
146161

147-
export { safeWriteJson, activeLocks } // Export activeLocks for testing lock contention
162+
export { safeWriteJson }

0 commit comments

Comments
 (0)