mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-31 02:17:01 -04:00 
			
		
		
		
	
		
			
				
	
	
		
			343 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			343 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| '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;
 |