mirror of
https://github.com/zebrajr/node.git
synced 2025-12-07 12:20:50 +01:00
PR-URL: https://github.com/nodejs/node/pull/60012 Reviewed-By: Luigi Pinca <luigipinca@gmail.com> Reviewed-By: Michaël Zasso <targos@protonmail.com> Reviewed-By: Rafael Gonzaga <rafael.nunu@hotmail.com>
176 lines
5.3 KiB
JavaScript
176 lines
5.3 KiB
JavaScript
const fs = require('node:fs/promises')
|
|
const { rmdirSync } = require('node:fs')
|
|
const promiseRetry = require('promise-retry')
|
|
const { onExit } = require('signal-exit')
|
|
|
|
// a lockfile implementation inspired by the unmaintained proper-lockfile library
|
|
//
|
|
// similarities:
|
|
// - based on mkdir's atomicity
|
|
// - works across processes and even machines (via NFS)
|
|
// - cleans up after itself
|
|
// - detects compromised locks
|
|
//
|
|
// differences:
|
|
// - higher-level API (just a withLock function)
|
|
// - written in async/await style
|
|
// - uses mtime + inode for more reliable compromised lock detection
|
|
// - more ergonomic compromised lock handling (i.e. withLock will reject, and callbacks have access to an AbortSignal)
|
|
// - uses a more recent version of signal-exit
|
|
|
|
const touchInterval = 1_000
|
|
// mtime precision is platform dependent, so use a reasonably large threshold
|
|
const staleThreshold = 5_000
|
|
|
|
// track current locks and their cleanup functions
|
|
const currentLocks = new Map()
|
|
|
|
function cleanupLocks () {
|
|
for (const [, cleanup] of currentLocks) {
|
|
try {
|
|
cleanup()
|
|
} catch (err) {
|
|
//
|
|
}
|
|
}
|
|
}
|
|
|
|
// clean up any locks that were not released normally
|
|
onExit(cleanupLocks)
|
|
|
|
/**
|
|
* Acquire an advisory lock for the given path and hold it for the duration of the callback.
|
|
*
|
|
* The lock will be released automatically when the callback resolves or rejects.
|
|
* Concurrent calls to withLock() for the same path will wait until the lock is released.
|
|
*/
|
|
async function withLock (lockPath, cb) {
|
|
try {
|
|
const signal = await acquireLock(lockPath)
|
|
return await new Promise((resolve, reject) => {
|
|
signal.addEventListener('abort', () => {
|
|
reject(Object.assign(new Error('Lock compromised'), { code: 'ECOMPROMISED' }))
|
|
});
|
|
|
|
(async () => {
|
|
try {
|
|
resolve(await cb(signal))
|
|
} catch (err) {
|
|
reject(err)
|
|
}
|
|
})()
|
|
})
|
|
} finally {
|
|
releaseLock(lockPath)
|
|
}
|
|
}
|
|
|
|
function acquireLock (lockPath) {
|
|
return promiseRetry({
|
|
minTimeout: 100,
|
|
maxTimeout: 5_000,
|
|
// 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
|
|
forever: true,
|
|
}, async (retry) => {
|
|
try {
|
|
await fs.mkdir(lockPath)
|
|
} catch (err) {
|
|
if (err.code !== 'EEXIST' && err.code !== 'EBUSY' && err.code !== 'EPERM') {
|
|
throw err
|
|
}
|
|
|
|
const status = await getLockStatus(lockPath)
|
|
|
|
if (status === 'locked') {
|
|
// let's see if we can acquire it on the next attempt 🤞
|
|
return retry(err)
|
|
}
|
|
if (status === 'stale') {
|
|
try {
|
|
// 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
|
|
deleteLock(lockPath)
|
|
} catch (e) {
|
|
// on windows, EBUSY/EPERM can happen if another process is (re)creating the lock; maybe we can acquire it on a subsequent attempt 🤞
|
|
if (e.code === 'EBUSY' || e.code === 'EPERM') {
|
|
return retry(e)
|
|
}
|
|
throw e
|
|
}
|
|
}
|
|
// immediately attempt to acquire the lock (no backoff)
|
|
return await acquireLock(lockPath)
|
|
}
|
|
try {
|
|
const signal = await maintainLock(lockPath)
|
|
return signal
|
|
} catch (err) {
|
|
throw Object.assign(new Error('Lock compromised'), { code: 'ECOMPROMISED' })
|
|
}
|
|
})
|
|
}
|
|
|
|
function deleteLock (lockPath) {
|
|
try {
|
|
// synchronous, so we can call in an exit handler
|
|
rmdirSync(lockPath)
|
|
} catch (err) {
|
|
if (err.code !== 'ENOENT') {
|
|
throw err
|
|
}
|
|
}
|
|
}
|
|
|
|
function releaseLock (lockPath) {
|
|
currentLocks.get(lockPath)?.()
|
|
currentLocks.delete(lockPath)
|
|
}
|
|
|
|
async function getLockStatus (lockPath) {
|
|
try {
|
|
const stat = await fs.stat(lockPath)
|
|
return (Date.now() - stat.mtimeMs > staleThreshold) ? 'stale' : 'locked'
|
|
} catch (err) {
|
|
if (err.code === 'ENOENT') {
|
|
return 'unlocked'
|
|
}
|
|
throw err
|
|
}
|
|
}
|
|
|
|
async function maintainLock (lockPath) {
|
|
const controller = new AbortController()
|
|
const stats = await fs.stat(lockPath)
|
|
// 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
|
|
let mtime = Math.round(stats.mtimeMs / 1000)
|
|
const signal = controller.signal
|
|
|
|
async function touchLock () {
|
|
try {
|
|
const currentStats = (await fs.stat(lockPath))
|
|
const currentMtime = Math.round(currentStats.mtimeMs / 1000)
|
|
if (currentStats.ino !== stats.ino || currentMtime !== mtime) {
|
|
throw new Error('Lock compromised')
|
|
}
|
|
mtime = Math.round(Date.now() / 1000)
|
|
// touch the lock, unless we just released it during this iteration
|
|
if (currentLocks.has(lockPath)) {
|
|
await fs.utimes(lockPath, mtime, mtime)
|
|
}
|
|
} catch (err) {
|
|
// stats mismatch or other fs error means the lock was compromised
|
|
controller.abort()
|
|
}
|
|
}
|
|
|
|
const timeout = setInterval(touchLock, touchInterval)
|
|
timeout.unref()
|
|
function cleanup () {
|
|
clearInterval(timeout)
|
|
deleteLock(lockPath)
|
|
}
|
|
currentLocks.set(lockPath, cleanup)
|
|
return signal
|
|
}
|
|
|
|
module.exports = withLock
|