'use strict'; const path = require('path'); const fs = require('graceful-fs'); const retry = require('../../retry'); const onExit = require('../../signalExit'); const mtimePrecision = require('./mtime-precision'); const locks = {}; function getLockFile(file, options) { return options.lockfilePath || `${file}.lock`; } function resolveCanonicalPath(file, options, callback) { if (!options.realpath) { return callback(null, path.resolve(file)); } // Use realpath to resolve symlinks // It also resolves relative paths options.fs.realpath(file, callback); } function acquireLock(file, options, callback) { const lockfilePath = getLockFile(file, options); // Use mkdir to create the lockfile (atomic operation) options.fs.mkdir(lockfilePath, (err) => { if (!err) { // At this point, we acquired the lock! // Probe the mtime precision return mtimePrecision.probe(lockfilePath, options.fs, (err, mtime, mtimePrecision) => { // If it failed, try to remove the lock.. /* istanbul ignore if */ if (err) { options.fs.rmdir(lockfilePath, () => { }); return callback(err); } callback(null, mtime, mtimePrecision); }); } // If error is not EEXIST then some other error occurred while locking if (err.code !== 'EEXIST') { return callback(err); } // Otherwise, check if lock is stale by analyzing the file mtime if (options.stale <= 0) { return callback(Object.assign(new Error('Lock file is already being held'), { code: 'ELOCKED', file })); } options.fs.stat(lockfilePath, (err, stat) => { if (err) { // Retry if the lockfile has been removed (meanwhile) // Skip stale check to avoid recursiveness if (err.code === 'ENOENT') { return acquireLock(file, { ...options, stale: 0 }, callback); } return callback(err); } if (!isLockStale(stat, options)) { return callback(Object.assign(new Error('Lock file is already being held'), { code: 'ELOCKED', file })); } // If it's stale, remove it and try again! // Skip stale check to avoid recursiveness removeLock(file, options, (err) => { if (err) { return callback(err); } acquireLock(file, { ...options, stale: 0 }, callback); }); }); }); } function isLockStale(stat, options) { return stat.mtime.getTime() < Date.now() - options.stale; } function removeLock(file, options, callback) { // Remove lockfile, ignoring ENOENT errors options.fs.rmdir(getLockFile(file, options), (err) => { if (err && err.code !== 'ENOENT') { return callback(err); } callback(); }); } function updateLock(file, options) { const lock = locks[file]; // Just for safety, should never happen /* istanbul ignore if */ if (lock.updateTimeout) { return; } lock.updateDelay = lock.updateDelay || options.update; lock.updateTimeout = setTimeout(() => { lock.updateTimeout = null; // Stat the file to check if mtime is still ours // If it is, we can still recover from a system sleep or a busy event loop options.fs.stat(lock.lockfilePath, (err, stat) => { const isOverThreshold = lock.lastUpdate + options.stale < Date.now(); // If it failed to update the lockfile, keep trying unless // the lockfile was deleted or we are over the threshold if (err) { if (err.code === 'ENOENT' || isOverThreshold) { return setLockAsCompromised(file, lock, Object.assign(err, { code: 'ECOMPROMISED' })); } lock.updateDelay = 1000; return updateLock(file, options); } const isMtimeOurs = lock.mtime.getTime() === stat.mtime.getTime(); if (!isMtimeOurs) { return setLockAsCompromised( file, lock, Object.assign( new Error('Unable to update lock within the stale threshold'), { code: 'ECOMPROMISED' } )); } const mtime = mtimePrecision.getMtime(lock.mtimePrecision); options.fs.utimes(lock.lockfilePath, mtime, mtime, (err) => { const isOverThreshold = lock.lastUpdate + options.stale < Date.now(); // Ignore if the lock was released if (lock.released) { return; } // If it failed to update the lockfile, keep trying unless // the lockfile was deleted or we are over the threshold if (err) { if (err.code === 'ENOENT' || isOverThreshold) { return setLockAsCompromised(file, lock, Object.assign(err, { code: 'ECOMPROMISED' })); } lock.updateDelay = 1000; return updateLock(file, options); } // All ok, keep updating.. lock.mtime = mtime; lock.lastUpdate = Date.now(); lock.updateDelay = null; updateLock(file, options); }); }); }, lock.updateDelay); // Unref the timer so that the nodejs process can exit freely // This is safe because all acquired locks will be automatically released // on process exit // We first check that `lock.updateTimeout.unref` exists because some users // may be using this module outside of NodeJS (e.g., in an electron app), // and in those cases `setTimeout` return an integer. /* istanbul ignore else */ if (lock.updateTimeout.unref) { lock.updateTimeout.unref(); } } function setLockAsCompromised(file, lock, err) { // Signal the lock has been released lock.released = true; // Cancel lock mtime update // Just for safety, at this point updateTimeout should be null /* istanbul ignore if */ if (lock.updateTimeout) { clearTimeout(lock.updateTimeout); } if (locks[file] === lock) { delete locks[file]; } lock.options.onCompromised(err); } // ---------------------------------------------------------- function lock(file, options, callback) { /* istanbul ignore next */ options = { stale: 10000, update: null, realpath: true, retries: 0, fs, onCompromised: (err) => { throw err; }, ...options, }; options.retries = options.retries || 0; options.retries = typeof options.retries === 'number' ? { retries: options.retries } : options.retries; options.stale = Math.max(options.stale || 0, 2000); options.update = options.update == null ? options.stale / 2 : options.update || 0; options.update = Math.max(Math.min(options.update, options.stale / 2), 1000); // Resolve to a canonical file path resolveCanonicalPath(file, options, (err, file) => { if (err) { return callback(err); } // Attempt to acquire the lock const operation = retry.operation(options.retries); operation.attempt(() => { acquireLock(file, options, (err, mtime, mtimePrecision) => { if (operation.retry(err)) { return; } if (err) { return callback(operation.mainError()); } // We now own the lock const lock = locks[file] = { lockfilePath: getLockFile(file, options), mtime, mtimePrecision, options, lastUpdate: Date.now(), }; // We must keep the lock fresh to avoid staleness updateLock(file, options); callback(null, (releasedCallback) => { if (lock.released) { return releasedCallback && releasedCallback(Object.assign(new Error('Lock is already released'), { code: 'ERELEASED' })); } // Not necessary to use realpath twice when unlocking unlock(file, { ...options, realpath: false }, releasedCallback); }); }); }); }); } function unlock(file, options, callback) { options = { fs, realpath: true, ...options, }; // Resolve to a canonical file path resolveCanonicalPath(file, options, (err, file) => { if (err) { return callback(err); } // Skip if the lock is not acquired const lock = locks[file]; if (!lock) { return callback(Object.assign(new Error('Lock is not acquired/owned by you'), { code: 'ENOTACQUIRED' })); } lock.updateTimeout && clearTimeout(lock.updateTimeout); // Cancel lock mtime update lock.released = true; // Signal the lock has been released delete locks[file]; // Delete from locks removeLock(file, options, callback); }); } function check(file, options, callback) { options = { stale: 10000, realpath: true, fs, ...options, }; options.stale = Math.max(options.stale || 0, 2000); // Resolve to a canonical file path resolveCanonicalPath(file, options, (err, file) => { if (err) { return callback(err); } // Check if lockfile exists options.fs.stat(getLockFile(file, options), (err, stat) => { if (err) { // If does not exist, file is not locked. Otherwise, callback with error return err.code === 'ENOENT' ? callback(null, false) : callback(err); } // Otherwise, check if lock is stale by analyzing the file mtime return callback(null, !isLockStale(stat, options)); }); }); } function getLocks() { return locks; } // Remove acquired locks on exit /* istanbul ignore next */ onExit(() => { for (const file in locks) { const options = locks[file].options; try { options.fs.rmdirSync(getLockFile(file, options)); } catch (e) { /* Empty */ } } }); module.exports.lock = lock; module.exports.unlock = unlock; module.exports.check = check; module.exports.getLocks = getLocks;