mirror of
https://github.com/pnpm/pnpm.git
synced 2026-04-27 18:46:18 -04:00
fix(link-bins): skip relinking bins that already point at the correct target (#11069)
## Summary `linkBin()` unconditionally calls `cmdShim()` / `symlinkDir()` even when the target bin already points at the correct path. This causes redundant I/O on repeated installs and `EACCES` failures when the bin directory lives on a read-only filesystem (Docker layer caching, CI prewarm, NFS mounts). This PR adds a check at the top of `linkBin()` that verifies the existing bin before skipping: - **Symlinks**: `readlink` target is compared against `cmd.path` - **Cmd-shim files**: checked via `isShimPointingAt()` from `@zkochan/cmd-shim` v9, which embeds a `# cmd-shim-target=<path>` marker in every generated sh shim - Files larger than 4KB (binaries) are never skipped — they are not cmd-shims Stale or incorrect bins (wrong target, missing marker, different provider) are always rewritten. Follows up on feedback from #11020. ## Changes - `bins/linker/src/index.ts` — add target verification check in `linkBin()` - `bins/linker/test/index.ts` — tests for skip and rewrite behavior - `pnpm-workspace.yaml` — upgrade `@zkochan/cmd-shim` to v9 --------- Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
6
.changeset/skip-existing-bin-files.md
Normal file
6
.changeset/skip-existing-bin-files.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@pnpm/bins.linker": patch
|
||||
"pnpm": patch
|
||||
---
|
||||
|
||||
Skip linking bins that already reference the correct target. This avoids redundant I/O during repeated installs and prevents permission errors when the store is read-only (e.g. Docker layer caching, CI prewarm, NFS). Bins that reference a stale or incorrect target are still rewritten.
|
||||
@@ -10,7 +10,7 @@ import { readPackageJsonFromDir } from '@pnpm/pkg-manifest.reader'
|
||||
import { getAllDependenciesFromManifest } from '@pnpm/pkg-manifest.utils'
|
||||
import type { DependencyManifest, EngineDependency, ProjectManifest } from '@pnpm/types'
|
||||
import { safeReadProjectManifestOnly } from '@pnpm/workspace.project-manifest-reader'
|
||||
import cmdShim from '@zkochan/cmd-shim'
|
||||
import { cmdShim, isShimPointingAt } from '@zkochan/cmd-shim'
|
||||
import { rimraf } from '@zkochan/rimraf'
|
||||
import fixBin from 'bin-links/lib/fix-bin.js'
|
||||
import { isSubdir } from 'is-subdir'
|
||||
@@ -26,6 +26,8 @@ const binsConflictLogger = logger('bins-conflict')
|
||||
const IS_WINDOWS = isWindows()
|
||||
const EXECUTABLE_SHEBANG_SUPPORTED = !IS_WINDOWS
|
||||
const POWER_SHELL_IS_SUPPORTED = IS_WINDOWS
|
||||
// A cmd-shim is a small shell script. Anything larger is a binary and should not be read.
|
||||
const CMD_SHIM_MAX_SIZE = 4 * 1024
|
||||
|
||||
export type WarningCode = 'BINARIES_CONFLICT' | 'EMPTY_BIN'
|
||||
|
||||
@@ -258,6 +260,24 @@ export interface LinkBinOptions {
|
||||
|
||||
async function linkBin (cmd: CommandInfo, binsDir: string, opts?: LinkBinOptions): Promise<void> {
|
||||
const externalBinPath = path.join(binsDir, cmd.name)
|
||||
// Skip if the existing bin already references the correct target.
|
||||
// This avoids redundant I/O on warm installs and EACCES on read-only stores.
|
||||
// We verify the target path — not just existence — so that conflict resolution
|
||||
// changes or provider swaps still get the bin rewritten.
|
||||
try {
|
||||
const stat = await fs.lstat(externalBinPath)
|
||||
if (stat.isSymbolicLink()) {
|
||||
const target = await fs.readlink(externalBinPath)
|
||||
if (target === cmd.path || path.resolve(binsDir, target) === path.resolve(cmd.path)) {
|
||||
return
|
||||
}
|
||||
} else if (stat.isFile() && stat.size < CMD_SHIM_MAX_SIZE) {
|
||||
const content = await fs.readFile(externalBinPath, 'utf8')
|
||||
if (isShimPointingAt(content, cmd.path)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
if (IS_WINDOWS) {
|
||||
const exePath = path.join(binsDir, `${cmd.name}${getExeExtension()}`)
|
||||
if (existsSync(exePath)) {
|
||||
|
||||
@@ -76,6 +76,44 @@ test('linkBins()', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('linkBins() skips bins that already reference the correct target', async () => {
|
||||
const binTarget = temporaryDirectory()
|
||||
const warn = jest.fn()
|
||||
const simpleFixture = f.prepare('simple-fixture')
|
||||
|
||||
await linkBins(path.join(simpleFixture, 'node_modules'), binTarget, { warn })
|
||||
|
||||
const binLocation = path.join(binTarget, 'simple')
|
||||
expect(fs.existsSync(binLocation)).toBe(true)
|
||||
const originalContent = fs.readFileSync(binLocation, 'utf8')
|
||||
// The bin contains a cmd-shim-target marker with the correct target path
|
||||
const expectedTarget = normalizePath(path.join(simpleFixture, 'node_modules', 'simple', 'index.js'))
|
||||
expect(originalContent).toContain(`# cmd-shim-target=${expectedTarget}\n`)
|
||||
// Append a sentinel to the existing (correct) content to prove it is not rewritten
|
||||
const sentinel = originalContent + '\n# sentinel'
|
||||
fs.writeFileSync(binLocation, sentinel, 'utf8')
|
||||
|
||||
await linkBins(path.join(simpleFixture, 'node_modules'), binTarget, { warn })
|
||||
|
||||
expect(fs.readFileSync(binLocation, 'utf8')).toBe(sentinel)
|
||||
})
|
||||
|
||||
test('linkBins() rewrites bins that lack a target marker', async () => {
|
||||
const binTarget = temporaryDirectory()
|
||||
const warn = jest.fn()
|
||||
const simpleFixture = f.prepare('simple-fixture')
|
||||
|
||||
// Create a stale bin without a cmd-shim-target marker
|
||||
fs.mkdirSync(binTarget, { recursive: true })
|
||||
const binLocation = path.join(binTarget, 'simple')
|
||||
fs.writeFileSync(binLocation, '#!/bin/sh\n"$basedir/../wrong-pkg/index.js" "$@"', 'utf8')
|
||||
|
||||
await linkBins(path.join(simpleFixture, 'node_modules'), binTarget, { warn })
|
||||
|
||||
const content = fs.readFileSync(binLocation, 'utf8')
|
||||
expect(content).not.toContain('wrong-pkg')
|
||||
})
|
||||
|
||||
test('linkBins() never creates a PowerShell shim for the pnpm CLI', async () => {
|
||||
const binTarget = temporaryDirectory()
|
||||
const fixture = f.prepare('pnpm-cli')
|
||||
|
||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -425,8 +425,8 @@ catalogs:
|
||||
specifier: ^4.0.8
|
||||
version: 4.1.3
|
||||
'@zkochan/cmd-shim':
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.1
|
||||
specifier: ^9.0.0
|
||||
version: 9.0.0
|
||||
'@zkochan/retry':
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
@@ -1436,7 +1436,7 @@ importers:
|
||||
version: link:../../workspace/project-manifest-reader
|
||||
'@zkochan/cmd-shim':
|
||||
specifier: 'catalog:'
|
||||
version: 8.0.1
|
||||
version: 9.0.0
|
||||
'@zkochan/rimraf':
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.0
|
||||
@@ -11562,8 +11562,8 @@ packages:
|
||||
resolution: {integrity: sha512-E5mgrRS8Kk80n19Xxmrx5qO9UG03FyZd8Me5gxYi++VPZsOv8+OsclA+0Fth4KTDCrQ/FkJryNFKJ6/642lo4g==}
|
||||
engines: {node: '>=18.12'}
|
||||
|
||||
'@zkochan/cmd-shim@8.0.1':
|
||||
resolution: {integrity: sha512-fz72az47CZQE5vD+NWd51VBq8II6FAebp+SXRsS0b+sNHUXNFA3/F1dgBSkNQO6/BClomsV4WaRFqAkLdUe1zw==}
|
||||
'@zkochan/cmd-shim@9.0.0':
|
||||
resolution: {integrity: sha512-UH8qOvWZjZJr/mLk5xbrhGmBAf8iLbDkCZCyjnXri5cVFpvZgBUszZJmTmvDTIrzLAZ8lb++qvedOdsRokaQBg==}
|
||||
engines: {node: '>=22.13'}
|
||||
|
||||
'@zkochan/diable@1.0.2':
|
||||
@@ -19868,11 +19868,9 @@ snapshots:
|
||||
graceful-fs: 4.2.11(patch_hash=68ebc232025360cb3dcd3081f4067f4e9fc022ab6b6f71a3230e86c7a5b337d1)
|
||||
is-windows: 1.0.2
|
||||
|
||||
'@zkochan/cmd-shim@8.0.1':
|
||||
'@zkochan/cmd-shim@9.0.0':
|
||||
dependencies:
|
||||
cmd-extension: 1.0.2
|
||||
graceful-fs: 4.2.11(patch_hash=68ebc232025360cb3dcd3081f4067f4e9fc022ab6b6f71a3230e86c7a5b337d1)
|
||||
is-windows: 1.0.2
|
||||
|
||||
'@zkochan/diable@1.0.2':
|
||||
dependencies:
|
||||
|
||||
@@ -148,7 +148,7 @@ catalog:
|
||||
'@yarnpkg/nm': 4.0.7
|
||||
'@yarnpkg/parsers': 3.0.3
|
||||
'@yarnpkg/pnp': ^4.0.8
|
||||
'@zkochan/cmd-shim': ^8.0.1
|
||||
'@zkochan/cmd-shim': ^9.0.0
|
||||
'@zkochan/retry': ^0.2.0
|
||||
'@zkochan/rimraf': ^4.0.0
|
||||
'@zkochan/table': ^2.0.1
|
||||
@@ -349,7 +349,7 @@ minimumReleaseAgeExclude:
|
||||
- '@pnpm/*'
|
||||
- '@rushstack/worker-pool@0.7.7'
|
||||
- '@zkochan/*'
|
||||
- '@zkochan/cmd-shim@8.0.0 || 8.0.1'
|
||||
- '@zkochan/cmd-shim@9.0.0'
|
||||
- better-path-resolve
|
||||
- body-parser@2.2.1
|
||||
- can-link
|
||||
|
||||
Reference in New Issue
Block a user