Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
31 changes: 30 additions & 1 deletion libraries/rush-lib/src/api/LastInstallFlag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@

import { pnpmSyncGetJsonVersion } from 'pnpm-sync-lib';

import { JsonFile, type JsonObject, Path, type IPackageJson, Objects } from '@rushstack/node-core-library';
import {
FileSystem,
JsonFile,
type JsonObject,
Path,
type IPackageJson,
Objects
} from '@rushstack/node-core-library';

import type { PackageManagerName } from './packageManager/PackageManager';
import type { RushConfiguration } from './RushConfiguration';
Expand Down Expand Up @@ -182,6 +189,28 @@ export class LastInstallFlag extends FlagFile<Partial<ILastInstallFlagJson>> {
}
}

/**
* Checks for a LockFile associated with the same install resource as a LastInstallFlag.
*
* @internal
*/
export async function doesLastInstallFlagLockFileExistAsync(
folderPath: string,
lockFileResourceName: string
): Promise<boolean> {
for (const itemName of await FileSystem.readFolderItemNamesAsync(folderPath)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
for (const itemName of await FileSystem.readFolderItemNamesAsync(folderPath)) {
const folderItemNames: string[] = await FileSystem.readFolderItemNamesAsync(folderPath);
for (const itemName of folderItemNames) {

if (itemName === `${lockFileResourceName}.lock`) {
return true;
}

if (itemName.startsWith(`${lockFileResourceName}#`) && itemName.endsWith('.lock')) {
return true;
}
Comment on lines +202 to +208

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where do these names come from? Can you share code with whatever generates them when they're written?

}

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.
Expand Down
101 changes: 68 additions & 33 deletions libraries/rush-lib/src/logic/installManager/InstallHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -424,47 +424,84 @@ export class InstallHelpers {
node: process.versions.node
});

const isPackageManagerMarkerValid: boolean = await packageManagerMarker.isValidAsync();
const packageManagerInstallLockFileExists: boolean = await doesLastInstallFlagLockFileExistAsync(
rushUserFolder,
packageManagerAndVersion
);

if (isPackageManagerMarkerValid && !packageManagerInstallLockFileExists) {
logIfConsoleOutputIsNotRestricted(
`Found ${packageManager} version ${packageManagerVersion} in ${packageManagerToolFolder}`
);
await InstallHelpers._ensureLocalPackageManagerSymlinkAsync(
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'
});
// 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}`
);
}

logIfConsoleOutputIsNotRestricted(
`Successfully installed ${packageManager} version ${packageManagerVersion}`
);
} else {
logIfConsoleOutputIsNotRestricted(
`Found ${packageManager} version ${packageManagerVersion} in ${packageManagerToolFolder}`
await packageManagerMarker.createAsync();

await InstallHelpers._ensureLocalPackageManagerSymlinkAsync(
rushConfiguration,
packageManager,
packageManagerToolFolder,
logIfConsoleOutputIsNotRestricted
);
} finally {
lock.release();
}
}

await packageManagerMarker.createAsync();

private static async _ensureLocalPackageManagerSymlinkAsync(
rushConfiguration: RushConfiguration,
packageManager: PackageManagerName,
packageManagerToolFolder: string,
logIfConsoleOutputIsNotRestricted: (message?: string) => void
): Promise<void> {
// Example: "C:\MyRepo\common\temp"
FileSystem.ensureFolder(rushConfiguration.commonTempFolder);

Expand All @@ -488,8 +525,6 @@ export class InstallHelpers {
linkTargetPath: packageManagerToolFolder,
newLinkPath: localPackageManagerToolFolder
});

lock.release();
}

// Helper for getPackageManagerEnvironment
Expand Down
127 changes: 126 additions & 1 deletion libraries/rush-lib/src/logic/test/InstallHelpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
// 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 { 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.name, () => {
describe(InstallHelpers.generateCommonPackageJsonAsync.name, () => {
Expand All @@ -32,6 +35,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 =
Expand Down Expand Up @@ -114,4 +121,122 @@ describe(InstallHelpers.name, () => {
expect(pnpmField).not.toHaveProperty('patchedDependencies');
});
});

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 getPackageManagerToolFolder(rushGlobalFolder: RushGlobalFolder): string {
return `${rushGlobalFolder.nodeSpecificPath}/${packageManager}-${packageManagerVersion}`;
}

async function writeInstalledPackageManagerAsync(rushGlobalFolder: RushGlobalFolder): Promise<void> {
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ensure that the lockAcquireSpy returns false so that the test doesn't try to actually install the package if something goes wrong.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated


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(
`${rushGlobalFolder.nodeSpecificPath}/${packageManager}-${packageManagerVersion}#123.lock`,
''
);

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);
});
});
});