-
Notifications
You must be signed in to change notification settings - Fork 3.8k
fix: allow concurrent non-local npx calls #8512
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 11 commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
43fa096
fix: allow concurrent non-local npx calls
jenseng 904c22a
fix: test
jenseng 6749831
switch to inline implementation
jenseng 8f909db
update comment, fix sync callback handling
jenseng 08ba9c9
single line comment
jenseng b86edcb
another single line comment
jenseng 02f0c94
switch to promise-retry
jenseng 2c06415
create lockfile within install dir
jenseng 58ec2a0
make test more reliable
jenseng a107493
fix dependencies
jenseng be1efa1
fix a race condition around lock compromise detection
jenseng 3f5b284
make code more clear
jenseng 25d515b
fix typo
jenseng ebb5224
fix utimes argument
jenseng 939e5fa
remove redundant rmdir
jenseng 989c355
improve compromised lock detection
jenseng 8b1b38f
make test more resilient... though maybe we should just delete it? 🤔
jenseng e938026
make test more resilient
jenseng 48f2400
fix windows race condition
jenseng File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| 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 = 100 | ||
| // 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 { | ||
| await 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') { | ||
| 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') { | ||
| // 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 | ||
| await releaseLock(lockPath) | ||
| } | ||
| return await acquireLock(lockPath) | ||
| } | ||
| const signal = await maintainLock(lockPath) | ||
| return signal | ||
| }) | ||
| } | ||
|
|
||
| async function releaseLock (lockPath) { | ||
| try { | ||
| currentLocks.get(lockPath)?.() | ||
| currentLocks.delete(lockPath) | ||
| await fs.rmdir(lockPath) | ||
| } catch (err) { | ||
| if (err.code !== 'ENOENT') { | ||
| throw err | ||
| } | ||
| } | ||
| } | ||
|
|
||
| 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) | ||
| let mtimeMs = stats.mtimeMs | ||
| const signal = controller.signal | ||
|
|
||
| async function touchLock () { | ||
| try { | ||
| const currentStats = (await fs.stat(lockPath)) | ||
| if (currentStats.ino !== stats.ino || currentStats.mtimeMs !== mtimeMs) { | ||
| throw new Error('Lock compromised') | ||
| } | ||
| await fs.utimes(lockPath, new Date(), new Date(mtimeMs = Date.now())) | ||
jenseng marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } catch (err) { | ||
| // stats mismatch or other fs error means the lock was compromised, unless we just released the lock during this iteration | ||
| if (currentLocks.has(lockPath)) { | ||
| controller.abort() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const timeout = setInterval(touchLock, touchInterval) | ||
| timeout.unref() | ||
| function cleanup () { | ||
| rmdirSync(lockPath) | ||
| clearInterval(timeout) | ||
| } | ||
| currentLocks.set(lockPath, cleanup) | ||
| return signal | ||
| } | ||
|
|
||
| module.exports = withLock | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.