Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
with-lock.js178 linesDownload Raw Back to lib
1const fs = require('node:fs/promises')
2const { rmdirSync } = require('node:fs')
3const { promiseRetry } = require('@gar/promise-retry')
4const { onExit } = require('signal-exit')
5
6// a lockfile implementation inspired by the unmaintained proper-lockfile library
7//
8// similarities:
9// - based on mkdir's atomicity
10// - works across processes and even machines (via NFS)
11// - cleans up after itself
12// - detects compromised locks
13//
14// differences:
15// - higher-level API (just a withLock function)
16// - written in async/await style
17// - uses mtime + inode for more reliable compromised lock detection
18// - more ergonomic compromised lock handling (i.e. withLock will reject, and callbacks have access to an AbortSignal)
19// - uses a more recent version of signal-exit
20
21// mtime precision is platform dependent, so deal in seconds
22const touchInterval = 1_000
23// use a reasonably large threshold, in case stat calls take a while
24const staleThreshold = 60_000
25
26// track current locks and their cleanup functions
27const currentLocks = new Map()
28
29function cleanupLocks () {
30  for (const [, cleanup] of currentLocks) {
31    try {
32      cleanup()
33    } catch (err) {
34      //
35    }
36  }
37}
38
39// clean up any locks that were not released normally
40onExit(cleanupLocks)
41
42/**
43 * Acquire an advisory lock for the given path and hold it for the duration of the callback.
44 *
45 * The lock will be released automatically when the callback resolves or rejects.
46 * Concurrent calls to withLock() for the same path will wait until the lock is released.
47 */
48async function withLock (lockPath, cb) {
49  try {
50    const signal = await acquireLock(lockPath)
51    return await new Promise((resolve, reject) => {
52      signal.addEventListener('abort', () => {
53        reject(Object.assign(new Error('Lock compromised'), { code: 'ECOMPROMISED' }))
54      });
55
56      (async () => {
57        try {
58          resolve(await cb(signal))
59        } catch (err) {
60          reject(err)
61        }
62      })()
63    })
64  } finally {
65    releaseLock(lockPath)
66  }
67}
68
69function acquireLock (lockPath) {
70  return promiseRetry(async (retry) => {
71    try {
72      await fs.mkdir(lockPath)
73    } catch (err) {
74      if (err.code !== 'EEXIST' && err.code !== 'EBUSY' && err.code !== 'EPERM') {
75        throw err
76      }
77
78      const status = await getLockStatus(lockPath)
79
80      if (status === 'locked') {
81        // let's see if we can acquire it on the next attempt 🤞
82        return retry(err)
83      }
84      if (status === 'stale') {
85        try {
86          // there is a very tiny window where another process could also release the stale lock and acquire it before we release it here; the lock compromise checker should detect this and throw an error
87          deleteLock(lockPath)
88        } catch (e) {
89          // on windows, EBUSY/EPERM can happen if another process is (re)creating the lock; maybe we can acquire it on a subsequent attempt 🤞
90          if (e.code === 'EBUSY' || e.code === 'EPERM') {
91            return retry(e)
92          }
93          throw e
94        }
95      }
96      // immediately attempt to acquire the lock (no backoff)
97      return await acquireLock(lockPath)
98    }
99    try {
100      const signal = await maintainLock(lockPath)
101      return signal
102    } catch (err) {
103      throw Object.assign(new Error('Lock compromised'), { code: 'ECOMPROMISED' })
104    }
105  }, {
106    minTimeout: 100,
107    maxTimeout: 5_000,
108    // if another process legitimately holds the lock, wait for it to release; if it dies abnormally and the lock becomes stale, we'll acquire it automatically
109    forever: true,
110  })
111}
112
113function deleteLock (lockPath) {
114  try {
115    // synchronous, so we can call in an exit handler
116    rmdirSync(lockPath)
117  } catch (err) {
118    if (err.code !== 'ENOENT') {
119      throw err
120    }
121  }
122}
123
124function releaseLock (lockPath) {
125  currentLocks.get(lockPath)?.()
126  currentLocks.delete(lockPath)
127}
128
129async function getLockStatus (lockPath) {
130  try {
131    const stat = await fs.stat(lockPath)
132    return (Date.now() - stat.mtimeMs > staleThreshold) ? 'stale' : 'locked'
133  } catch (err) {
134    if (err.code === 'ENOENT') {
135      return 'unlocked'
136    }
137    throw err
138  }
139}
140
141async function maintainLock (lockPath) {
142  const controller = new AbortController()
143  const stats = await fs.stat(lockPath)
144  // fs.utimes operates on floating points seconds (directly, or via strings/Date objects), which may not match the underlying filesystem's mtime precision, meaning that we might read a slightly different mtime than we write. always round to the nearest second, since all filesystems support at least second precision
145  let mtime = Math.round(stats.mtimeMs / 1000)
146  const signal = controller.signal
147
148  let timeout
149  async function touchLock () {
150    try {
151      const currentStats = (await fs.stat(lockPath))
152      const currentMtime = Math.round(currentStats.mtimeMs / 1000)
153      if (currentStats.ino !== stats.ino || currentMtime !== mtime) {
154        throw new Error('Lock compromised')
155      }
156      mtime = Math.round(Date.now() / 1000)
157      // touch the lock, unless we just released it during this iteration
158      if (currentLocks.has(lockPath)) {
159        await fs.utimes(lockPath, mtime, mtime)
160      }
161      timeout = setTimeout(touchLock, touchInterval).unref()
162    } catch (err) {
163      // stats mismatch or other fs error means the lock was compromised
164      controller.abort()
165    }
166  }
167
168  timeout = setTimeout(touchLock, touchInterval).unref()
169  function cleanup () {
170    clearTimeout(timeout)
171    deleteLock(lockPath)
172  }
173  currentLocks.set(lockPath, cleanup)
174  return signal
175}
176
177module.exports = withLock
178 
codekingpro/portable-devtools · Team Ai