POSIX never gave you a way to ask what your umask is. umask(2) installs a new mask and hands back the old one, so the only portable read is a write: set it to zero, keep the return value, put the old value back. On single-threaded code that costs nothing. pnpm's Rust CLI is not single-threaded, and the 12.16.0 release notes, published 27 October 2026, carry the result in one line: reading the file-creation mask on Linux no longer sets it, because pnpm had to replace the mask to learn what it was, and a file another thread created between the two calls was created unmasked.
A pnpm store is shared state across every project on the box, and bad shared state there outlives the install that produced it. Another artifact of that sharing is pnpm build scripts not running. What makes this one worse is that the damaged file is not just world-writable. It is also permanently excluded from pnpm's hardlink tier, and the upgrade does nothing about either half.
Why can nothing read a umask?
Node documents the identical defect in its own API. DEP0139 marks process.umask() with no arguments deprecated since v14.0.0 and v12.19.0, documentation-only, with this rationale: "Calling process.umask() with no argument causes the process-wide umask to be written twice. This introduces a race condition between threads, and is a potential security vulnerability. There is no safe, cross-platform alternative API."
Linux has an escape hatch, and it exists for exactly this complaint. Richard W.M. Jones posted the procfs patch to linux-kernel on 14 April 2016, adapted from earlier work by Pierre Carrier, arguing that a library cannot check the umask without changing it, which is dangerous when the main program is multithreaded (LWN coverage). man 5 proc_pid_status now prints: "Umask: Process umask, expressed in octal with a leading zero; see umask(2). (Since Linux 4.7.)" The field only appears for tasks that have a task->fs (man7).
So the read was always available on Linux and pnpm was not using it. Ten years of availability, and a package manager written in 2026 still shipped the syscall pair.
What pnpm did in those microseconds
PR #17219, fix(fs/linux): read the umask without writing it, was merged by zkochan on 22 October 2026 with a changeset marking pacquet: patch. current_umask in pnpm/crates/fs used zero-and-restore; it now parses the Umask: line out of /proc/thread-self/status and falls back to the syscall pair where that line is unavailable. The PR's handling of the counter-argument is the part worth quoting: an earlier defence of the window covered executable bits and import-tier linking, which "says nothing about the file's permissions, which is the defect."
Commit 86de902 moved the path from /proc/self to /proc/thread-self, and the reason is thinner than it looks. /proc/self/status reports the thread group leader's mask while the syscall fallback reports the calling thread's, so the two code paths could disagree on a process that unshared filesystem info. pnpm never does that, so no answer changes today. /proc/thread-self dates to Linux 3.17, older than the Umask: line, which makes the field the binding constraint rather than the path. The Rust CLI is new enough that its filesystem layer is still producing firsts; the 12.6 line shipped invalid peer certificate: UnknownIssuer.
The LazyLock that caches the mask survived the fix untouched, so the window opened once per process and stayed open for microseconds. Narrow odds, except that the 12.16.0 release notes also record that pnpm now links downloaded packages into the virtual store on a separate thread, so a slow link pass no longer pauses in-flight downloads. The concurrency that makes installs fast is the concurrency that fills the window.
Why pnpm wants your umask at all
Not for tidiness. The mode of a store inode decides whether pnpm hardlinks it into your node_modules/.pnpm or copies it, and pnpm compares that mode against what a fresh file would get under your umask.
PR #16678, merged 7 October 2026, loosened a check that had demanded an exact match. The Qodo review on the PR reads the Rust bitmask in pnpm/crates/fs/src/file_mode.rs as (mode ^ desired) & 0o777 & !GROUP_OTHER_WRITE == 0 && mode & !desired & 0o002 == 0. Tolerated: a store inode missing group-write or other-write, or carrying an extra group-write. Rejected: an extra other-write bit, and any difference in read, execute or owner bits. The entry point is pnpm_fs::file_mode::store_inode_mode_is_linkable, called from source_has_desired_mode in pnpm/crates/deps-restorer/src/link_file/target_mode.rs; the v11 TypeScript equivalent is isLinkableStoreMode in @pnpm/fs.indexed-pkg-importer.
That PR also records what the strict version cost people: under umask 000 against a 0777 store, every file was copied and installs ran about 30% slower, which is issue #16677.
The file that gets copied on every install, forever
Put the two PRs side by side and you get a consequence neither one states.
A file created inside the unmask window gets the mode its creating call asked for, with nothing subtracted, and on a store write that includes other-write. Since #15882 a normal store write inherits group-write from the shard directory and never sets other-write. An other-writable file in a pnpm store therefore has no accounted-for route except that window or a deliberately widened storeDir. And an extra other-write bit is the one difference the linkability check rejects outright.
The file is world-writable, and it is copied on every install, in every project that resolves that package, for as long as the store lives. pnpm's reason to write it again never arrives, because content addressing means the hash is already present. Hardlinking a store into a project has sharp edges elsewhere in the ecosystem too: uv 0.13.0 sdist hard links. Here the permission bug and the performance bug are one inode.
Does 12.16.0 actually fix your host?
"On Linux" overstates it, and the PR thread says as much. CodeRabbit asked for the changeset to qualify the claim for hosts without procfs; the author declined on changeset-style grounds and left it for the maintainer. On a builder with no /proc mounted, or a kernel below 4.7, 12.16.0 silently takes the old zero-and-restore path. One command answers it for your box:
grep Umask /proc/thread-self/status
No output means you upgraded into the fallback. On any Unix that is not Linux, there is no procfs path to take at all.
Mode handling in this area has been fragile all month. PR #17104, merged 19 October 2026, repaired macOS store directories the owner cannot read: fs.lchmodSync opened with O_WRONLY | O_SYMLINK before fchmod, so the install died with EISDIR on every directory. The fix opens with O_SEARCH plus O_NOFOLLOW, then fstat and fchmod on the descriptor.
Who actually eats this
Shared CI runners and shared storeDir installs take the real damage. Under umask 077, a file written unmasked becomes readable and writable by every other tenant on the box, and pnpm will never rewrite it to narrow the bits back down.
Container builds take the performance half. A store baked into a lower layer that fails the tier check gets copied, which means copy-up, which means layer size and install time you will blame on the registry.
And then there is everyone who has filed "pnpm is slower than it should be" without once suspecting the umask line in their own /etc/profile.
Honest scope: the window opens once per process and lasts microseconds, and it only catches a file whose creation lands inside it. Most installs will never produce one. The asymmetry is that probability is low and persistence is total, which is the shape of defect worth a single audit pass rather than a monitoring dashboard.
What to run against your store this week
Check behaviour before you trust the version string. Brotli patch day taught me that habit: I ran Node's own invalid-block-switch vector against my box instead of reading node -v, and the decoder answered errno=-9 on an EOL Node 20 line that will never get the fix (php2shell and Node Brotli RCE). Vendored and repackaged CLIs make a version table wrong in both directions.
- Probe the running binary, not the tag:
strace -f -e trace=umask,openat pnpm install 2>&1 | grep -E 'umask\(|thread-self'
A umask() pair means you are on the old read. An openat of /proc/thread-self/status with no umask() call means you are on the new one.
- Confirm the host can even take the fix with
grep Umask /proc/thread-self/status. Empty output plus 12.16.0 means fallback, so treat the host as unfixed. - Audit what already exists, because the upgrade is not retroactive:
find "$(pnpm store path)" -type f -perm -o+w
Every hit is both a permission artifact and a permanent copy. Fix it with chmod o-w on those paths, or prune and re-download them. A pnpm version bump will not touch them.
- Measure how much of your install the tier check is rejecting, on a warm store:
find node_modules/.pnpm -type f -links 1 | wc -l
A large count on a store built under a different umask than the one installing is the #3807 case from PR #16678, not a regression.
- Pin the umask explicitly in CI jobs and in image builds, so the store and every install that consumes it agree.
umask 022written once in the job definition is cheaper than the 30% copy penalty issue #16677 describes.
Delete step 1 from your runbook when pnpm removes the syscall fallback entirely; until then, the strace output is the only claim about your host that is actually checkable.
Comments
Be the first to comment.