diff --git a/README.md b/README.md index f548cae7..cc2eca30 100644 --- a/README.md +++ b/README.md @@ -875,6 +875,10 @@ Additionally, nvm modifies `PATH`, and, if present, `MANPATH` and `NODE_PATH` wh The following environment variables can be set to configure `nvm install`: - `NVM_NO_SOURCE_FALLBACK` - when `1`, a failed binary download aborts instead of silently falling back to a (much slower) from-source compile; the persistent equivalent of the `-b` flag, and mutually exclusive with `-s`. +- `NVM_INSTALL_LOCK_TIMEOUT` - seconds to wait for a concurrent install of the same version to finish before giving up (default `600`). On timeout, nvm prints the lock path so a lock left behind by a killed install can be removed. +- `NVM_INSTALL_LOCK_STALE` - minutes after which an install lock is assumed abandoned and stolen automatically; `0` (the default) never steals. + +`nvm install ` takes a per-version advisory lock (a directory under `$NVM_DIR/.cache/locks`), so two shells installing the same version at once cannot corrupt its version directory; installs of *different* versions never block each other. ## Bash Completion diff --git a/nvm.sh b/nvm.sh index eaaa2897..7fc9382e 100755 --- a/nvm.sh +++ b/nvm.sh @@ -3230,6 +3230,80 @@ nvm_cache_dir() { nvm_echo "${NVM_DIR}/.cache" } +# Turn a version into a filesystem-safe lock name. Versions that reach here are +# already restricted to [0-9A-Za-z._+-], but be defensive about anything else. +nvm_install_lock_name() { + command printf '%s' "${1-}" | command tr -c '0-9A-Za-z._+-' '_' +} + +# Acquire an advisory, per-version install lock so two concurrent +# `nvm install ` runs cannot race on the same version directory +# (one removing/replacing it while the other reads or writes it). The lock is a +# directory created with `mkdir`, which is atomic across POSIX filesystems. +# +# Tunables (env vars): +# NVM_INSTALL_LOCK_TIMEOUT seconds to wait for a held lock (default 600) +# NVM_INSTALL_LOCK_STALE minutes after which a lock is assumed abandoned +# and stolen; 0 (default) never steals +# +# On success the lock path is recorded in NVM_INSTALL_LOCK for the matching +# nvm_release_install_lock. +nvm_acquire_install_lock() { + local VERSION + VERSION="${1-}" + if [ -z "${VERSION}" ]; then + return 0 + fi + + local LOCK_ROOT + LOCK_ROOT="$(nvm_cache_dir)/locks" + # If the lock directory can't be created, don't block the install over it. + command mkdir -p "${LOCK_ROOT}" 2>/dev/null || return 0 + + local LOCK + LOCK="${LOCK_ROOT}/$(nvm_install_lock_name "${VERSION}")" + + local TIMEOUT + TIMEOUT="${NVM_INSTALL_LOCK_TIMEOUT:-600}" + local STALE + STALE="${NVM_INSTALL_LOCK_STALE:-0}" + + local WAITED + WAITED=0 + local ANNOUNCED + ANNOUNCED=0 + while ! command mkdir "${LOCK}" 2>/dev/null; do + # Steal a lock left behind by a crashed install once it is old enough. + if [ "${STALE}" != '0' ] && [ -n "$(command find "${LOCK}" -maxdepth 0 -type d -mmin "+${STALE}" 2>/dev/null)" ]; then + nvm_err "Removing stale install lock for ${VERSION} (older than ${STALE} minute(s))" + command rm -rf "${LOCK}" 2>/dev/null + continue + fi + if [ "${WAITED}" -ge "${TIMEOUT}" ]; then + nvm_err "Timed out after ${TIMEOUT}s waiting for another install of ${VERSION} to finish." + nvm_err "If no other install is running, remove ${LOCK} and try again." + return 1 + fi + if [ "${ANNOUNCED}" -eq 0 ]; then + nvm_err "Waiting for another install of ${VERSION} to finish..." + ANNOUNCED=1 + fi + command sleep 1 + WAITED=$((WAITED + 1)) + done + + NVM_INSTALL_LOCK="${LOCK}" + return 0 +} + +# Release the lock acquired by nvm_acquire_install_lock, if any. +nvm_release_install_lock() { + if [ -n "${NVM_INSTALL_LOCK-}" ]; then + command rmdir "${NVM_INSTALL_LOCK}" 2>/dev/null || command rm -rf "${NVM_INSTALL_LOCK}" 2>/dev/null || true + unset NVM_INSTALL_LOCK + fi +} + # args: pattern # Lists versions available in the local cache (not yet installed). # Returns version numbers like "v18.20.4", one per line, sorted. @@ -3933,6 +4007,11 @@ nvm() { fi EXIT_CODE=0 else + # Serialize concurrent installs of this version so two runs cannot race + # on its version directory (one replacing it while the other reads it). + if ! nvm_acquire_install_lock "${VERSION}"; then + return 1 + fi if [ "_${NVM_OS}" = "_freebsd" ]; then # node.js and io.js do not have a FreeBSD binary @@ -3975,6 +4054,8 @@ nvm() { EXIT_CODE=$? fi fi + + nvm_release_install_lock fi if [ $EXIT_CODE -eq 0 ] && ! nvm_validate_install "${VERSION}"; then @@ -4853,6 +4934,7 @@ nvm() { nvm_die_on_prefix nvm_get_make_jobs nvm_get_minor_version \ nvm_has_solaris_binary nvm_is_merged_node_version \ nvm_is_natural_num nvm_is_version_installed nvm_validate_install \ + nvm_install_lock_name nvm_acquire_install_lock nvm_release_install_lock \ nvm_list_aliases nvm_make_alias nvm_print_alias_path \ nvm_print_default_alias nvm_print_formatted_alias nvm_resolve_local_alias \ nvm_sanitize_path nvm_has_colors nvm_process_parameters \ @@ -4866,7 +4948,7 @@ nvm() { nvm_write_nvmrc \ >/dev/null 2>&1 unset NVM_NODEJS_ORG_MIRROR NVM_IOJS_ORG_MIRROR NVM_DIR \ - NVM_CD_FLAGS NVM_BIN NVM_INC NVM_MAKE_JOBS \ + NVM_CD_FLAGS NVM_BIN NVM_INC NVM_MAKE_JOBS NVM_INSTALL_LOCK \ NVM_COLORS INSTALLED_COLOR SYSTEM_COLOR \ CURRENT_COLOR NOT_INSTALLED_COLOR DEFAULT_COLOR LTS_COLOR \ >/dev/null 2>&1 diff --git a/test/fast/Unit tests/nvm_acquire_install_lock b/test/fast/Unit tests/nvm_acquire_install_lock new file mode 100755 index 00000000..6f5d38cf --- /dev/null +++ b/test/fast/Unit tests/nvm_acquire_install_lock @@ -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 diff --git a/test/fast/Unit tests/nvm_install_lock_name b/test/fast/Unit tests/nvm_install_lock_name new file mode 100755 index 00000000..a96e9c39 --- /dev/null +++ b/test/fast/Unit tests/nvm_install_lock_name @@ -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 diff --git a/test/fast/Unit tests/nvm_release_install_lock b/test/fast/Unit tests/nvm_release_install_lock new file mode 100755 index 00000000..84b60c36 --- /dev/null +++ b/test/fast/Unit tests/nvm_release_install_lock @@ -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