424 lines
20 KiB
JavaScript
424 lines
20 KiB
JavaScript
|
"use strict";
|
||
|
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||
|
// See LICENSE in the project root for license information.
|
||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||
|
if (k2 === undefined) k2 = k;
|
||
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
||
|
}
|
||
|
Object.defineProperty(o, k2, desc);
|
||
|
}) : (function(o, m, k, k2) {
|
||
|
if (k2 === undefined) k2 = k;
|
||
|
o[k2] = m[k];
|
||
|
}));
|
||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||
|
}) : function(o, v) {
|
||
|
o["default"] = v;
|
||
|
});
|
||
|
var __importStar = (this && this.__importStar) || function (mod) {
|
||
|
if (mod && mod.__esModule) return mod;
|
||
|
var result = {};
|
||
|
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||
|
__setModuleDefault(result, mod);
|
||
|
return result;
|
||
|
};
|
||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||
|
exports.LockFile = exports.getProcessStartTime = exports.getProcessStartTimeFromProcStat = void 0;
|
||
|
const path = __importStar(require("path"));
|
||
|
const child_process = __importStar(require("child_process"));
|
||
|
const FileSystem_1 = require("./FileSystem");
|
||
|
const FileWriter_1 = require("./FileWriter");
|
||
|
const Async_1 = require("./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 = 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.
|
||
|
*/
|
||
|
function getProcessStartTimeFromProcStat(stat) {
|
||
|
// 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 = stat.trimRight().split(' ');
|
||
|
let i = 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 = 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 = 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;
|
||
|
}
|
||
|
exports.getProcessStartTimeFromProcStat = getProcessStartTimeFromProcStat;
|
||
|
/**
|
||
|
* Helper function that is exported for unit tests only.
|
||
|
* Returns undefined if the process doesn't exist with that pid.
|
||
|
*/
|
||
|
function getProcessStartTime(pid) {
|
||
|
const pidString = pid.toString();
|
||
|
if (pid < 0 || pidString.indexOf('e') >= 0 || pidString.indexOf('E') >= 0) {
|
||
|
throw new Error(`"pid" is negative or too large`);
|
||
|
}
|
||
|
let args;
|
||
|
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.spawnSync('ps', args, {
|
||
|
encoding: 'utf8'
|
||
|
});
|
||
|
const psStdout = 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;
|
||
|
try {
|
||
|
stat = FileSystem_1.FileSystem.readFile(`/proc/${pidString}/stat`);
|
||
|
}
|
||
|
catch (error) {
|
||
|
if (error.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 = 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 = psStdout.split('\n');
|
||
|
// successfuly able to run "ps", but no process was found
|
||
|
if (psSplit[1] === '') {
|
||
|
return undefined;
|
||
|
}
|
||
|
if (psSplit[1]) {
|
||
|
const trimmed = psSplit[1].trim();
|
||
|
if (trimmed.length > 10) {
|
||
|
return trimmed;
|
||
|
}
|
||
|
}
|
||
|
throw new Error(`Unexpected output from the "ps" command`);
|
||
|
}
|
||
|
exports.getProcessStartTime = getProcessStartTime;
|
||
|
/**
|
||
|
* 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
|
||
|
*/
|
||
|
class LockFile {
|
||
|
constructor(fileWriter, filePath, dirtyWhenAcquired) {
|
||
|
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.
|
||
|
*/
|
||
|
static getLockFilePath(resourceFolder, resourceName, pid = process.pid) {
|
||
|
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`.
|
||
|
*/
|
||
|
static tryAcquire(resourceFolder, resourceName) {
|
||
|
FileSystem_1.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
|
||
|
*/
|
||
|
static acquire(resourceFolder, resourceName, maxWaitMs) {
|
||
|
const interval = 100;
|
||
|
const startTime = Date.now();
|
||
|
const retryLoop = async () => {
|
||
|
const lock = 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_1.Async.sleep(interval);
|
||
|
return retryLoop();
|
||
|
};
|
||
|
return retryLoop();
|
||
|
}
|
||
|
/**
|
||
|
* Attempts to acquire the lock on a Linux or OSX machine
|
||
|
*/
|
||
|
static _tryAcquireMacOrLinux(resourceFolder, resourceName) {
|
||
|
let dirtyWhenAcquired = false;
|
||
|
// get the current process' pid
|
||
|
const pid = process.pid;
|
||
|
const startTime = LockFile._getStartTime(pid);
|
||
|
if (!startTime) {
|
||
|
throw new Error(`Unable to calculate start time for current process.`);
|
||
|
}
|
||
|
const pidLockFilePath = LockFile.getLockFilePath(resourceFolder, resourceName);
|
||
|
let lockFileHandle;
|
||
|
let 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_1.FileWriter.open(pidLockFilePath);
|
||
|
lockFileHandle.write(startTime);
|
||
|
const currentBirthTimeMs = FileSystem_1.FileSystem.getStatistics(pidLockFilePath).birthtime.getTime();
|
||
|
let smallestBirthTimeMs = currentBirthTimeMs;
|
||
|
let smallestBirthTimePid = pid.toString();
|
||
|
// now, scan the directory for all lockfiles
|
||
|
const files = FileSystem_1.FileSystem.readFolderItemNames(resourceFolder);
|
||
|
// look for anything ending with # then numbers and ".lock"
|
||
|
const lockFileRegExp = /^(.+)#([0-9]+)\.lock$/;
|
||
|
let match;
|
||
|
let otherPid;
|
||
|
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 = path.join(resourceFolder, fileInFolder);
|
||
|
dirtyWhenAcquired = true;
|
||
|
// console.log(`FOUND OTHER LOCKFILE: ${otherPid}`);
|
||
|
const otherPidCurrentStartTime = LockFile._getStartTime(parseInt(otherPid, 10));
|
||
|
let otherPidOldStartTime;
|
||
|
let otherBirthtimeMs;
|
||
|
try {
|
||
|
otherPidOldStartTime = FileSystem_1.FileSystem.readFile(fileInFolderPath);
|
||
|
// check the timestamp of the file
|
||
|
otherBirthtimeMs = FileSystem_1.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_1.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_1.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
|
||
|
*/
|
||
|
static _tryAcquireWindows(resourceFolder, resourceName) {
|
||
|
const lockFilePath = LockFile.getLockFilePath(resourceFolder, resourceName);
|
||
|
let dirtyWhenAcquired = false;
|
||
|
let fileHandle;
|
||
|
let lockFile;
|
||
|
try {
|
||
|
if (FileSystem_1.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_1.FileSystem.deleteFile(lockFilePath);
|
||
|
}
|
||
|
try {
|
||
|
// Attempt to open an exclusive lockfile
|
||
|
fileHandle = FileWriter_1.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.
|
||
|
*/
|
||
|
release(deleteFile = true) {
|
||
|
if (this.isReleased) {
|
||
|
throw new Error(`The lock for file "${path.basename(this._filePath)}" has already been released.`);
|
||
|
}
|
||
|
this._fileWriter.close();
|
||
|
if (deleteFile) {
|
||
|
FileSystem_1.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.
|
||
|
*/
|
||
|
get dirtyWhenAcquired() {
|
||
|
return this._dirtyWhenAcquired;
|
||
|
}
|
||
|
/**
|
||
|
* Returns the absolute path to the lockfile
|
||
|
*/
|
||
|
get filePath() {
|
||
|
return this._filePath;
|
||
|
}
|
||
|
/**
|
||
|
* Returns true if this lock is currently being held.
|
||
|
*/
|
||
|
get isReleased() {
|
||
|
return this._fileWriter === undefined;
|
||
|
}
|
||
|
}
|
||
|
LockFile._getStartTime = getProcessStartTime;
|
||
|
exports.LockFile = LockFile;
|
||
|
//# sourceMappingURL=LockFile.js.map
|