Skip to content

[BUG] install hangs indefinitely instead of failing with ETARGET when --min-release-age rejects the requested version #9891

Description

@cj-christoph-gysin

Is there an existing issue for this?

  • I have searched the existing issues

This issue exists in the latest npm version

  • I am using the latest npm

This is not just a request to bump a dependency for a CVE

  • Confirmed

Current Behavior

When npm install <pkg>@<version> is given a --min-release-age that the requested
version cannot satisfy, npm can loop without ever terminating instead of failing with
ETARGET.

The correct behaviour does happen in small projects. With only eslint in the
manifest (70 packages in the lockfile), the same command fails cleanly in ~1 second:

npm error code ETARGET
npm error notarget No matching version found for eslint@10.8.1 with a date before 8/4/2026, 11:08:22 AM.

Add the rest of the eslint ecosystem (182 packages) and the identical command runs
indefinitely with no output and no error. I have let the manifest below run 9
minutes on npm 12.0.2 without it completing or printing anything, and the same
command ran 52 minutes in CI before the job was cancelled.

--loglevel=silly shows the resolver spinning on a single packument. Over 90
seconds:

Count Line
7779 npm silly packumentCache full:https://registry.npmjs.org/eslint cache-miss
7778 npm silly packumentCache full:https://registry.npmjs.org/eslint set size:2048190 disposed:false
7777 npm silly fetch manifest eslint@10.8.1
3898 npm warn ERESOLVE overriding peer dependency

Only 31 HTTP fetches occur in total, so this is not network-bound — it is the
in-memory packument cache failing to retain the entry. Note 7778 set calls,
7779 cache-miss, and zero dispose events
. The entry is never evicted; it is
never admitted in the first place.

That appears to be by design in PackumentCache
(node_modules/@npmcli/arborist/lib/packument-cache.js):

sizeCalculation: (p) => {
  if (!p[sizeKey]) {
    return maxEntrySize + 1
  }
  if (p[sizeKey] < 10_000) {
    return p[sizeKey] * 2
  }
  if (p[sizeKey] < 1_000_000) {
    return Math.floor(p[sizeKey] * 1.5)
  }
  // It is less beneficial to store a small amount of super large things
  // at the cost of all other packuments.
  return maxEntrySize + 1
},

Any packument whose _contentLength is >= 1,000,000 returns maxEntrySize + 1, so
lru-cache refuses the entry. eslint's full packument is 2,048,190 bytes, matching
the logged set size:2048190 exactly. It is therefore permanently uncacheable, and
every retry re-reads and re-parses ~2 MB.

The cache had ample room, so this is the size branch rather than memory pressure:

npm silly packumentCache heap:4496293888 maxSize:1124073472 maxEntrySize:562036736

On its own that is only a performance tax. What makes it fatal here is that
--min-release-age renders the requested version unsatisfiable, which puts the
resolver on a retry/backtrack path. Each iteration pays a full uncached 2 MB fetch
and parse, and with eslint's 427 versions and its version-locked @eslint/*
peerOptional graph the loop does not converge.

Expected Behavior

npm install eslint@10.8.1 --min-release-age=14 should fail with ETARGET the same
way it does in a small project, since the requested version is unambiguously
excluded by the gate. Whatever the resolver does internally, an explicitly requested
version that the gate rejects should be a fast, deterministic failure rather than an
unbounded loop.

Steps To Reproduce

Requested version must be younger than the gate. eslint@10.8.1 was published
2026-08-07, so --min-release-age=14 excludes it as of 2026-08-18; adjust the
version or the gate if reproducing later.

mkdir /tmp/npm-min-release-age-hang && cd /tmp/npm-min-release-age-hang

cat > package.json <<'JSON'
{
  "name": "repro", "version": "1.0.0", "private": true,
  "devDependencies": {
    "@eslint/eslintrc": "^3.3.5",
    "@eslint/js": "^10.0.1",
    "@typescript-eslint/eslint-plugin": "^8.50.0",
    "@typescript-eslint/parser": "^8.50.0",
    "@vitest/eslint-plugin": "^1.6.18",
    "eslint": "^10.0.3",
    "eslint-config-prettier": "^10.1.8",
    "eslint-plugin-prettier": "^5.5.4",
    "prettier": "^3.7.4",
    "typescript": "^6.0.3",
    "vitest": "^4.1.7"
  }
}
JSON

# 1. Create the lockfile (182 packages)
npm install --package-lock-only --ignore-scripts

# 2. Hangs indefinitely -- no output, no error
npm install eslint@10.8.1 --ignore-scripts --package-lock-only --min-release-age=14

Step 1 already resolves eslint to 10.8.1, so step 2 hangs while re-requesting a
version the lockfile already contains — the gate rejects it regardless. The
real-world shape hangs identically: replacing step 1 with
npm install eslint@10.8.0 --package-lock-only --ignore-scripts to pin the lockfile
at 10.8.0, then running step 2 as the 10.8.0 -> 10.8.1 bump, ran 5 minutes
without completing. That is how this surfaced, as a Dependabot patch update.

Contrast cases, all with --min-release-age=14 and all measured on npm 12.0.2:

Case Result
Manifest above (182 pkgs), eslint@10.8.1 (10d, gate rejects) no completion after 9 min
Same, but lockfile pinned to 10.8.0 first (the real bump) no completion after 5 min
eslint alone (70 pkgs), eslint@10.8.1 (10d, gate rejects) ETARGET in ~1s (correct)
Manifest above, eslint@10.8.0 (24d, gate accepts) exit 0, sub-second

So neither the graph nor the gate alone is sufficient; it needs a rejected target plus
the peer-coupled graph. A rejected target in a small project fails correctly, and the
same large graph resolves instantly when the target passes the gate.

Not dependent on --force (hangs with and without it), not dependent on any private
registry or proxy — reproduced against registry.npmjs.org directly.

Environment

  • npm: reproduced on 12.0.2 (latest) and 11.12.1
  • Node.js: v24.15.0
  • OS: macOS 15 (Darwin 25.5.0, arm64)
  • Registry: https://registry.npmjs.org/ (isolated empty user/global npmrc, fresh cache dir)

Also reproduced on npm 11.17.0 / Ubuntu 24.04 inside Dependabot's updater container
against an AWS CodeArtifact registry, where the proxy logged the same packument
being refetched 21,732 times over 52 minutes before the job was cancelled.

Possible directions

  1. Fail fast when an explicitly requested name@version is excluded by the gate,
    before entering the retry path.
  2. Bound or memoize the retry loop so a rejected target cannot be reconsidered
    indefinitely.
  3. Reconsider the >= 1,000,000 byte uncacheable branch. A negative-lookup marker, or
    caching a version-index projection rather than the whole packument, would stop
    large-packument packages from paying an unbounded re-parse cost. typescript
    (15.5 MB) and vitest (2.2 MB) are in the same category as eslint.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions