node/deps/npm/node_modules/libnpmexec/lib/with-lock.js
npm CLI robot 5d843c914e
deps: upgrade npm to 11.6.1
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>
2025-10-02 22:30:58 +00:00

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