diff --git a/common/changes/@microsoft/rush/package-manager-install-lock_2026-06-23-13-27.json b/common/changes/@microsoft/rush/package-manager-install-lock_2026-06-23-13-27.json new file mode 100644 index 0000000000..b11b30f952 --- /dev/null +++ b/common/changes/@microsoft/rush/package-manager-install-lock_2026-06-23-13-27.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Avoid acquiring the global package manager install lock when the requested package manager is already installed and no install lock file exists.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "EscapeB@users.noreply.github.com" +} diff --git a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts index 1296125a98..af687c59bb 100644 --- a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts +++ b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts @@ -298,47 +298,81 @@ export class InstallHelpers { node: process.versions.node }); + if ( + (await packageManagerMarker.isValidAsync()) && + !InstallHelpers._doesPackageManagerInstallLockFileExist(rushUserFolder, packageManagerAndVersion) + ) { + logIfConsoleOutputIsNotRestricted( + `Found ${packageManager} version ${packageManagerVersion} in ${packageManagerToolFolder}` + ); + InstallHelpers._ensureLocalPackageManagerSymlink( + rushConfiguration, + packageManager, + packageManagerToolFolder, + logIfConsoleOutputIsNotRestricted + ); + return; + } + logIfConsoleOutputIsNotRestricted(`Trying to acquire lock for ${packageManagerAndVersion}`); const lock: LockFile = await LockFile.acquireAsync(rushUserFolder, packageManagerAndVersion); logIfConsoleOutputIsNotRestricted(`Acquired lock for ${packageManagerAndVersion}`); - if (!(await packageManagerMarker.isValidAsync()) || lock.dirtyWhenAcquired) { - logIfConsoleOutputIsNotRestricted( - Colorize.bold(`Installing ${packageManager} version ${packageManagerVersion}\n`) - ); + try { + if (!(await packageManagerMarker.isValidAsync()) || lock.dirtyWhenAcquired) { + logIfConsoleOutputIsNotRestricted( + Colorize.bold(`Installing ${packageManager} version ${packageManagerVersion}\n`) + ); + + // note that this will remove the last-install flag from the directory + await Utilities.installPackageInDirectoryAsync({ + directory: packageManagerToolFolder, + packageName: packageManager, + version: rushConfiguration.packageManagerToolVersion, + tempPackageTitle: `${packageManager}-local-install`, + maxInstallAttempts: maxInstallAttempts, + // This is using a local configuration to install a package in a shared global location. + // Generally that's a bad practice, but in this case if we can successfully install + // the package at all, we can reasonably assume it's good for all the repositories. + // In particular, we'll assume that two different NPM registries cannot have two + // different implementations of the same version of the same package. + // This was needed for: https://github.com/microsoft/rushstack/issues/691 + commonRushConfigFolder: rushConfiguration.commonRushConfigFolder, + // Only filter npm-incompatible properties when the repo uses pnpm or yarn. + // If the repo uses npm, the .npmrc is already configured for npm, so don't filter. + filterNpmIncompatibleProperties: rushConfiguration.packageManager !== 'npm' + }); + + logIfConsoleOutputIsNotRestricted( + `Successfully installed ${packageManager} version ${packageManagerVersion}` + ); + } else { + logIfConsoleOutputIsNotRestricted( + `Found ${packageManager} version ${packageManagerVersion} in ${packageManagerToolFolder}` + ); + } - // note that this will remove the last-install flag from the directory - await Utilities.installPackageInDirectoryAsync({ - directory: packageManagerToolFolder, - packageName: packageManager, - version: rushConfiguration.packageManagerToolVersion, - tempPackageTitle: `${packageManager}-local-install`, - maxInstallAttempts: maxInstallAttempts, - // This is using a local configuration to install a package in a shared global location. - // Generally that's a bad practice, but in this case if we can successfully install - // the package at all, we can reasonably assume it's good for all the repositories. - // In particular, we'll assume that two different NPM registries cannot have two - // different implementations of the same version of the same package. - // This was needed for: https://github.com/microsoft/rushstack/issues/691 - commonRushConfigFolder: rushConfiguration.commonRushConfigFolder, - // Only filter npm-incompatible properties when the repo uses pnpm or yarn. - // If the repo uses npm, the .npmrc is already configured for npm, so don't filter. - filterNpmIncompatibleProperties: rushConfiguration.packageManager !== 'npm' - }); + await packageManagerMarker.createAsync(); - logIfConsoleOutputIsNotRestricted( - `Successfully installed ${packageManager} version ${packageManagerVersion}` - ); - } else { - logIfConsoleOutputIsNotRestricted( - `Found ${packageManager} version ${packageManagerVersion} in ${packageManagerToolFolder}` + InstallHelpers._ensureLocalPackageManagerSymlink( + rushConfiguration, + packageManager, + packageManagerToolFolder, + logIfConsoleOutputIsNotRestricted ); + } finally { + lock.release(); } + } - await packageManagerMarker.createAsync(); - + private static _ensureLocalPackageManagerSymlink( + rushConfiguration: RushConfiguration, + packageManager: PackageManagerName, + packageManagerToolFolder: string, + logIfConsoleOutputIsNotRestricted: (message?: string) => void + ): void { // Example: "C:\MyRepo\common\temp" FileSystem.ensureFolder(rushConfiguration.commonTempFolder); @@ -365,8 +399,23 @@ export class InstallHelpers { linkTargetPath: packageManagerToolFolder, newLinkPath: localPackageManagerToolFolder }); + } + + private static _doesPackageManagerInstallLockFileExist( + rushUserFolder: string, + packageManagerAndVersion: string + ): boolean { + for (const itemName of FileSystem.readFolderItemNames(rushUserFolder)) { + if (itemName === `${packageManagerAndVersion}.lock`) { + return true; + } + + if (itemName.startsWith(`${packageManagerAndVersion}#`) && itemName.endsWith('.lock')) { + return true; + } + } - lock.release(); + return false; } // Helper for getPackageManagerEnvironment diff --git a/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts b/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts index c6441d7484..e57f55f923 100644 --- a/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts +++ b/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts @@ -1,12 +1,17 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { type IPackageJson, JsonFile } from '@rushstack/node-core-library'; +import * as path from 'node:path'; + +import { FileSystem, type IPackageJson, JsonFile, LockFile } from '@rushstack/node-core-library'; import { StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; import { TestUtilities } from '@rushstack/heft-config-file'; import { InstallHelpers } from '../installManager/InstallHelpers'; import { RushConfiguration } from '../../api/RushConfiguration'; +import { LastInstallFlag } from '../../api/LastInstallFlag'; +import type { RushGlobalFolder } from '../../api/RushGlobalFolder'; +import { Utilities } from '../../utilities/Utilities'; describe('InstallHelpers', () => { describe('generateCommonPackageJson', () => { @@ -73,4 +78,71 @@ describe('InstallHelpers', () => { ); }); }); + + describe(InstallHelpers.ensureLocalPackageManagerAsync.name, () => { + const tempFolderPath: string = `${__dirname}/temp/${InstallHelpers.name}`; + + beforeEach(() => { + FileSystem.ensureEmptyFolder(tempFolderPath); + }); + + afterEach(() => { + FileSystem.deleteFolder(tempFolderPath); + jest.restoreAllMocks(); + }); + + it('does not acquire the global lock when the package manager is already installed', async () => { + const rushGlobalFolder: RushGlobalFolder = { + path: `${tempFolderPath}/rush-global`, + nodeSpecificPath: `${tempFolderPath}/rush-global/node-${process.version}` + } as RushGlobalFolder; + const packageManagerToolFolder: string = path.join(rushGlobalFolder.nodeSpecificPath, 'pnpm-10.27.0'); + await new LastInstallFlag(packageManagerToolFolder, { node: process.versions.node }).createAsync(); + + const lockAcquireSpy: jest.SpyInstance = jest.spyOn(LockFile, 'acquireAsync'); + const rushConfiguration: RushConfiguration = { + commonRushConfigFolder: `${tempFolderPath}/common/config/rush`, + commonTempFolder: `${tempFolderPath}/common/temp`, + packageManager: 'pnpm', + packageManagerToolVersion: '10.27.0' + } as RushConfiguration; + + await InstallHelpers.ensureLocalPackageManagerAsync(rushConfiguration, rushGlobalFolder, 1, true); + + expect(lockAcquireSpy).not.toHaveBeenCalled(); + expect(FileSystem.exists(`${rushConfiguration.commonTempFolder}/pnpm-local`)).toEqual(true); + }); + + it('acquires the global lock if an install lock file is present', async () => { + const rushGlobalFolder: RushGlobalFolder = { + path: `${tempFolderPath}/rush-global`, + nodeSpecificPath: `${tempFolderPath}/rush-global/node-${process.version}` + } as RushGlobalFolder; + const packageManagerToolFolder: string = path.join(rushGlobalFolder.nodeSpecificPath, 'pnpm-10.27.0'); + await new LastInstallFlag(packageManagerToolFolder, { node: process.versions.node }).createAsync(); + FileSystem.writeFile(`${rushGlobalFolder.nodeSpecificPath}/pnpm-10.27.0#123.lock`, ''); + + const releaseAsync: jest.Mock = jest.fn(); + const lockAcquireSpy: jest.SpyInstance = jest.spyOn(LockFile, 'acquireAsync').mockResolvedValue({ + dirtyWhenAcquired: true, + release: releaseAsync + } as unknown as LockFile); + const installSpy: jest.SpyInstance = jest + .spyOn(Utilities, 'installPackageInDirectoryAsync') + .mockResolvedValue(); + const rushConfiguration: RushConfiguration = { + commonRushConfigFolder: `${tempFolderPath}/common/config/rush`, + commonTempFolder: `${tempFolderPath}/common/temp`, + packageManager: 'pnpm', + packageManagerToolVersion: '10.27.0' + } as RushConfiguration; + + await InstallHelpers.ensureLocalPackageManagerAsync(rushConfiguration, rushGlobalFolder, 1, true); + + expect(lockAcquireSpy).toHaveBeenCalledTimes(1); + expect(installSpy).toHaveBeenCalledTimes(1); + expect(releaseAsync).toHaveBeenCalledTimes(1); + expect(FileSystem.exists(`${rushConfiguration.commonTempFolder}/pnpm-local`)).toEqual(true); + }); + }); });