Skip to content

TypeScript CLI decodes package archives with unbounded memory #14164

Description

@zkochan

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

  1. 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.

  2. 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.

  3. 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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions