[New] nvm install: serialize concurrent installs of the same version

Two `nvm install <same version>` runs could race on the version directory
- one removing or replacing it while the other reads or writes it.
Take a per-version advisory lock
(an atomically-created directory under $NVM_DIR/.cache locks)
around the binary/source install,
so a second run of the same version waits for the first;
installs of different versions never contend.

NVM_INSTALL_LOCK_TIMEOUT (seconds, default 600) bounds the wait,
after which nvm reports the lock path so a lock left by a killed install can be removed.
NVM_INSTALL_LOCK_STALE (minutes, default 0 / off)
opts into automatically stealing a lock older than that,
for unattended or CI use.
This commit is contained in:
Jordan Harband
2026-07-24 10:55:58 -07:00
parent 9570a22bfe
commit 809f3ee5ab
5 changed files with 210 additions and 1 deletions
+56
View File
@@ -0,0 +1,56 @@
#!/bin/sh
cleanup() {
[ -n "${NVM_DIR}" ] && [ -d "${NVM_DIR}" ] && rm -rf "${NVM_DIR}"
unset -f die cleanup
unset NVM_DIR NVM_INSTALL_LOCK NVM_INSTALL_LOCK_TIMEOUT NVM_INSTALL_LOCK_STALE \
version lock OUTPUT EXIT_CODE
}
die() { echo "$@" ; cleanup ; exit 1; }
: nvm.sh
\. ../../../nvm.sh
type nvm_acquire_install_lock > /dev/null 2>&1 || die 'nvm_acquire_install_lock is not available'
NVM_DIR="$(mktemp -d)"
[ -n "${NVM_DIR}" ] || die 'unable to create temp NVM_DIR'
version='v20.0.0'
lock="$(nvm_cache_dir)/locks/$(nvm_install_lock_name "${version}")"
# An empty version is a no-op that succeeds and takes no lock.
unset NVM_INSTALL_LOCK
nvm_acquire_install_lock '' || die 'acquiring with an empty version should succeed as a no-op'
[ -z "${NVM_INSTALL_LOCK-}" ] || die 'an empty-version acquire should not record a lock'
# Acquiring a free lock succeeds, creates the lock dir, and records its path.
nvm_acquire_install_lock "${version}" || die 'acquiring a free lock should succeed'
[ -d "${lock}" ] || die 'acquire should create the lock directory'
[ "${NVM_INSTALL_LOCK}" = "${lock}" ] || die 'acquire should record NVM_INSTALL_LOCK'
# Release it so the following contention checks start from a held-by-someone-else state.
command rmdir "${lock}" 2>/dev/null
unset NVM_INSTALL_LOCK
# A lock held by "another process", with no wait budget, fails and names the path.
command mkdir -p "${lock}"
OUTPUT="$(NVM_INSTALL_LOCK_TIMEOUT=0 nvm_acquire_install_lock "${version}" 2>&1)"; EXIT_CODE=$?
[ "${EXIT_CODE}" != "0" ] || die 'acquire should fail when the lock is held and the timeout is 0'
case "${OUTPUT}" in
*"${lock}"*) ;;
*) die "the timeout message should name the lock path; got >${OUTPUT}<" ;;
esac
[ -d "${lock}" ] || die 'a failed acquire must not remove the held lock'
# By default, a held lock is never stolen no matter how old it is.
touch -t 202001010000 "${lock}" 2>/dev/null || die 'unable to age the lock dir'
NVM_INSTALL_LOCK_TIMEOUT=0 nvm_acquire_install_lock "${version}" 2>/dev/null && die 'an old lock must not be stolen unless NVM_INSTALL_LOCK_STALE is set'
[ -d "${lock}" ] || die 'the old lock should still be held'
# With NVM_INSTALL_LOCK_STALE set, a sufficiently old lock is stolen and re-acquired.
unset NVM_INSTALL_LOCK
NVM_INSTALL_LOCK_STALE=1 NVM_INSTALL_LOCK_TIMEOUT=0 nvm_acquire_install_lock "${version}" || die 'a stale lock should be stolen when NVM_INSTALL_LOCK_STALE is set'
[ "${NVM_INSTALL_LOCK}" = "${lock}" ] || die 'stealing a stale lock should acquire it'
cleanup
+32
View File
@@ -0,0 +1,32 @@
#!/bin/sh
cleanup() {
[ -n "${NVM_DIR}" ] && [ -d "${NVM_DIR}" ] && rm -rf "${NVM_DIR}"
unset -f die cleanup
unset NVM_DIR
}
die() { echo "$@" ; cleanup ; exit 1; }
: nvm.sh
\. ../../../nvm.sh
type nvm_install_lock_name > /dev/null 2>&1 || die 'nvm_install_lock_name is not available'
NVM_DIR="$(mktemp -d)"
[ -n "${NVM_DIR}" ] || die 'unable to create temp NVM_DIR'
# A normal version passes through unchanged.
[ "$(nvm_install_lock_name 'v20.0.0')" = 'v20.0.0' ] || die "v20.0.0 => >$(nvm_install_lock_name 'v20.0.0')<"
# Dots, hyphens, plus, and underscores are all preserved.
[ "$(nvm_install_lock_name 'iojs-v1.0.0')" = 'iojs-v1.0.0' ] || die "iojs-v1.0.0 => >$(nvm_install_lock_name 'iojs-v1.0.0')<"
# Path separators and other unsafe characters become underscores.
[ "$(nvm_install_lock_name 'lts/*')" = 'lts__' ] || die "lts/* => >$(nvm_install_lock_name 'lts/*')<"
[ "$(nvm_install_lock_name 'a b/c')" = 'a_b_c' ] || die "a b/c => >$(nvm_install_lock_name 'a b/c')<"
# The empty string maps to the empty string (no trailing-newline artifact).
[ "$(nvm_install_lock_name '')" = '' ] || die "empty => >$(nvm_install_lock_name '')<"
cleanup
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
cleanup() {
[ -n "${NVM_DIR}" ] && [ -d "${NVM_DIR}" ] && rm -rf "${NVM_DIR}"
unset -f die cleanup
unset NVM_DIR NVM_INSTALL_LOCK version lock
}
die() { echo "$@" ; cleanup ; exit 1; }
: nvm.sh
\. ../../../nvm.sh
type nvm_release_install_lock > /dev/null 2>&1 || die 'nvm_release_install_lock is not available'
NVM_DIR="$(mktemp -d)"
[ -n "${NVM_DIR}" ] || die 'unable to create temp NVM_DIR'
version='v20.0.0'
lock="$(nvm_cache_dir)/locks/$(nvm_install_lock_name "${version}")"
# Releasing when nothing is held is a no-op that succeeds.
unset NVM_INSTALL_LOCK
nvm_release_install_lock || die 'releasing with no lock held should succeed'
# After acquiring, releasing removes the lock directory and clears the marker.
nvm_acquire_install_lock "${version}" || die 'setup: acquire failed'
[ -d "${lock}" ] || die 'setup: lock dir should exist after acquire'
nvm_release_install_lock || die 'release should succeed'
[ ! -d "${lock}" ] || die 'release should remove the lock directory'
[ -z "${NVM_INSTALL_LOCK-}" ] || die 'release should clear NVM_INSTALL_LOCK'
# Releasing again is a harmless no-op.
nvm_release_install_lock || die 'a second release should be a no-op'
cleanup