Skip to content

Commit 0ce9231

Browse files
authored
Merge pull request #4497 from andrew--r/feature/lockfile-handle-failure-gracefully
[node-core-library] Gracefully handle irregular LockFile.tryAcquire fail on macOS/Linux
2 parents 3c70ff2 + 37b5aa4 commit 0ce9231

File tree

6 files changed

+121
-4
lines changed

6 files changed

+121
-4
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@rushstack/node-core-library",
5+
"comment": "LockFile: prevent accidentaly deleting freshly created lockfile when multiple processes try to acquire the same lock on macOS/Linux",
6+
"type": "patch"
7+
}
8+
],
9+
"packageName": "@rushstack/node-core-library"
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@rushstack/node-core-library",
5+
"comment": "Add getStatistics() method to FileWriter instances",
6+
"type": "minor"
7+
}
8+
],
9+
"packageName": "@rushstack/node-core-library"
10+
}

common/reviews/api/node-core-library.api.md

+1
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ export type FileSystemStats = fs.Stats;
304304
export class FileWriter {
305305
close(): void;
306306
readonly filePath: string;
307+
getStatistics(): FileSystemStats;
307308
static open(filePath: string, flags?: IFileWriterFlags): FileWriter;
308309
write(text: string): void;
309310
}

libraries/node-core-library/src/FileWriter.ts

+13
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
22
// See LICENSE in the project root for license information.
33

4+
import type { FileSystemStats } from './FileSystem';
45
import { Import } from './Import';
56

67
const fsx: typeof import('fs-extra') = Import.lazy('fs-extra', require);
@@ -102,4 +103,16 @@ export class FileWriter {
102103
fsx.closeSync(fd);
103104
}
104105
}
106+
107+
/**
108+
* Gets the statistics for the given file handle. Throws if the file handle has been closed.
109+
* Behind the scenes it uses `fs.statSync()`.
110+
*/
111+
public getStatistics(): FileSystemStats {
112+
if (!this._fileDescriptor) {
113+
throw new Error(`Cannot get file statistics, file descriptor has already been released.`);
114+
}
115+
116+
return fsx.fstatSync(this._fileDescriptor);
117+
}
105118
}

libraries/node-core-library/src/LockFile.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -263,8 +263,7 @@ export class LockFile {
263263
// We should ideally maintain a dictionary of normalized acquired filenames
264264
lockFileHandle = FileWriter.open(pidLockFilePath);
265265
lockFileHandle.write(startTime);
266-
267-
const currentBirthTimeMs: number = FileSystem.getStatistics(pidLockFilePath).birthtime.getTime();
266+
const currentBirthTimeMs: number = lockFileHandle.getStatistics().birthtime.getTime();
268267

269268
let smallestBirthTimeMs: number = currentBirthTimeMs;
270269
let smallestBirthTimePid: string = pid.toString();
@@ -297,8 +296,11 @@ export class LockFile {
297296
otherPidOldStartTime = FileSystem.readFile(fileInFolderPath);
298297
// check the timestamp of the file
299298
otherBirthtimeMs = FileSystem.getStatistics(fileInFolderPath).birthtime.getTime();
300-
} catch (err) {
301-
// this means the file is probably deleted already
299+
} catch (error) {
300+
if (FileSystem.isNotExistError(error)) {
301+
// the file is already deleted by other process, skip it
302+
continue;
303+
}
302304
}
303305

304306
// if the otherPidOldStartTime is invalid, then we should look at the timestamp,

libraries/node-core-library/src/test/LockFile.test.ts

+81
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const libTestFolder: string = path.resolve(__dirname, '../../lib/test');
1616

1717
describe(LockFile.name, () => {
1818
afterEach(() => {
19+
jest.restoreAllMocks();
1920
setLockFileGetProcessStartTime(getProcessStartTime);
2021
});
2122

@@ -201,6 +202,86 @@ describe(LockFile.name, () => {
201202
// this lock should be undefined since there is an existing lock
202203
expect(lock).toBeUndefined();
203204
});
205+
206+
test('deletes other hanging lockfiles if corresponding processes are not running anymore', () => {
207+
// ensure test folder is clean
208+
const testFolder: string = path.join(libTestFolder, '4');
209+
FileSystem.ensureEmptyFolder(testFolder);
210+
211+
const resourceName: string = 'test';
212+
213+
const otherPid: number = 999999999;
214+
const otherPidInitialStartTime: string = '2012-01-02 12:53:12';
215+
216+
// simulate a hanging lockfile that was not cleaned by other process
217+
const otherPidLockFileName: string = LockFile.getLockFilePath(testFolder, resourceName, otherPid);
218+
const lockFileHandle: FileWriter = FileWriter.open(otherPidLockFileName);
219+
lockFileHandle.write(otherPidInitialStartTime);
220+
lockFileHandle.close();
221+
FileSystem.updateTimes(otherPidLockFileName, {
222+
accessedTime: 10000,
223+
modifiedTime: 10000
224+
});
225+
226+
// return undefined as if the process was not running anymore
227+
setLockFileGetProcessStartTime((pid: number) => {
228+
return pid === otherPid ? undefined : getProcessStartTime(pid);
229+
});
230+
231+
const deleteFileSpy = jest.spyOn(FileSystem, 'deleteFile');
232+
LockFile.tryAcquire(testFolder, resourceName);
233+
234+
expect(deleteFileSpy).toHaveBeenCalledTimes(1);
235+
expect(deleteFileSpy).toHaveBeenNthCalledWith(1, otherPidLockFileName);
236+
});
237+
238+
test('doesn’t attempt deleting other process lockfile if it is released in the middle of acquiring process', () => {
239+
// ensure test folder is clean
240+
const testFolder: string = path.join(libTestFolder, '5');
241+
FileSystem.ensureEmptyFolder(testFolder);
242+
243+
const resourceName: string = 'test';
244+
245+
const otherPid: number = 999999999;
246+
const otherPidStartTime: string = '2012-01-02 12:53:12';
247+
248+
const otherPidLockFileName: string = LockFile.getLockFilePath(testFolder, resourceName, otherPid);
249+
250+
// create an open lockfile for other process
251+
const lockFileHandle: FileWriter = FileWriter.open(otherPidLockFileName);
252+
lockFileHandle.write(otherPidStartTime);
253+
lockFileHandle.close();
254+
FileSystem.updateTimes(otherPidLockFileName, {
255+
accessedTime: 10000,
256+
modifiedTime: 10000
257+
});
258+
259+
// return other process start time as if it was still running
260+
setLockFileGetProcessStartTime((pid: number) => {
261+
return pid === otherPid ? otherPidStartTime : getProcessStartTime(pid);
262+
});
263+
264+
const originalReadFile = FileSystem.readFile;
265+
jest.spyOn(FileSystem, 'readFile').mockImplementation((filePath: string) => {
266+
if (filePath === otherPidLockFileName) {
267+
// simulate other process lock release right before the current process reads
268+
// other process lockfile to decide on next steps for acquiring the lock
269+
FileSystem.deleteFile(filePath);
270+
}
271+
272+
return originalReadFile(filePath);
273+
});
274+
275+
const deleteFileSpy = jest.spyOn(FileSystem, 'deleteFile');
276+
277+
LockFile.tryAcquire(testFolder, resourceName);
278+
279+
// Ensure there were no other FileSystem.deleteFile calls after our lock release simulation.
280+
// An extra attempt to delete the lockfile might lead to unexpectedly deleting a new lockfile
281+
// created by another process right after releasing/deleting the previous lockfile
282+
expect(deleteFileSpy).toHaveBeenCalledTimes(1);
283+
expect(deleteFileSpy).toHaveBeenNthCalledWith(1, otherPidLockFileName);
284+
});
204285
});
205286
}
206287

0 commit comments

Comments
 (0)