Skip to content
Merged
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
2 changes: 2 additions & 0 deletions apps/rush-cli-client/bin/rush-client
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
#!/usr/bin/env node
// Node >= 22.1 reuses compiled code across invocations; this does not change the environment.
require('node:module').enableCompileCache?.();
require('../lib-commonjs/start.js');
2 changes: 2 additions & 0 deletions apps/rush-cli-client/bin/rushx-client
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
#!/usr/bin/env node
// Node >= 22.1 reuses compiled code across invocations; this does not change the environment.
require('node:module').enableCompileCache?.();
require('../lib-commonjs/startRushX.js');
42 changes: 25 additions & 17 deletions apps/rush-cli-client/src/daemonConnectionOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,13 @@

import * as fs from 'node:fs';

import { Rush } from '@microsoft/rush-lib';
import { JsonFile } from '@rushstack/node-core-library';
import type { IConnectOrStartDaemonOptions } from '@rushstack/rush-client-core';
import { computeDaemonWorkspaceKey, resolveDaemonPathsFromProcess } from '@rushstack/rush-daemon-transport';
import { readDaemonInstallationMetadata } from '@rushstack/rush-daemon/lib/DaemonInstallation';
import {
DaemonLauncherUnavailableError,
getSelectedDaemonStartCommand,
selectDaemonLauncherAsync,
type IVersionSelectedDaemonLaunch
} from '@rushstack/rush-daemon/lib/VersionSelectedDaemonLauncher';
import type * as VersionSelectedDaemonLauncherModule from '@rushstack/rush-daemon/lib/VersionSelectedDaemonLauncher';

import { getBundledRushVersion, loadVersionSelectedDaemonLauncher } from './lazyRushModules';

export function getDaemonConnectionOptions(
repoRoot: string,
Expand All @@ -25,7 +21,7 @@ export function getDaemonConnectionOptions(
const daemonPackagePath: string = require.resolve('@rushstack/rush-daemon/package.json');
const daemonPackage: { version: string; bin: { rushd: string } } = JsonFile.load(daemonPackagePath);
if (autoStart && readDaemonInstallationMetadata(daemonPackagePath).rushVersion !== rushVersion) {
throw new DaemonLauncherUnavailableError(
throw new (loadVersionSelectedDaemonLauncher().DaemonLauncherUnavailableError)(
rushVersion,
'The synchronous launcher only supports its installed engine; use asynchronous version selection.'
);
Expand All @@ -34,7 +30,7 @@ export function getDaemonConnectionOptions(
paths: resolveDaemonPathsFromProcess(computeDaemonWorkspaceKey({ canonicalRepoRoot, rushVersion })),
expectedDaemonVersion: daemonPackage.version,
startCommand: autoStart
? getSelectedDaemonStartCommand(daemonPackagePath, {
? loadVersionSelectedDaemonLauncher().getSelectedDaemonStartCommand(daemonPackagePath, {
repoRoot: canonicalRepoRoot,
rushVersion,
environment
Expand All @@ -57,13 +53,25 @@ export async function getDaemonConnectionOptionsAsync(
false
);
if (!autoStart) return { paths: options.paths };
// The bundled runtime is already loaded here; its bootstrap re-attests before binding.
if (rushVersion === Rush.version)
return getDaemonConnectionOptions(repoRoot, rushVersion, environment, true);
const launch: IVersionSelectedDaemonLaunch = await selectDaemonLauncherAsync({
repoRoot: fs.realpathSync.native(repoRoot),
rushVersion,
environment
});
if (rushVersion === getBundledRushVersion()) {
const daemonPackagePath: string = require.resolve('@rushstack/rush-daemon/package.json');
if (readDaemonInstallationMetadata(daemonPackagePath).rushVersion !== rushVersion) {
// Preserve the eager synchronous launcher error.
return getDaemonConnectionOptions(repoRoot, rushVersion, environment, true);
}
// The bundled runtime's bootstrap re-attests before binding. Resolve it only if a start is needed.
return {
...options,
resolveStartCommandAsync: async () =>
getDaemonConnectionOptions(repoRoot, rushVersion, environment, true).startCommand!
};
}
const { selectDaemonLauncherAsync } = loadVersionSelectedDaemonLauncher();
const launch: VersionSelectedDaemonLauncherModule.IVersionSelectedDaemonLaunch =
await selectDaemonLauncherAsync({
repoRoot: fs.realpathSync.native(repoRoot),
rushVersion,
environment
});
return { ...options, expectedDaemonVersion: launch.daemonVersion, startCommand: launch.startCommand };
}
35 changes: 23 additions & 12 deletions apps/rush-cli-client/src/launchClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@

import * as path from 'node:path';

import type { IDaemonConfigurationJson } from '@microsoft/rush-lib';
// A deep import keeps the warm connect path from evaluating the @microsoft/rush-lib entry point.
import {
Rush,
RushConfiguration,
daemonEnvironmentVariables,
resolveDaemonConfiguration,
type IDaemonConfigurationJson
} from '@microsoft/rush-lib';
resolveDaemonConfiguration
} from '@microsoft/rush-lib/lib/api/DaemonConfiguration';
import { JsonFile } from '@rushstack/node-core-library';
import {
DaemonClientError,
Expand All @@ -22,14 +21,18 @@ import {
} from '@rushstack/rush-client-core';
import type { DaemonVerbosity, IDaemonRequestEnvelope } from '@rushstack/rush-daemon-protocol';
import { ConsoleTerminalProvider } from '@rushstack/terminal';
import { MinimalRushConfiguration } from '@microsoft/rush/lib/MinimalRushConfiguration';
import { DaemonLauncherUnavailableError } from '@rushstack/rush-daemon/lib/VersionSelectedDaemonLauncher';

import { executeDaemonCommandAsync } from './daemonCommands';
import { ClientOperationRenderer } from './ClientOperationRenderer';
import { getDaemonConnectionOptionsAsync } from './daemonConnectionOptions';
import { selectClientRoute, type IClientRoute } from './routing';
import { writeStreamAsync } from './writeStreamAsync';
import {
getBundledRushVersion,
loadMinimalRushConfiguration,
loadVersionSelectedDaemonLauncher,
tryFindRushJsonLocation
} from './lazyRushModules';

interface IWorkspaceJson {
readonly rushVersion: string;
Expand All @@ -39,7 +42,7 @@ interface IWorkspaceJson {
export async function launchClientAsync(rushx: boolean): Promise<void> {
const cwd: string = process.cwd();
const environment: Readonly<NodeJS.ProcessEnv> = Object.freeze({ ...process.env });
const rushJsonPath: string | undefined = RushConfiguration.tryFindRushJsonLocation({ startingFolder: cwd });
const rushJsonPath: string | undefined = tryFindRushJsonLocation(cwd);
const workspace: IWorkspaceJson | undefined = rushJsonPath ? JsonFile.load(rushJsonPath) : undefined;
const config: Readonly<Required<IDaemonConfigurationJson>> = resolveDaemonConfiguration(
workspace?.daemon,
Expand All @@ -52,7 +55,8 @@ export async function launchClientAsync(rushx: boolean): Promise<void> {
rushx,
hasTerminal: !!(process.stdin.isTTY || process.stdout.isTTY || process.stderr.isTTY)
});
const selectedVersion: string = environment.RUSH_PREVIEW_VERSION ?? workspace?.rushVersion ?? Rush.version;
const selectedVersion: string =
environment.RUSH_PREVIEW_VERSION ?? workspace?.rushVersion ?? getBundledRushVersion();
if (!rushx && route.commandName === 'daemon') {
if ((route.argv[1] === 'start' || route.argv[1] === 'restart') && process.argv.includes('--no-daemon')) {
throw new Error(`--no-daemon cannot be combined with daemon ${route.argv[1]}.`);
Expand Down Expand Up @@ -112,7 +116,10 @@ export async function launchClientAsync(rushx: boolean): Promise<void> {
};
client = await connectOrStartDaemonAsync(connection);
} catch (error) {
if (!(error instanceof DaemonClientError) && !(error instanceof DaemonLauncherUnavailableError))
if (
!(error instanceof DaemonClientError) &&
!(error instanceof loadVersionSelectedDaemonLauncher().DaemonLauncherUnavailableError)
)
throw error;
process.stderr.write(`rush-client: ${error.message} Using in-process Rush.\n`);
launchInProcess(route.argv, rushx, selectedVersion);
Expand Down Expand Up @@ -145,7 +152,11 @@ export async function launchClientAsync(rushx: boolean): Promise<void> {
}
};
try {
if (rushx) MinimalRushConfiguration.loadFromDefaultLocation((line) => discoveryLines.push(line));
if (rushx) {
loadMinimalRushConfiguration().MinimalRushConfiguration.loadFromDefaultLocation((line) =>
discoveryLines.push(line)
);
}
await renderer.initializeAsync();
outcome = await executeWithDaemonRestartAsync(client, connection, {
request,
Expand Down Expand Up @@ -207,7 +218,7 @@ function launchInProcess(argv: ReadonlyArray<string>, rushx: boolean, selectedVe
const executable: string = rushx ? 'rushx' : 'rush';
const rushFolder: string = path.dirname(require.resolve('@microsoft/rush/package.json'));
process.argv = [process.execPath, path.join(rushFolder, 'bin', executable), ...argv];
if (selectedVersion !== Rush.version) {
if (selectedVersion !== getBundledRushVersion()) {
// Old Rush releases reject new RUSH_* names. Only strip this launcher's own inputs;
// the request snapshot was captured earlier and is never mutated.
for (const name of [...Object.values(daemonEnvironmentVariables), 'RUSH_DAEMON_EXPERIMENTAL']) {
Expand Down
42 changes: 42 additions & 0 deletions apps/rush-cli-client/src/lazyRushModules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

// The warm daemon connect path must not load the @microsoft/rush-lib bundle (hundreds of milliseconds).
// Modules that depend on it are loaded here on demand, only by fallback, rushx, startup and version selection.

import * as fs from 'node:fs';
import * as path from 'node:path';

import type * as RushLibModule from '@microsoft/rush-lib';
import type * as MinimalRushConfigurationModule from '@microsoft/rush/lib/MinimalRushConfiguration';
import type * as VersionSelectedDaemonLauncherModule from '@rushstack/rush-daemon/lib/VersionSelectedDaemonLauncher';

export function loadRushLib(): typeof RushLibModule {
return require('@microsoft/rush-lib');
}

export function loadMinimalRushConfiguration(): typeof MinimalRushConfigurationModule {
return require('@microsoft/rush/lib/MinimalRushConfiguration');
}

export function loadVersionSelectedDaemonLauncher(): typeof VersionSelectedDaemonLauncherModule {
return require('@rushstack/rush-daemon/lib/VersionSelectedDaemonLauncher');
}

/** Equals `Rush.version`. */
export function getBundledRushVersion(): string {
return (require('@microsoft/rush-lib/package.json') as { version: string }).version;
}

/** Same search as `RushConfiguration.tryFindRushJsonLocation({ startingFolder })`. */
export function tryFindRushJsonLocation(startingFolder: string): string | undefined {
let currentFolder: string = startingFolder;
let parentFolder: string = path.dirname(currentFolder);
while (parentFolder && parentFolder !== currentFolder) {
const rushJsonFilename: string = path.join(currentFolder, 'rush.json');
if (fs.existsSync(rushJsonFilename)) return rushJsonFilename;
currentFolder = parentFolder;
parentFolder = path.dirname(currentFolder);
}
return undefined;
}
5 changes: 3 additions & 2 deletions apps/rush-cli-client/src/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
// See LICENSE in the project root for license information.

import type { IDaemonRequestAdmissionOptions } from '@rushstack/rush-daemon-protocol';
import { RushXCommand, type IRushXCommandLineArguments } from '@microsoft/rush-lib';
import type { IRushXCommandLineArguments } from '@microsoft/rush-lib';

import { loadRushLib } from './lazyRushModules';
import { parseClientAdmissionControls, type IClientAdmissionControls } from './ClientAdmissionControls';

const neverDaemonize: ReadonlySet<string> = new Set([
Expand Down Expand Up @@ -53,7 +54,7 @@ export function selectClientRoute(options: IClientRouteOptions): IClientRoute {
...(separator < 0 ? [] : controls.argv.slice(separator))
];
const rushxArguments: IRushXCommandLineArguments | undefined = options.rushx
? RushXCommand.parseArguments(argv, options.environment)
? loadRushLib().RushXCommand.parseArguments(argv, options.environment)
: undefined;
const commandName: string | undefined = rushxArguments ? rushxArguments.commandName || undefined : argv[0];
const reporterControls: boolean =
Expand Down
19 changes: 14 additions & 5 deletions apps/rush-cli-client/src/test/NativeBuildTestFixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ export interface INativeBuildTestFixture {
readonly folder: string;
readonly environment: NodeJS.ProcessEnv;
readonly paths: IDaemonPaths;
invokeAsync(argv: ReadonlyArray<string>, rushx?: boolean): Promise<INativeBuildResult>;
invokeAsync(
argv: ReadonlyArray<string>,
rushx?: boolean,
nodeArgs?: ReadonlyArray<string>
): Promise<INativeBuildResult>;
snapshotAsync(...args: string[]): Promise<IDaemonGraphSnapshotPayload['snapshot']>;
runAsync(work: (fixture: INativeBuildTestFixture) => Promise<void>): Promise<void>;
trackWatch(child: ChildProcess, closed: Promise<unknown[]>): void;
Expand Down Expand Up @@ -125,17 +129,22 @@ export function createNativeBuildTestFixture(): INativeBuildTestFixture {
})
);

function invokeAsync(argv: ReadonlyArray<string>, rushx: boolean = false): Promise<INativeBuildResult> {
function invokeAsync(
argv: ReadonlyArray<string>,
rushx: boolean = false,
nodeArgs: ReadonlyArray<string> = []
): Promise<INativeBuildResult> {
if (!acceptingInvocations) throw new Error('The native build fixture is already closing.');
return spawnClientAsync(argv, rushx);
return spawnClientAsync(argv, rushx, nodeArgs);
}

async function spawnClientAsync(
argv: ReadonlyArray<string>,
rushx: boolean = false
rushx: boolean = false,
nodeArgs: ReadonlyArray<string> = []
): Promise<INativeBuildResult> {
const entry: string = path.resolve(__dirname, rushx ? '../../bin/rushx-client' : '../../bin/rush-client');
const child = spawn(process.execPath, [entry, ...argv], {
const child = spawn(process.execPath, [...nodeArgs, entry, ...argv], {
cwd: rushx ? path.join(folder, 'b') : folder,
env: environment,
stdio: ['ignore', 'pipe', 'pipe']
Expand Down
15 changes: 15 additions & 0 deletions apps/rush-cli-client/src/test/StartupModuleProbe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

// Preloaded with `node --require` by startupBudget.test.ts. Reports the loaded module count and the loaded
// Rush modules on one short stderr line (a large synchronous write to a pipe at exit can be truncated).
import * as fs from 'node:fs';

process.once('exit', () => {
const modules: string[] = Object.keys(require.cache);
const report: { count: number; rushModules: string[] } = {
count: modules.length,
rushModules: modules.filter((name) => /[\\/](rush-lib|rush|rush-daemon)[\\/](lib-commonjs|dist)[\\/]/.test(name))
};
fs.writeSync(2, `\nrush-client-startup-modules:${JSON.stringify(report)}\n`);
});
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ describe('version-selected daemon connection options', () => {
const asynchronous = await getDaemonConnectionOptionsAsync(repoRoot, Rush.version, process.env, true);
expect(asynchronous.paths).toEqual(synchronous.paths);
expect(asynchronous.expectedDaemonVersion).toBe(synchronous.expectedDaemonVersion);
expect(asynchronous.startCommand?.args).toEqual(synchronous.startCommand?.args);
expect(asynchronous.startCommand).toBeUndefined();
expect((await asynchronous.resolveStartCommandAsync?.())?.args).toEqual(synchronous.startCommand?.args);
});

it('does not claim a different requested engine in the synchronous default launcher', () => {
Expand Down
67 changes: 67 additions & 0 deletions apps/rush-cli-client/src/test/startupBudget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// 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 {
createNativeBuildTestFixture,
type INativeBuildResult,
type INativeBuildTestFixture
} from './NativeBuildTestFixture';

// Must match StartupModuleProbe.ts. The probe is not imported here because it registers an exit hook.
const STARTUP_MODULES_MARKER: string = 'rush-client-startup-modules:';
const PROBE_ARGS: ReadonlyArray<string> = ['--require', path.resolve(__dirname, 'StartupModuleProbe.js')];

// Only in-process fallback, rushx discovery, daemon startup and version selection may load these.
const DEFERRED_MODULES: ReadonlyArray<RegExp> = [
/[\\/]rush-lib[\\/]lib-commonjs[\\/]index\.js$/,
/[\\/]rush[\\/]lib-commonjs[\\/](start|MinimalRushConfiguration)\.js$/,
/[\\/]rush-daemon[\\/]lib-commonjs[\\/](index|VersionSelectedDaemonLauncher)\.js$/
];

// A warm build loaded ~1360 modules before #6054 and ~310 after it (Linux); leave headroom for growth.
const WARM_CONNECT_MODULE_BUDGET: number = 600;

interface IStartupModules {
readonly count: number;
readonly rushModules: ReadonlyArray<string>;
}

function readLoadedModules(result: INativeBuildResult): IStartupModules {
const line: string | undefined = result.stderr
.split('\n')
.find((candidate) => candidate.startsWith(STARTUP_MODULES_MARKER));
if (!line) throw new Error(`The startup module probe did not report: ${result.stderr}`);
return JSON.parse(line.slice(STARTUP_MODULES_MARKER.length));
}

describe('rush-client startup budget', () => {
let fixture: INativeBuildTestFixture;

beforeEach(() => {
fixture = createNativeBuildTestFixture();
});
afterEach(async () => {
await fixture.closeAsync();
});

it('connects to a warm daemon without loading the @microsoft/rush-lib entry point', async () => {
// Cold auto-start may load the launcher; the warm invocations below must not.
expect((await fixture.invokeAsync(['build'])).code).toBe(0);
for (const argv of [['build'], ['daemon', 'status']]) {
const result: INativeBuildResult = await fixture.invokeAsync(argv, false, PROBE_ARGS);
expect(result.code).toBe(0);
const modules: IStartupModules = readLoadedModules(result);
expect({ argv, deferred: modules.rushModules.filter((name) => DEFERRED_MODULES.some((re) => re.test(name))) }).toEqual({
argv,
deferred: []
});
expect(modules.count).toBeLessThanOrEqual(WARM_CONNECT_MODULE_BUDGET);
}
// The probe and patterns must observe the in-process path, or the assertions above prove nothing.
const native: INativeBuildResult = await fixture.invokeAsync(['--no-daemon', 'build'], false, PROBE_ARGS);
expect(native.code).toBe(0);
expect(readLoadedModules(native).rushModules.filter((name) => DEFERRED_MODULES[0].test(name))).toHaveLength(1);
}, 60000);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"packageName": "@rushstack/rush-cli-client",
"comment": "Connect to a warm daemon without loading the @microsoft/rush-lib bundle; heavy modules are now loaded only for in-process fallback, rushx discovery, daemon startup, and version selection. The bin scripts also enable the Node.js compile cache when available.",
"type": "patch"
}
],
"packageName": "@rushstack/rush-cli-client",
"email": "selarkin@microsoft.com"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"packageName": "@rushstack/rush-client-core",
"comment": "Add IConnectOrStartDaemonOptions.resolveStartCommandAsync so a start command is resolved only when a daemon must be started or replaced.",
"type": "patch"
}
],
"packageName": "@rushstack/rush-client-core",
"email": "selarkin@microsoft.com"
}
Loading
Loading