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/api/LastInstallFlag.ts b/libraries/rush-lib/src/api/LastInstallFlag.ts index a0af3df9dd..114ab5df20 100644 --- a/libraries/rush-lib/src/api/LastInstallFlag.ts +++ b/libraries/rush-lib/src/api/LastInstallFlag.ts @@ -1,9 +1,19 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import * as path from 'node:path'; + import { pnpmSyncGetJsonVersion } from 'pnpm-sync-lib'; -import { JsonFile, type JsonObject, Path, type IPackageJson, Objects } from '@rushstack/node-core-library'; +import { + FileSystem, + JsonFile, + LockFile, + type JsonObject, + Path, + type IPackageJson, + Objects +} from '@rushstack/node-core-library'; import type { PackageManagerName } from './packageManager/PackageManager'; import type { RushConfiguration } from './RushConfiguration'; @@ -182,6 +192,35 @@ export class LastInstallFlag extends FlagFile> { } } +/** + * Checks for a LockFile associated with the same install resource as a LastInstallFlag. + * + * @internal + */ +export async function doesLastInstallFlagLockFileExistAsync( + folderPath: string, + lockFileResourceName: string +): Promise { + const lockFileName: string = path.basename(LockFile.getLockFilePath(folderPath, lockFileResourceName)); + const pidLockFileNameRegExp: RegExp = /^.+#([0-9]+)\.lock$/; + const folderItemNames: string[] = await FileSystem.readFolderItemNamesAsync(folderPath); + for (const itemName of folderItemNames) { + if (itemName === lockFileName) { + return true; + } + + const match: RegExpMatchArray | null = itemName.match(pidLockFileNameRegExp); + if ( + match && + itemName === path.basename(LockFile.getLockFilePath(folderPath, lockFileResourceName, Number(match[1]))) + ) { + return true; + } + } + + return false; +} + /** * Gets the LastInstall flag and sets the current state. This state is used to compare * against the last-known-good state tracked by the LastInstall flag. diff --git a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts index 397c039ed2..6274a3ca28 100644 --- a/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts +++ b/libraries/rush-lib/src/logic/installManager/InstallHelpers.ts @@ -12,7 +12,7 @@ import { } from '@rushstack/node-core-library'; import { Colorize, type ITerminal } from '@rushstack/terminal'; -import { LastInstallFlag } from '../../api/LastInstallFlag'; +import { doesLastInstallFlagLockFileExistAsync, LastInstallFlag } from '../../api/LastInstallFlag'; import type { PackageManagerName } from '../../api/packageManager/PackageManager'; import type { RushConfiguration } from '../../api/RushConfiguration'; import type { RushGlobalFolder } from '../../api/RushGlobalFolder'; @@ -427,73 +427,108 @@ export class InstallHelpers { node: process.versions.node }); - 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`) - ); - - // 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' - }); + const isPackageManagerMarkerValid: boolean = await packageManagerMarker.isValidAsync(); + const packageManagerInstallLockFileExists: boolean = await doesLastInstallFlagLockFileExistAsync( + rushUserFolder, + packageManagerAndVersion + ); - logIfConsoleOutputIsNotRestricted( - `Successfully installed ${packageManager} version ${packageManagerVersion}` - ); - } else { + if (isPackageManagerMarkerValid && !packageManagerInstallLockFileExists) { logIfConsoleOutputIsNotRestricted( `Found ${packageManager} version ${packageManagerVersion} in ${packageManagerToolFolder}` ); + await _ensureLocalPackageManagerSymlinkAsync( + rushConfiguration, + packageManager, + packageManagerToolFolder, + logIfConsoleOutputIsNotRestricted + ); + return; } - await packageManagerMarker.createAsync(); - - // Example: "C:\MyRepo\common\temp" - FileSystem.ensureFolder(rushConfiguration.commonTempFolder); + logIfConsoleOutputIsNotRestricted(`Trying to acquire lock for ${packageManagerAndVersion}`); - // Example: "C:\MyRepo\common\temp\pnpm-local" - const localPackageManagerToolFolder: string = `${rushConfiguration.commonTempFolder}/${packageManager}-local`; + const lock: LockFile = await LockFile.acquireAsync(rushUserFolder, packageManagerAndVersion); - logIfConsoleOutputIsNotRestricted(`\nSymlinking "${localPackageManagerToolFolder}"`); - logIfConsoleOutputIsNotRestricted(` --> "${packageManagerToolFolder}"`); + logIfConsoleOutputIsNotRestricted(`Acquired lock for ${packageManagerAndVersion}`); - // We cannot use FileSystem.exists() to test the existence of a symlink, because it will - // return false for broken symlinks. There is no way to test without catching an exception. try { - await FileSystem.deleteFolderAsync(localPackageManagerToolFolder); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error; + 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}` + ); } - } - await FileSystem.createSymbolicLinkJunctionAsync({ - linkTargetPath: packageManagerToolFolder, - newLinkPath: localPackageManagerToolFolder - }); + await packageManagerMarker.createAsync(); + + await _ensureLocalPackageManagerSymlinkAsync( + rushConfiguration, + packageManager, + packageManagerToolFolder, + logIfConsoleOutputIsNotRestricted + ); + } finally { + lock.release(); + } + } +} - lock.release(); +async function _ensureLocalPackageManagerSymlinkAsync( + rushConfiguration: RushConfiguration, + packageManager: PackageManagerName, + packageManagerToolFolder: string, + logIfConsoleOutputIsNotRestricted: (message?: string) => void +): Promise { + // Example: "C:\MyRepo\common\temp" + FileSystem.ensureFolder(rushConfiguration.commonTempFolder); + + // Example: "C:\MyRepo\common\temp\pnpm-local" + const localPackageManagerToolFolder: string = `${rushConfiguration.commonTempFolder}/${packageManager}-local`; + + logIfConsoleOutputIsNotRestricted(`\nSymlinking "${localPackageManagerToolFolder}"`); + logIfConsoleOutputIsNotRestricted(` --> "${packageManagerToolFolder}"`); + + // We cannot use FileSystem.exists() to test the existence of a symlink, because it will + // return false for broken symlinks. There is no way to test without catching an exception. + try { + await FileSystem.deleteFolderAsync(localPackageManagerToolFolder); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } } + + await FileSystem.createSymbolicLinkJunctionAsync({ + linkTargetPath: packageManagerToolFolder, + newLinkPath: localPackageManagerToolFolder + }); } // 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 15a8d90969..12bead34be 100644 --- a/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts +++ b/libraries/rush-lib/src/logic/test/InstallHelpers.test.ts @@ -1,13 +1,16 @@ // 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 { 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 type { PnpmWorkspaceFile } from '../pnpm/PnpmWorkspaceFile'; +import { LastInstallFlag } from '../../api/LastInstallFlag'; +import type { RushGlobalFolder } from '../../api/RushGlobalFolder'; +import { Utilities } from '../../utilities/Utilities'; describe(InstallHelpers.name, () => { describe(InstallHelpers.generateCommonPackageJsonAsync.name, () => { @@ -34,6 +37,10 @@ describe(InstallHelpers.name, () => { mockJsonFileSaveAsync.mockClear(); }); + afterAll(() => { + jest.restoreAllMocks(); + }); + it('generates correct package json with pnpm configurations', async () => { const RUSH_JSON_FILENAME: string = `${__dirname}/pnpmConfig/rush.json`; const rushConfiguration: RushConfiguration = @@ -119,4 +126,126 @@ describe(InstallHelpers.name, () => { expect(workspaceFile?.trustPolicyIgnoreAfter).toEqual(1440); }); }); + + describe(InstallHelpers.ensureLocalPackageManagerAsync.name, () => { + const tempFolderPath: string = `${__dirname}/temp/${InstallHelpers.name}`; + const packageManager: 'pnpm' = 'pnpm'; + const packageManagerVersion: string = '10.27.0'; + + function getRushGlobalFolder(): RushGlobalFolder { + return { + path: `${tempFolderPath}/rush-global`, + nodeSpecificPath: `${tempFolderPath}/rush-global/node-${process.version}` + } as RushGlobalFolder; + } + + function getRushConfiguration(): RushConfiguration { + return { + commonRushConfigFolder: `${tempFolderPath}/common/config/rush`, + commonTempFolder: `${tempFolderPath}/common/temp`, + packageManager, + packageManagerToolVersion: packageManagerVersion + } as RushConfiguration; + } + + function getPackageManagerAndVersion(): string { + return `${packageManager}-${packageManagerVersion}`; + } + + function getPackageManagerToolFolder(rushGlobalFolder: RushGlobalFolder): string { + return `${rushGlobalFolder.nodeSpecificPath}/${getPackageManagerAndVersion()}`; + } + + async function writeInstalledPackageManagerAsync(rushGlobalFolder: RushGlobalFolder): Promise { + const packageManagerToolFolder: string = getPackageManagerToolFolder(rushGlobalFolder); + + await Promise.all([ + JsonFile.saveAsync( + { + dependencies: { + [packageManager]: packageManagerVersion + }, + description: 'Temporary file generated by the Rush tool', + name: `${packageManager}-local-install`, + private: true, + version: '0.0.0' + }, + `${packageManagerToolFolder}/package.json`, + { ensureFolderExists: true } + ), + JsonFile.saveAsync( + { + name: packageManager, + version: packageManagerVersion + }, + `${packageManagerToolFolder}/node_modules/${packageManager}/package.json`, + { ensureFolderExists: true } + ), + FileSystem.writeFileAsync(`${packageManagerToolFolder}/node_modules/.bin/${packageManager}`, '', { + ensureFolderExists: true + }) + ]); + + await new LastInstallFlag(packageManagerToolFolder, { node: process.versions.node }).createAsync(); + } + + beforeEach(() => { + jest.restoreAllMocks(); + 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 = getRushGlobalFolder(); + await writeInstalledPackageManagerAsync(rushGlobalFolder); + + const lockAcquireSpy: jest.SpyInstance = jest + .spyOn(LockFile, 'acquireAsync') + .mockResolvedValue(false as unknown as LockFile); + const installSpy: jest.SpyInstance = jest + .spyOn(Utilities, 'installPackageInDirectoryAsync') + .mockRejectedValue(new Error('The package manager should already be installed.')); + const rushConfiguration: RushConfiguration = getRushConfiguration(); + + await InstallHelpers.ensureLocalPackageManagerAsync(rushConfiguration, rushGlobalFolder, 1, true); + + expect(lockAcquireSpy).not.toHaveBeenCalled(); + expect(installSpy).not.toHaveBeenCalled(); + await expect( + FileSystem.existsAsync(`${rushConfiguration.commonTempFolder}/pnpm-local`) + ).resolves.toEqual(true); + }); + + it('acquires the global lock if an install lock file is present', async () => { + const rushGlobalFolder: RushGlobalFolder = getRushGlobalFolder(); + await writeInstalledPackageManagerAsync(rushGlobalFolder); + await FileSystem.writeFileAsync( + LockFile.getLockFilePath(rushGlobalFolder.nodeSpecificPath, getPackageManagerAndVersion(), 123), + '' + ); + + const releaseLockMock: jest.Mock = jest.fn(); + const lockAcquireSpy: jest.SpyInstance = jest.spyOn(LockFile, 'acquireAsync').mockResolvedValue({ + dirtyWhenAcquired: true, + release: releaseLockMock + } as unknown as LockFile); + const installSpy: jest.SpyInstance = jest + .spyOn(Utilities, 'installPackageInDirectoryAsync') + .mockResolvedValue(); + const rushConfiguration: RushConfiguration = getRushConfiguration(); + + await InstallHelpers.ensureLocalPackageManagerAsync(rushConfiguration, rushGlobalFolder, 1, true); + + expect(lockAcquireSpy).toHaveBeenCalledTimes(1); + expect(installSpy).toHaveBeenCalledTimes(1); + expect(releaseLockMock).toHaveBeenCalledTimes(1); + await expect( + FileSystem.existsAsync(`${rushConfiguration.commonTempFolder}/pnpm-local`) + ).resolves.toEqual(true); + }); + }); });