[BUG] install hangs indefinitely instead of failing with ETARGET when --min-release-age rejects the requested version
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 10.1k
- Forks
- 4.7k
- Avg merge
- 2d 2h
- Merged PRs (30d)
- 19
Description
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
- Fail fast when an explicitly requested
name@versionis excluded by the gate,
before entering the retry path. - Bound or memoize the retry loop so a rejected target cannot be reconsidered
indefinitely. - 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) andvitest(2.2 MB) are in the same category aseslint.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with node_modules/@npmcli/arborist/lib/packument-cache.js, especially the sizeCalculation branch, and reproduce the large eslint graph using the two npm install commands in the issue. Trace the retry behavior when --min-release-age rejects eslint@10.8.1 and compare it with the small-project ETARGET case. Done means the explicitly rejected version fails deterministically with ETARGET instead of looping indefinitely.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, nodejs
- Domain
- cli, tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100