Skip to content

loadWorkspaceState crashes with "Unexpected end of JSON input" under concurrent pnpm run (non-atomic write, no parse-error handling) #12020

Description

@magne4000

Verify latest release

  • I verified that the issue exists in the latest pnpm release

pnpm version

v11.4.0

Which area(s) of pnpm are affected? (leave empty if unsure)

No response

Link to the code that reproduces this issue or a replay of the bug

No response

Reproduction steps

A. Deterministic — proves the I/O pattern itself is racy

Save the following as repro.mjs and run node repro.mjs. It uses only node:fs and mirrors the exact pattern pnpm uses at updateWorkspaceState and loadWorkspaceState:

import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";

const FILE = path.join(os.tmpdir(), "pnpm-race-demo-state.json");
const WORKERS = 8, ITERS = 500, PAYLOAD_KB = 200;

const payload = JSON.stringify({
  projects: Array.from({ length: 50 }, (_, i) => ({ id: `pkg-${i}` })),
  pad: "x".repeat(PAYLOAD_KB * 1024),
}, null, 2) + "\n";

// Mirrors pnpm's updateWorkspaceState — direct writeFile, NOT atomic.
const update = () => fs.writeFileSync(FILE, payload);
// Mirrors pnpm's loadWorkspaceState — handles ENOENT but NOT parse errors.
const load = () => {
  let c;
  try { c = fs.readFileSync(FILE, "utf-8"); }
  catch (e) { if (e?.code === "ENOENT") return; throw e; }
  return JSON.parse(c);
};

if (process.argv[2] === "--worker") {
  for (let i = 0; i < ITERS; i++) {
    update();
    try { load(); }
    catch (e) {
      console.error(`worker ${process.pid} iter ${i}: ${e.message}`);
      process.exit(1);
    }
  }
  process.exit(0);
}

fs.writeFileSync(FILE, payload);
const procs = Array.from({ length: WORKERS }, () =>
  spawn(process.execPath, [fileURLToPath(import.meta.url), "--worker"], { stdio: "inherit" }));
let crashes = 0;
await Promise.all(procs.map(p =>
  new Promise(res => p.on("exit", code => { if (code !== 0) crashes++; res(); }))));
console.log(`Result: ${crashes}/${WORKERS} workers crashed.`);
process.exit(crashes > 0 ? 0 : 2);

ypical output on first run (Node 22, Linux, ~5 seconds):

worker 643 iter 0: Unexpected end of JSON input
worker 673 iter 1: Unexpected end of JSON input
worker 674 iter 1: Unexpected end of JSON input
worker 680 iter 1: Unexpected end of JSON input
worker 645 iter 12: Unterminated string in JSON at position 73728 (line 411 column 66418)
worker 681 iter 0: Unexpected end of JSON input
Result: 7/8 workers crashed.

(All errors are valid SyntaxError outcomes of JSON.parse on a partial read of a concurrent writeFile.)

B. Real-world — observed crash from a monorepo CI run

A monorepo where the build orchestrator fans out via pnpm run <target> per package (in our case via nx run-many --target=build --parallel=8) triggers the same crash unpredictably. The stack trace seen in CI:

pnpm: Unexpected end of JSON input
    at JSON.parse (<anonymous>)
    at loadWorkspaceState (/home/runner/setup-pnpm/.../pnpm/dist/pnpm.cjs:136939:19)
    at checkDepsStatus (/home/runner/setup-pnpm/.../pnpm/dist/pnpm.cjs:141974:71)
    at runDepsStatusCheck (/home/runner/setup-pnpm/.../pnpm/dist/pnpm.cjs:183766:91)
    at Object.handler (/home/runner/setup-pnpm/.../pnpm/dist/pnpm.cjs:183959:62)
    at async main (/home/runner/setup-pnpm/.../pnpm/dist/pnpm.cjs:193416:34)
    at async runPnpm (/home/runner/setup-pnpm/.../pnpm/dist/pnpm.cjs:193683:5)

The race is intermittent because the window depends on disk speed, payload size, and process scheduling. But the underlying pattern (A) is provably unsafe.

Describe the Bug

When two or more pnpm processes run concurrently in the same workspace (common when a task runner like Nx, Turbo, or Lerna spawns pnpm run <target> per package in parallel), they can race on node_modules/.pnpm-workspace-state-v1.json and one of them crashes with SyntaxError: Unexpected end of JSON input inside loadWorkspaceState.

Even if a user wants verifyDepsBeforeRun enabled (the v11 default is "install"), pnpm should not crash because two of its own processes raced on a cache file it owns. A stale or corrupt cache should degrade gracefully, not abort the command. This also covers other corruption sources: a crashed/killed pnpm during a previous write, full disk during write, etc.

Expected Behavior

Concurrent pnpm invocations in the same workspace should not crash on the deps-status cache file. A partial or corrupt node_modules/.pnpm-workspace-state-v1.json should be treated as a cache miss (same behavior as ENOENT), and writes should be atomic so concurrent readers never observe a truncated file.

Which Node.js version are you using?

22.11.0

Which operating systems have you used?

  • macOS
  • Windows
  • Linux

If your OS is a Linux based, which one it is? (Include the version if relevant)

ubuntu-latest (Github CI)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Fields

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions