"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