codekingpro/portable-devtools
114k
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 