-
Notifications
You must be signed in to change notification settings - Fork 586
/
LockFile.ts
462 lines (403 loc) · 18.4 KB
/
LockFile.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
import * as path from 'path';
import * as child_process from 'child_process';
import { FileSystem } from './FileSystem';
import { FileWriter } from './FileWriter';
import { Async } from './Async';
/**
* http://man7.org/linux/man-pages/man5/proc.5.html
* (22) starttime %llu
* The time the process started after system boot. In kernels before Linux 2.6, this value was
* expressed in jiffies. Since Linux 2.6, the value is expressed in clock ticks (divide by
* sysconf(_SC_CLK_TCK)).
* The format for this field was %lu before Linux 2.6.
*/
const procStatStartTimePos: number = 22;
/**
* Parses the process start time from the contents of a linux /proc/[pid]/stat file.
* @param stat - The contents of a linux /proc/[pid]/stat file.
* @returns The process start time in jiffies, or undefined if stat has an unexpected format.
*/
export function getProcessStartTimeFromProcStat(stat: string): string | undefined {
// Parse the value at position procStatStartTimePos.
// We cannot just split stat on spaces, because value 2 may contain spaces.
// For example, when running the following Shell commands:
// > cp "$(which bash)" ./'bash 2)('
// > ./'bash 2)(' -c 'OWNPID=$BASHPID;cat /proc/$OWNPID/stat'
// 59389 (bash 2)() S 59358 59389 59358 34818 59389 4202496 329 0 0 0 0 0 0 0 20 0 1 0
// > rm -rf ./'bash 2)('
// The output shows a stat file such that value 2 contains spaces.
// To still umambiguously parse such output we assume no values after the second ends with a right parenthesis...
// trimRight to remove the trailing line terminator.
let values: string[] = stat.trimRight().split(' ');
let i: number = values.length - 1;
while (
i >= 0 &&
// charAt returns an empty string if the index is out of bounds.
values[i].charAt(values[i].length - 1) !== ')'
) {
i -= 1;
}
// i is the index of the last part of the second value (but i need not be 1).
if (i < 1) {
// Format of stat has changed.
return undefined;
}
const value2: string = values.slice(1, i + 1).join(' ');
values = [values[0], value2].concat(values.slice(i + 1));
if (values.length < procStatStartTimePos) {
// Older version of linux, or non-standard configuration of linux.
return undefined;
}
const startTimeJiffies: string = values[procStatStartTimePos - 1];
// In theory, the representations of start time returned by `cat /proc/[pid]/stat` and `ps -o lstart` can change
// while the system is running, but we assume this does not happen.
// So the caller can safely use this value as part of a unique process id (on the machine, without comparing
// accross reboots).
return startTimeJiffies;
}
/**
* Helper function that is exported for unit tests only.
* Returns undefined if the process doesn't exist with that pid.
*/
export function getProcessStartTime(pid: number): string | undefined {
const pidString: string = pid.toString();
if (pid < 0 || pidString.indexOf('e') >= 0 || pidString.indexOf('E') >= 0) {
throw new Error(`"pid" is negative or too large`);
}
let args: string[];
if (process.platform === 'darwin') {
args = [`-p ${pidString}`, '-o lstart'];
} else if (process.platform === 'linux') {
args = ['-p', pidString, '-o', 'lstart'];
} else {
throw new Error(`Unsupported system: ${process.platform}`);
}
const psResult: child_process.SpawnSyncReturns<string> = child_process.spawnSync('ps', args, {
encoding: 'utf8'
});
const psStdout: string = psResult.stdout;
// If no process with PID pid exists then the exit code is non-zero on linux but stdout is not empty.
// But if no process exists we do not want to fall back on /proc/*/stat to determine the process
// start time, so we we additionally test for !psStdout. NOTE: !psStdout evaluates to true if
// zero bytes are written to stdout.
if (psResult.status !== 0 && !psStdout && process.platform === 'linux') {
// Try to read /proc/[pid]/stat and get the value at position procStatStartTimePos.
let stat: undefined | string;
try {
stat = FileSystem.readFile(`/proc/${pidString}/stat`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
// Either no process with PID pid exists, or this version/configuration of linux is non-standard.
// We assume the former.
return undefined;
}
if (stat !== undefined) {
const startTimeJiffies: string | undefined = getProcessStartTimeFromProcStat(stat);
if (startTimeJiffies === undefined) {
throw new Error(
`Could not retrieve the start time of process ${pidString} from the OS because the ` +
`contents of /proc/${pidString}/stat have an unexpected format`
);
}
return startTimeJiffies;
}
}
// there was an error executing ps (zero bytes were written to stdout).
if (!psStdout) {
throw new Error(`Unexpected output from "ps" command`);
}
const psSplit: string[] = psStdout.split('\n');
// successfuly able to run "ps", but no process was found
if (psSplit[1] === '') {
return undefined;
}
if (psSplit[1]) {
const trimmed: string = psSplit[1].trim();
if (trimmed.length > 10) {
return trimmed;
}
}
throw new Error(`Unexpected output from the "ps" command`);
}
/**
* The `LockFile` implements a file-based mutex for synchronizing access to a shared resource
* between multiple Node.js processes. It is not recommended for synchronization solely within
* a single Node.js process.
* @remarks
* The implementation works on Windows, Mac, and Linux without requiring any native helpers.
* On non-Windows systems, the algorithm requires access to the `ps` shell command. On Linux,
* it requires access the `/proc/${pidString}/stat` filesystem.
* @public
*/
export class LockFile {
private static _getStartTime: (pid: number) => string | undefined = getProcessStartTime;
private _fileWriter: FileWriter | undefined;
private _filePath: string;
private _dirtyWhenAcquired: boolean;
private constructor(fileWriter: FileWriter | undefined, filePath: string, dirtyWhenAcquired: boolean) {
this._fileWriter = fileWriter;
this._filePath = filePath;
this._dirtyWhenAcquired = dirtyWhenAcquired;
}
/**
* Returns the path of the lockfile that will be created when a lock is successfully acquired.
* @param resourceFolder - The folder where the lock file will be created
* @param resourceName - An alphanumeric name that describes the resource being locked. This will become
* the filename of the temporary file created to manage the lock.
* @param pid - The PID for the current Node.js process (`process.pid`), which is used by the locking algorithm.
*/
public static getLockFilePath(
resourceFolder: string,
resourceName: string,
pid: number = process.pid
): string {
if (!resourceName.match(/^[a-zA-Z0-9][a-zA-Z0-9-.]+[a-zA-Z0-9]$/)) {
throw new Error(
`The resource name "${resourceName}" is invalid.` +
` It must be an alphanumberic string with only "-" or "." It must start with an alphanumeric character.`
);
}
if (process.platform === 'win32') {
return path.join(path.resolve(resourceFolder), `${resourceName}.lock`);
} else if (process.platform === 'linux' || process.platform === 'darwin') {
return path.join(path.resolve(resourceFolder), `${resourceName}#${pid}.lock`);
}
throw new Error(`File locking not implemented for platform: "${process.platform}"`);
}
/**
* Attempts to create a lockfile with the given filePath.
* @param resourceFolder - The folder where the lock file will be created
* @param resourceName - An alphanumeric name that describes the resource being locked. This will become
* the filename of the temporary file created to manage the lock.
* @returns If successful, returns a `LockFile` instance. If unable to get a lock, returns `undefined`.
*/
public static tryAcquire(resourceFolder: string, resourceName: string): LockFile | undefined {
FileSystem.ensureFolder(resourceFolder);
if (process.platform === 'win32') {
return LockFile._tryAcquireWindows(resourceFolder, resourceName);
} else if (process.platform === 'linux' || process.platform === 'darwin') {
return LockFile._tryAcquireMacOrLinux(resourceFolder, resourceName);
}
throw new Error(`File locking not implemented for platform: "${process.platform}"`);
}
/**
* Attempts to create the lockfile. Will continue to loop at every 100ms until the lock becomes available
* or the maxWaitMs is surpassed.
*
* @remarks
* This function is subject to starvation, whereby it does not ensure that the process that has been
* waiting the longest to acquire the lock will get it first. This means that a process could theoretically
* wait for the lock forever, while other processes skipped it in line and acquired the lock first.
*
* @param resourceFolder - The folder where the lock file will be created
* @param resourceName - An alphanumeric name that describes the resource being locked. This will become
* the filename of the temporary file created to manage the lock.
* @param maxWaitMs - The maximum number of milliseconds to wait for the lock before reporting an error
*/
public static acquire(resourceFolder: string, resourceName: string, maxWaitMs?: number): Promise<LockFile> {
const interval: number = 100;
const startTime: number = Date.now();
const retryLoop: () => Promise<LockFile> = async () => {
const lock: LockFile | undefined = LockFile.tryAcquire(resourceFolder, resourceName);
if (lock) {
return lock;
}
if (maxWaitMs && Date.now() > startTime + maxWaitMs) {
throw new Error(`Exceeded maximum wait time to acquire lock for resource "${resourceName}"`);
}
await Async.sleep(interval);
return retryLoop();
};
return retryLoop();
}
/**
* Attempts to acquire the lock on a Linux or OSX machine
*/
private static _tryAcquireMacOrLinux(resourceFolder: string, resourceName: string): LockFile | undefined {
let dirtyWhenAcquired: boolean = false;
// get the current process' pid
const pid: number = process.pid;
const startTime: string | undefined = LockFile._getStartTime(pid);
if (!startTime) {
throw new Error(`Unable to calculate start time for current process.`);
}
const pidLockFilePath: string = LockFile.getLockFilePath(resourceFolder, resourceName);
let lockFileHandle: FileWriter | undefined;
let lockFile: LockFile;
try {
// open in write mode since if this file exists, it cannot be from the current process
// TODO: This will malfunction if the same process tries to acquire two locks on the same file.
// We should ideally maintain a dictionary of normalized acquired filenames
lockFileHandle = FileWriter.open(pidLockFilePath);
lockFileHandle.write(startTime);
const currentBirthTimeMs: number = FileSystem.getStatistics(pidLockFilePath).birthtime.getTime();
let smallestBirthTimeMs: number = currentBirthTimeMs;
let smallestBirthTimePid: string = pid.toString();
// now, scan the directory for all lockfiles
const files: string[] = FileSystem.readFolderItemNames(resourceFolder);
// look for anything ending with # then numbers and ".lock"
const lockFileRegExp: RegExp = /^(.+)#([0-9]+)\.lock$/;
let match: RegExpMatchArray | null;
let otherPid: string;
for (const fileInFolder of files) {
if (
(match = fileInFolder.match(lockFileRegExp)) &&
match[1] === resourceName &&
(otherPid = match[2]) !== pid.toString()
) {
// we found at least one lockfile hanging around that isn't ours
const fileInFolderPath: string = path.join(resourceFolder, fileInFolder);
dirtyWhenAcquired = true;
// console.log(`FOUND OTHER LOCKFILE: ${otherPid}`);
const otherPidCurrentStartTime: string | undefined = LockFile._getStartTime(parseInt(otherPid, 10));
let otherPidOldStartTime: string | undefined;
let otherBirthtimeMs: number | undefined;
try {
otherPidOldStartTime = FileSystem.readFile(fileInFolderPath);
// check the timestamp of the file
otherBirthtimeMs = FileSystem.getStatistics(fileInFolderPath).birthtime.getTime();
} catch (err) {
// this means the file is probably deleted already
}
// if the otherPidOldStartTime is invalid, then we should look at the timestamp,
// if this file was created after us, ignore it
// if it was created within 1 second before us, then it could be good, so we
// will conservatively fail
// otherwise it is an old lock file and will be deleted
if (otherPidOldStartTime === '' && otherBirthtimeMs !== undefined) {
if (otherBirthtimeMs > currentBirthTimeMs) {
// ignore this file, he will be unable to get the lock since this process
// will hold it
// console.log(`Ignoring lock for pid ${otherPid} because its lockfile is newer than ours.`);
continue;
} else if (
otherBirthtimeMs - currentBirthTimeMs < 0 && // it was created before us AND
otherBirthtimeMs - currentBirthTimeMs > -1000
) {
// it was created less than a second before
// conservatively be unable to keep the lock
return undefined;
}
}
// console.log(`Other pid ${otherPid} lockfile has start time: "${otherPidOldStartTime}"`);
// console.log(`Other pid ${otherPid} actually has start time: "${otherPidCurrentStartTime}"`);
// this means the process is no longer executing, delete the file
if (!otherPidCurrentStartTime || otherPidOldStartTime !== otherPidCurrentStartTime) {
// console.log(`Other pid ${otherPid} is no longer executing!`);
FileSystem.deleteFile(fileInFolderPath);
continue;
}
// console.log(`Pid ${otherPid} lockfile has birth time: ${otherBirthtimeMs}`);
// console.log(`Pid ${pid} lockfile has birth time: ${currentBirthTimeMs}`);
// this is a lockfile pointing at something valid
if (otherBirthtimeMs !== undefined) {
// the other lock file was created before the current earliest lock file
// or the other lock file was created at the same exact time, but has earlier pid
// note that it is acceptable to do a direct comparison of the PIDs in this case
// since we are establishing a consistent order to apply to the lock files in all
// execution instances.
// it doesn't matter that the PIDs roll over, we've already
// established that these processes all started at the same time, so we just
// need to get all instances of the lock test to agree which one won.
if (
otherBirthtimeMs < smallestBirthTimeMs ||
(otherBirthtimeMs === smallestBirthTimeMs && otherPid < smallestBirthTimePid)
) {
smallestBirthTimeMs = otherBirthtimeMs;
smallestBirthTimePid = otherPid;
}
}
}
}
if (smallestBirthTimePid !== pid.toString()) {
// we do not have the lock
return undefined;
}
// we have the lock!
lockFile = new LockFile(lockFileHandle, pidLockFilePath, dirtyWhenAcquired);
lockFileHandle = undefined; // we have handed the descriptor off to the instance
} finally {
if (lockFileHandle) {
// ensure our lock is closed
lockFileHandle.close();
FileSystem.deleteFile(pidLockFilePath);
}
}
return lockFile;
}
/**
* Attempts to acquire the lock using Windows
* This algorithm is much simpler since we can rely on the operating system
*/
private static _tryAcquireWindows(resourceFolder: string, resourceName: string): LockFile | undefined {
const lockFilePath: string = LockFile.getLockFilePath(resourceFolder, resourceName);
let dirtyWhenAcquired: boolean = false;
let fileHandle: FileWriter | undefined;
let lockFile: LockFile;
try {
if (FileSystem.exists(lockFilePath)) {
dirtyWhenAcquired = true;
// If the lockfile is held by an process with an exclusive lock, then removing it will
// silently fail. OpenSync() below will then fail and we will be unable to create a lock.
// Otherwise, the lockfile is sitting on disk, but nothing is holding it, implying that
// the last process to hold it died.
FileSystem.deleteFile(lockFilePath);
}
try {
// Attempt to open an exclusive lockfile
fileHandle = FileWriter.open(lockFilePath, { exclusive: true });
} catch (error) {
// we tried to delete the lock, but something else is holding it,
// (probably an active process), therefore we are unable to create a lock
return undefined;
}
// Ensure we can hand off the file descriptor to the lockfile
lockFile = new LockFile(fileHandle, lockFilePath, dirtyWhenAcquired);
fileHandle = undefined;
} finally {
if (fileHandle) {
fileHandle.close();
}
}
return lockFile;
}
/**
* Unlocks a file and optionally removes it from disk.
* This can only be called once.
*
* @param deleteFile - Whether to delete the lockfile from disk. Defaults to true.
*/
public release(deleteFile: boolean = true): void {
if (this.isReleased) {
throw new Error(`The lock for file "${path.basename(this._filePath)}" has already been released.`);
}
this._fileWriter!.close();
if (deleteFile) {
FileSystem.deleteFile(this._filePath);
}
this._fileWriter = undefined;
}
/**
* Returns the initial state of the lock.
* This can be used to detect if the previous process was terminated before releasing the resource.
*/
public get dirtyWhenAcquired(): boolean {
return this._dirtyWhenAcquired;
}
/**
* Returns the absolute path to the lockfile
*/
public get filePath(): string {
return this._filePath;
}
/**
* Returns true if this lock is currently being held.
*/
public get isReleased(): boolean {
return this._fileWriter === undefined;
}
}