lockfile.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. 'use strict';
  2. const path = require('path');
  3. const fs = require('graceful-fs');
  4. const retry = require('retry');
  5. const onExit = require('signal-exit');
  6. const mtimePrecision = require('./mtime-precision');
  7. const locks = {};
  8. function getLockFile(file, options) {
  9. return options.lockfilePath || `${file}.lock`;
  10. }
  11. function resolveCanonicalPath(file, options, callback) {
  12. if (!options.realpath) {
  13. return callback(null, path.resolve(file));
  14. }
  15. // Use realpath to resolve symlinks
  16. // It also resolves relative paths
  17. options.fs.realpath(file, callback);
  18. }
  19. function acquireLock(file, options, callback) {
  20. const lockfilePath = getLockFile(file, options);
  21. // Use mkdir to create the lockfile (atomic operation)
  22. options.fs.mkdir(lockfilePath, (err) => {
  23. if (!err) {
  24. // At this point, we acquired the lock!
  25. // Probe the mtime precision
  26. return mtimePrecision.probe(lockfilePath, options.fs, (err, mtime, mtimePrecision) => {
  27. // If it failed, try to remove the lock..
  28. /* istanbul ignore if */
  29. if (err) {
  30. options.fs.rmdir(lockfilePath, () => {});
  31. return callback(err);
  32. }
  33. callback(null, mtime, mtimePrecision);
  34. });
  35. }
  36. // If error is not EEXIST then some other error occurred while locking
  37. if (err.code !== 'EEXIST') {
  38. return callback(err);
  39. }
  40. // Otherwise, check if lock is stale by analyzing the file mtime
  41. if (options.stale <= 0) {
  42. return callback(Object.assign(new Error('Lock file is already being held'), { code: 'ELOCKED', file }));
  43. }
  44. options.fs.stat(lockfilePath, (err, stat) => {
  45. if (err) {
  46. // Retry if the lockfile has been removed (meanwhile)
  47. // Skip stale check to avoid recursiveness
  48. if (err.code === 'ENOENT') {
  49. return acquireLock(file, { ...options, stale: 0 }, callback);
  50. }
  51. return callback(err);
  52. }
  53. if (!isLockStale(stat, options)) {
  54. return callback(Object.assign(new Error('Lock file is already being held'), { code: 'ELOCKED', file }));
  55. }
  56. // If it's stale, remove it and try again!
  57. // Skip stale check to avoid recursiveness
  58. removeLock(file, options, (err) => {
  59. if (err) {
  60. return callback(err);
  61. }
  62. acquireLock(file, { ...options, stale: 0 }, callback);
  63. });
  64. });
  65. });
  66. }
  67. function isLockStale(stat, options) {
  68. return stat.mtime.getTime() < Date.now() - options.stale;
  69. }
  70. function removeLock(file, options, callback) {
  71. // Remove lockfile, ignoring ENOENT errors
  72. options.fs.rmdir(getLockFile(file, options), (err) => {
  73. if (err && err.code !== 'ENOENT') {
  74. return callback(err);
  75. }
  76. callback();
  77. });
  78. }
  79. function updateLock(file, options) {
  80. const lock = locks[file];
  81. // Just for safety, should never happen
  82. /* istanbul ignore if */
  83. if (lock.updateTimeout) {
  84. return;
  85. }
  86. lock.updateDelay = lock.updateDelay || options.update;
  87. lock.updateTimeout = setTimeout(() => {
  88. lock.updateTimeout = null;
  89. // Stat the file to check if mtime is still ours
  90. // If it is, we can still recover from a system sleep or a busy event loop
  91. options.fs.stat(lock.lockfilePath, (err, stat) => {
  92. const isOverThreshold = lock.lastUpdate + options.stale < Date.now();
  93. // If it failed to update the lockfile, keep trying unless
  94. // the lockfile was deleted or we are over the threshold
  95. if (err) {
  96. if (err.code === 'ENOENT' || isOverThreshold) {
  97. return setLockAsCompromised(file, lock, Object.assign(err, { code: 'ECOMPROMISED' }));
  98. }
  99. lock.updateDelay = 1000;
  100. return updateLock(file, options);
  101. }
  102. const isMtimeOurs = lock.mtime.getTime() === stat.mtime.getTime();
  103. if (!isMtimeOurs) {
  104. return setLockAsCompromised(
  105. file,
  106. lock,
  107. Object.assign(
  108. new Error('Unable to update lock within the stale threshold'),
  109. { code: 'ECOMPROMISED' }
  110. ));
  111. }
  112. const mtime = mtimePrecision.getMtime(lock.mtimePrecision);
  113. options.fs.utimes(lock.lockfilePath, mtime, mtime, (err) => {
  114. const isOverThreshold = lock.lastUpdate + options.stale < Date.now();
  115. // Ignore if the lock was released
  116. if (lock.released) {
  117. return;
  118. }
  119. // If it failed to update the lockfile, keep trying unless
  120. // the lockfile was deleted or we are over the threshold
  121. if (err) {
  122. if (err.code === 'ENOENT' || isOverThreshold) {
  123. return setLockAsCompromised(file, lock, Object.assign(err, { code: 'ECOMPROMISED' }));
  124. }
  125. lock.updateDelay = 1000;
  126. return updateLock(file, options);
  127. }
  128. // All ok, keep updating..
  129. lock.mtime = mtime;
  130. lock.lastUpdate = Date.now();
  131. lock.updateDelay = null;
  132. updateLock(file, options);
  133. });
  134. });
  135. }, lock.updateDelay);
  136. // Unref the timer so that the nodejs process can exit freely
  137. // This is safe because all acquired locks will be automatically released
  138. // on process exit
  139. // We first check that `lock.updateTimeout.unref` exists because some users
  140. // may be using this module outside of NodeJS (e.g., in an electron app),
  141. // and in those cases `setTimeout` return an integer.
  142. /* istanbul ignore else */
  143. if (lock.updateTimeout.unref) {
  144. lock.updateTimeout.unref();
  145. }
  146. }
  147. function setLockAsCompromised(file, lock, err) {
  148. // Signal the lock has been released
  149. lock.released = true;
  150. // Cancel lock mtime update
  151. // Just for safety, at this point updateTimeout should be null
  152. /* istanbul ignore if */
  153. if (lock.updateTimeout) {
  154. clearTimeout(lock.updateTimeout);
  155. }
  156. if (locks[file] === lock) {
  157. delete locks[file];
  158. }
  159. lock.options.onCompromised(err);
  160. }
  161. // ----------------------------------------------------------
  162. function lock(file, options, callback) {
  163. /* istanbul ignore next */
  164. options = {
  165. stale: 10000,
  166. update: null,
  167. realpath: true,
  168. retries: 0,
  169. fs,
  170. onCompromised: (err) => { throw err; },
  171. ...options,
  172. };
  173. options.retries = options.retries || 0;
  174. options.retries = typeof options.retries === 'number' ? { retries: options.retries } : options.retries;
  175. options.stale = Math.max(options.stale || 0, 2000);
  176. options.update = options.update == null ? options.stale / 2 : options.update || 0;
  177. options.update = Math.max(Math.min(options.update, options.stale / 2), 1000);
  178. // Resolve to a canonical file path
  179. resolveCanonicalPath(file, options, (err, file) => {
  180. if (err) {
  181. return callback(err);
  182. }
  183. // Attempt to acquire the lock
  184. const operation = retry.operation(options.retries);
  185. operation.attempt(() => {
  186. acquireLock(file, options, (err, mtime, mtimePrecision) => {
  187. if (operation.retry(err)) {
  188. return;
  189. }
  190. if (err) {
  191. return callback(operation.mainError());
  192. }
  193. // We now own the lock
  194. const lock = locks[file] = {
  195. lockfilePath: getLockFile(file, options),
  196. mtime,
  197. mtimePrecision,
  198. options,
  199. lastUpdate: Date.now(),
  200. };
  201. // We must keep the lock fresh to avoid staleness
  202. updateLock(file, options);
  203. callback(null, (releasedCallback) => {
  204. if (lock.released) {
  205. return releasedCallback &&
  206. releasedCallback(Object.assign(new Error('Lock is already released'), { code: 'ERELEASED' }));
  207. }
  208. // Not necessary to use realpath twice when unlocking
  209. unlock(file, { ...options, realpath: false }, releasedCallback);
  210. });
  211. });
  212. });
  213. });
  214. }
  215. function unlock(file, options, callback) {
  216. options = {
  217. fs,
  218. realpath: true,
  219. ...options,
  220. };
  221. // Resolve to a canonical file path
  222. resolveCanonicalPath(file, options, (err, file) => {
  223. if (err) {
  224. return callback(err);
  225. }
  226. // Skip if the lock is not acquired
  227. const lock = locks[file];
  228. if (!lock) {
  229. return callback(Object.assign(new Error('Lock is not acquired/owned by you'), { code: 'ENOTACQUIRED' }));
  230. }
  231. lock.updateTimeout && clearTimeout(lock.updateTimeout); // Cancel lock mtime update
  232. lock.released = true; // Signal the lock has been released
  233. delete locks[file]; // Delete from locks
  234. removeLock(file, options, callback);
  235. });
  236. }
  237. function check(file, options, callback) {
  238. options = {
  239. stale: 10000,
  240. realpath: true,
  241. fs,
  242. ...options,
  243. };
  244. options.stale = Math.max(options.stale || 0, 2000);
  245. // Resolve to a canonical file path
  246. resolveCanonicalPath(file, options, (err, file) => {
  247. if (err) {
  248. return callback(err);
  249. }
  250. // Check if lockfile exists
  251. options.fs.stat(getLockFile(file, options), (err, stat) => {
  252. if (err) {
  253. // If does not exist, file is not locked. Otherwise, callback with error
  254. return err.code === 'ENOENT' ? callback(null, false) : callback(err);
  255. }
  256. // Otherwise, check if lock is stale by analyzing the file mtime
  257. return callback(null, !isLockStale(stat, options));
  258. });
  259. });
  260. }
  261. function getLocks() {
  262. return locks;
  263. }
  264. // Remove acquired locks on exit
  265. /* istanbul ignore next */
  266. onExit(() => {
  267. for (const file in locks) {
  268. const options = locks[file].options;
  269. try { options.fs.rmdirSync(getLockFile(file, options)); } catch (e) { /* Empty */ }
  270. }
  271. });
  272. module.exports.lock = lock;
  273. module.exports.unlock = unlock;
  274. module.exports.check = check;
  275. module.exports.getLocks = getLocks;