Split out of #13656, which bounded the same three paths in the Rust CLI (PR #14163). The TypeScript CLI is unbounded on all three, and unlike the Rust side the fix is not a small one, so it wants its own decision.
Nothing here is a vulnerability report: npm has comparable exposure, and a package that inflates to more RAM than the machine has is a broken package rather than an attack on pnpm specifically. It is worth writing down because the current design makes peak memory a function of the largest package in the tree times the worker count, which is a real cause of OOM kills on small CI runners — and because it is now the only stack of the two where that is true.
The three paths
-
Whole-archive gunzip. store/cafs/src/addFilesFromTarball.ts:30 calls gunzipSync(tarballBuffer, { chunkSize }) with no maxOutputLength, so the entire decompressed archive is materialized. chunkSize is zlib's internal working buffer, not an output bound. The compressed body is still alive alongside it in the worker, so peak is roughly compressed + fully-decompressed, times availableParallelism() - 1 workers.
-
Response body. fetching/tarball-fetcher/src/remoteTarballFetcher.ts:181-195 — the no-Content-Length branch collects chunks into an array and then copies them into a fresh SharedArrayBuffer, so a chunked response peaks at about twice the compressed size with nothing checking it against anything. The known-length branch at least throws ERR_PNPM_BAD_TARBALL_SIZE on an overrun, but that is an equality check against the server's own claim, not a ceiling.
-
Zip entries. fetching/binary-fetcher/src/index.ts:185 hands the file to adm-zip, which reads the whole archive into memory a second time and inflates each entry in full via getData() before writing it.
Why it is not a one-line fix
Adding maxOutputLength to the gunzipSync call would be a refusal — a package larger than the limit stops installing — which is exactly what the Rust-side fix avoided by streaming instead. Streaming here means replacing the design, not adding an option: store/cafs/src/parseTarball.ts is an index-only scan that walks 512-byte headers over one contiguous buffer and returns {offset, size} per entry, and addFilesFromTarball.ts:40 then hands out zero-copy subarray views of that same buffer. Both properties are deliberate and both go away with a streaming tar reader.
So the question to answer first is whether the memory ceiling is worth what the zero-copy extraction currently buys. A before/after on the cold-cache install benchmark would settle it.
Note that a streaming tar reader does already exist in the repo, just not on the install path: releasing/commands/src/publish/extractManifestFromPacked.ts:45 uses createGunzip() + tar-stream.
Written by an agent (Claude Code, claude-fable-5).
Split out of #13656, which bounded the same three paths in the Rust CLI (PR #14163). The TypeScript CLI is unbounded on all three, and unlike the Rust side the fix is not a small one, so it wants its own decision.
Nothing here is a vulnerability report: npm has comparable exposure, and a package that inflates to more RAM than the machine has is a broken package rather than an attack on pnpm specifically. It is worth writing down because the current design makes peak memory a function of the largest package in the tree times the worker count, which is a real cause of OOM kills on small CI runners — and because it is now the only stack of the two where that is true.
The three paths
Whole-archive gunzip.
store/cafs/src/addFilesFromTarball.ts:30callsgunzipSync(tarballBuffer, { chunkSize })with nomaxOutputLength, so the entire decompressed archive is materialized.chunkSizeis zlib's internal working buffer, not an output bound. The compressed body is still alive alongside it in the worker, so peak is roughlycompressed + fully-decompressed, timesavailableParallelism() - 1workers.Response body.
fetching/tarball-fetcher/src/remoteTarballFetcher.ts:181-195— the no-Content-Lengthbranch collects chunks into an array and then copies them into a freshSharedArrayBuffer, so a chunked response peaks at about twice the compressed size with nothing checking it against anything. The known-length branch at least throwsERR_PNPM_BAD_TARBALL_SIZEon an overrun, but that is an equality check against the server's own claim, not a ceiling.Zip entries.
fetching/binary-fetcher/src/index.ts:185hands the file toadm-zip, which reads the whole archive into memory a second time and inflates each entry in full viagetData()before writing it.Why it is not a one-line fix
Adding
maxOutputLengthto thegunzipSynccall would be a refusal — a package larger than the limit stops installing — which is exactly what the Rust-side fix avoided by streaming instead. Streaming here means replacing the design, not adding an option:store/cafs/src/parseTarball.tsis an index-only scan that walks 512-byte headers over one contiguous buffer and returns{offset, size}per entry, andaddFilesFromTarball.ts:40then hands out zero-copysubarrayviews of that same buffer. Both properties are deliberate and both go away with a streaming tar reader.So the question to answer first is whether the memory ceiling is worth what the zero-copy extraction currently buys. A before/after on the cold-cache install benchmark would settle it.
Note that a streaming tar reader does already exist in the repo, just not on the install path:
releasing/commands/src/publish/extractManifestFromPacked.ts:45usescreateGunzip()+tar-stream.Written by an agent (Claude Code, claude-fable-5).