D
P
0
← All articles Baca dalam Bahasa Indonesia

Next.js & React in Production

`libatomic.so.1: cannot open shared object file` on `pnpm -v`: Pinning `packageManager` to pnpm 11 Froze Deploys

· · 8 min read
`libatomic.so.1: cannot open shared object file` on `pnpm -v`: Pinning `packageManager` to pnpm 11 Froze Deploys

The commit message that took the pnpm pin to main contains a sentence that sounds completely settled:

Closes the moving target that killed production for eight days in August. The
pin lives in package.json and is self-enforcing, so the build image will fetch
and run exactly this version without any change to the CodeBuild config.

The word fetch in there is right. The word run is not, and neither is the promise that the CodeBuild config would not need touching. The commit is dated 31 August 2026, and on that same date four commits had been pushed, every git ref matched, and production quietly kept serving an older bundle because the build had failed.

The eight-day outage that message refers to was ERR_PNPM_IGNORED_BUILDS, and I have written up its mechanism separately. The root fits in one sentence: package.json did not set packageManager at the time, so the build image installed whatever pnpm happened to be current, and an unpinned package manager turns the build into a moving target. The pin was the cure. This post is about the cure's side effects.

Why the pin went into the repo

The pin is a single line in package.json, "packageManager": "pnpm@11.21.0". Its first draft was a request to the client's DevOps engineer, and I turned that draft down. Which pnpm the project needs is a property of the lockfile and of pnpm-workspace.yaml, not of the machine running the install, so the pin belongs in the repo, not in the buildspec. As I reckoned it then, asking the client's DevOps would only add a dependency and a delay for something a one-line commit settles permanently.

That reckoning leaned on one pnpm behaviour. According to pnpm's documentation, managePackageManagerVersions has defaulted to true since pnpm 10, and pnpm 11 keeps the behaviour under pmOnFail. So the pnpm on the build image would read packageManager, see a different version, and download the requested one itself.

Version 11.21.0 was chosen because it is the version the change was verified on: a frozen-lockfile install, typecheck, 300 tests, and a full production build with no lockfile change. The lesson I wrote down at the time sounded tidy: before routing a task to infra, check whether the repo can enforce it itself. I marked the issue closed on 31 August, with the pin commit recorded as the production commit.

Failing at pnpm -v, before a line compiled

The pin commit never reached production, and neither did the three commits pushed after it. CodeBuild failed in the INSTALL phase, on pnpm -v:

npm install -g pnpm          -> pnpm 10.x installed
pnpm -v
/root/.local/share/pnpm/package-manager-store/v11/links/@pnpm/exe/11.21.0/.../pnpm:
  error while loading shared libraries: libatomic.so.1: cannot open shared object file
exit status 127
Phase complete: INSTALL State: FAILED

The pin commit plus the three after it makes four commits that looked shipped, while production sat for hours on the pin commit's direct parent. There was no signal anywhere that the build had failed, and a comment in the project's code records that this silence is what cost an hour on 31 August.

Unlike the silently failing Turbopack build I wrote about before, this build died before a single line compiled, and what could not cope was not the code but the machine that had to run the pnpm version the repo chose.

The download worked, the binary would not load

What froze the deploys was exactly the mechanism the pin was meant to exploit. The first line of the log shows the image installing pnpm 10.x through npm install -g pnpm. That pnpm 10 reads packageManager, sees a different version, and downloads 11.21.0 itself. For a v11 target the download is not the plain JS package but @pnpm/exe, a standalone compiled binary, and that is the binary showing up under the package-manager-store/v11 path in the log.

That binary is dynamically linked against libatomic.so.1, a library the Amazon Linux image running the build does not carry. The image's yum install line was just gcc gcc-c++ make git. So the download succeeds, and then the binary cannot execute. This path was already known to be fragile: pnpm's own 10.23 release notes say self-update deliberately stopped using @pnpm/exe for v11 and newer because of compatibility issues, and pinning a v11 from a v10 runtime is precisely the path that fetches that executable build.

The claim that the pin enforces itself without any infra change is refuted right here. Downloading the pinned version needs no infra, but running it does. I had checked that claim against pnpm's documentation, which describes the download but says nothing about what the downloaded artifact needs in order to run, and I did not separate the two. So a pin added to close an outage caused by a package manager failing at install time froze deploys by failing at install time too, with a symptom that looked identical from the outside.

The Node requirement the pin brought along

libatomic turned out to be only half of it. After fixing that library, the client's DevOps engineer reported, without being sure what had changed, that the image had been on Node 20 and had to be moved to 22, after which a few packages broke and fixing them took time. The answer is written in pnpm's own metadata:

npm view pnpm@11.21.0 engines   ->   { node: '>=22.13' }

Per that report the build image ran Node 20, while pnpm 11.21.0 asks for Node 22.13 or newer, so the one-line pin also demanded a major Node upgrade. On my machine the requirement never surfaced, because it was already on Node v22.23.2, which is exactly why the local build passed. The gap was in package.json, which had no engines field at the time. Had engines.node been there, the requirement would have surfaced as a readable declaration rather than a failing build.

Who paid for that one line

The fix did not come from the repo. According to the client's DevOps engineer, libatomic was added to the packages the build image installs and the pipeline was re-run, and the Node upgrade plus the packages that broke after it were sorted out on their side as well. Summed up, the one-line pin cost the build image a system library, a major Node upgrade, and package fallout from that upgrade, which came to roughly two hours of the client's DevOps time plus about ninety minutes of production serving a broken bundle. The dependency on the client's DevOps that the pin was meant to avoid arrived anyway, only after the deploys froze.

The build image is still the right layer for this fix, because it keeps package.json the single source of the version number, and on that point the original reasoning was correct. The other way out, pinning the version a second time in the buildspec with npm install -g pnpm@11.21.0, also works because it installs the plain JS package and skips the self-download (its Node version requirement still applies), but it writes the version number in two places that can drift apart.

What changed in the repo, and what I have not confirmed

Two repo-side follow-ups landed on 31 August. The first corrected the header comment in pnpm-workspace.yaml, which until then still said pnpm downloads and runs exactly that version whatever the build image ships. The comment now contains this:

# THE PIN IS NOT FREE OF THE BUILD IMAGE, which is what the first version of
# this comment claimed. Downloading the pinned version needs nothing. Running
# it does.

A few lines further down, the same comment records that the fix was adding libatomic to the build image's packages, and that this is the first thing to check if the problem ever reappears on a new image. The second follow-up was a Node declaration in package.json, right below the pin:

  "packageManager": "pnpm@11.21.0",
  "engines": {
    "node": ">=22.13"
  },

Its commit message admits that the declaration does not enforce anything on its own. The point is that the next person choosing a pnpm version can see what runtime that choice drags along instead of finding out from a failing build.

As for the fix on the image, the evidence is indirect, because my notes do not record a step verifying that the next deploy landed. The clues are in git history: a route exposing the deployed commit was added on 31 August, and another commit message from the same date records that the route had shipped to production, even though it immediately reported its sha as unknown, a separate matter. So at least one build after the fix did land. A commit message dated 28 September also states that the pipeline runs Node 22.

Name the artifact that will run

My first lesson, that the repo can enforce this itself, turned out to be only half right. Choosing the version is a repo decision. Making the chosen version actually run is a property of the machine, and a repo cannot vouch for anything about a machine it has never seen. So before claiming a change needs no infra, name the artifact that will run and what it needs. A dependency you cannot see is still a dependency.

For a package-manager pin, that means two questions before committing: which artifact gets downloaded, and what the package manager's own engines require, because the pin carries those requirements with it. For the second, run npm view <pkg>@<version> engines before pinning a major version and compare the result with the Node the build image actually runs, since passing locally proves nothing when the local machine is several versions ahead.

The words fetch and run in that commit message turned out to be two different promises, and the repo can only keep the first one.