From f2e06f6d6defda5b31d48cf90bd58d7f586325ab Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 23 Sep 2026 20:03:04 -0700 Subject: [PATCH 1/3] [rush-cli-client] Connect to a warm daemon without loading @microsoft/rush-lib Fixes #6054 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/rush-cli-client/bin/rush-client | 2 + apps/rush-cli-client/bin/rushx-client | 2 + .../src/daemonConnectionOptions.ts | 42 ++++++++----- apps/rush-cli-client/src/launchClient.ts | 35 +++++++---- apps/rush-cli-client/src/lazyRushModules.ts | 42 +++++++++++++ apps/rush-cli-client/src/routing.ts | 5 +- .../src/test/NativeBuildTestFixture.ts | 19 ++++-- .../src/test/StartupModuleProbe.ts | 11 ++++ .../test/daemonConnectionSelection.test.ts | 3 +- .../src/test/startupBudget.test.ts | 62 +++++++++++++++++++ .../client-startup-perf_2026-09-24-03-05.json | 11 ++++ .../client-startup-perf_2026-09-24-03-05.json | 11 ++++ .../src/connectOrStartDaemon.ts | 20 +++++- .../src/test/connectOrStartDaemon.test.ts | 22 +++++++ 14 files changed, 247 insertions(+), 40 deletions(-) create mode 100644 apps/rush-cli-client/src/lazyRushModules.ts create mode 100644 apps/rush-cli-client/src/test/StartupModuleProbe.ts create mode 100644 apps/rush-cli-client/src/test/startupBudget.test.ts create mode 100644 common/changes/@rushstack/rush-cli-client/client-startup-perf_2026-09-24-03-05.json create mode 100644 common/changes/@rushstack/rush-client-core/client-startup-perf_2026-09-24-03-05.json diff --git a/apps/rush-cli-client/bin/rush-client b/apps/rush-cli-client/bin/rush-client index eef2fc27066..5df5630eb27 100755 --- a/apps/rush-cli-client/bin/rush-client +++ b/apps/rush-cli-client/bin/rush-client @@ -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'); diff --git a/apps/rush-cli-client/bin/rushx-client b/apps/rush-cli-client/bin/rushx-client index 8820423f29a..83e1580e4b1 100755 --- a/apps/rush-cli-client/bin/rushx-client +++ b/apps/rush-cli-client/bin/rushx-client @@ -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'); diff --git a/apps/rush-cli-client/src/daemonConnectionOptions.ts b/apps/rush-cli-client/src/daemonConnectionOptions.ts index ab4b5297f85..bb87818b850 100644 --- a/apps/rush-cli-client/src/daemonConnectionOptions.ts +++ b/apps/rush-cli-client/src/daemonConnectionOptions.ts @@ -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, @@ -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.' ); @@ -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 @@ -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 }; } diff --git a/apps/rush-cli-client/src/launchClient.ts b/apps/rush-cli-client/src/launchClient.ts index b739b9fadf7..26fb5bbd1be 100644 --- a/apps/rush-cli-client/src/launchClient.ts +++ b/apps/rush-cli-client/src/launchClient.ts @@ -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, @@ -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; @@ -39,7 +42,7 @@ interface IWorkspaceJson { export async function launchClientAsync(rushx: boolean): Promise { const cwd: string = process.cwd(); const environment: Readonly = 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> = resolveDaemonConfiguration( workspace?.daemon, @@ -52,7 +55,8 @@ export async function launchClientAsync(rushx: boolean): Promise { 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]}.`); @@ -112,7 +116,10 @@ export async function launchClientAsync(rushx: boolean): Promise { }; 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); @@ -145,7 +152,11 @@ export async function launchClientAsync(rushx: boolean): Promise { } }; 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, @@ -207,7 +218,7 @@ function launchInProcess(argv: ReadonlyArray, 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']) { diff --git a/apps/rush-cli-client/src/lazyRushModules.ts b/apps/rush-cli-client/src/lazyRushModules.ts new file mode 100644 index 00000000000..d10c8774de9 --- /dev/null +++ b/apps/rush-cli-client/src/lazyRushModules.ts @@ -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; +} diff --git a/apps/rush-cli-client/src/routing.ts b/apps/rush-cli-client/src/routing.ts index 310960249bf..33b6c9418a2 100644 --- a/apps/rush-cli-client/src/routing.ts +++ b/apps/rush-cli-client/src/routing.ts @@ -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 = new Set([ @@ -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 = diff --git a/apps/rush-cli-client/src/test/NativeBuildTestFixture.ts b/apps/rush-cli-client/src/test/NativeBuildTestFixture.ts index d21fc586730..07eb1bd763e 100644 --- a/apps/rush-cli-client/src/test/NativeBuildTestFixture.ts +++ b/apps/rush-cli-client/src/test/NativeBuildTestFixture.ts @@ -27,7 +27,11 @@ export interface INativeBuildTestFixture { readonly folder: string; readonly environment: NodeJS.ProcessEnv; readonly paths: IDaemonPaths; - invokeAsync(argv: ReadonlyArray, rushx?: boolean): Promise; + invokeAsync( + argv: ReadonlyArray, + rushx?: boolean, + nodeArgs?: ReadonlyArray + ): Promise; snapshotAsync(...args: string[]): Promise; runAsync(work: (fixture: INativeBuildTestFixture) => Promise): Promise; trackWatch(child: ChildProcess, closed: Promise): void; @@ -125,17 +129,22 @@ export function createNativeBuildTestFixture(): INativeBuildTestFixture { }) ); - function invokeAsync(argv: ReadonlyArray, rushx: boolean = false): Promise { + function invokeAsync( + argv: ReadonlyArray, + rushx: boolean = false, + nodeArgs: ReadonlyArray = [] + ): Promise { 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, - rushx: boolean = false + rushx: boolean = false, + nodeArgs: ReadonlyArray = [] ): Promise { 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'] diff --git a/apps/rush-cli-client/src/test/StartupModuleProbe.ts b/apps/rush-cli-client/src/test/StartupModuleProbe.ts new file mode 100644 index 00000000000..e9ef0150f78 --- /dev/null +++ b/apps/rush-cli-client/src/test/StartupModuleProbe.ts @@ -0,0 +1,11 @@ +// 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 every module the client loaded. +import * as fs from 'node:fs'; + +export const STARTUP_MODULES_MARKER: string = 'rush-client-startup-modules:'; + +process.once('exit', () => { + fs.writeSync(2, `\n${STARTUP_MODULES_MARKER}${JSON.stringify(Object.keys(require.cache))}\n`); +}); diff --git a/apps/rush-cli-client/src/test/daemonConnectionSelection.test.ts b/apps/rush-cli-client/src/test/daemonConnectionSelection.test.ts index 6bb229853d3..98daf2fe4db 100644 --- a/apps/rush-cli-client/src/test/daemonConnectionSelection.test.ts +++ b/apps/rush-cli-client/src/test/daemonConnectionSelection.test.ts @@ -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', () => { diff --git a/apps/rush-cli-client/src/test/startupBudget.test.ts b/apps/rush-cli-client/src/test/startupBudget.test.ts new file mode 100644 index 00000000000..e5139919862 --- /dev/null +++ b/apps/rush-cli-client/src/test/startupBudget.test.ts @@ -0,0 +1,62 @@ +// 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 = ['--require', path.resolve(__dirname, 'StartupModuleProbe.js')]; + +// Only in-process fallback, rushx discovery, daemon startup and version selection may load these. +const DEFERRED_MODULES: ReadonlyArray = [ + /[\\/]rush-lib[\\/]lib-commonjs[\\/]index\.js$/, + /[\\/]rush[\\/]lib-commonjs[\\/](start|MinimalRushConfiguration)\.js$/, + /[\\/]rush-daemon[\\/]lib-commonjs[\\/](index|VersionSelectedDaemonLauncher)\.js$/ +]; + +// A warm connect loaded ~1260 modules before #6054 and ~BASELINE after it; leave headroom for growth. +const WARM_CONNECT_MODULE_BUDGET: number = 600; + +function readLoadedModules(result: INativeBuildResult): ReadonlyArray { + 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: ReadonlyArray = readLoadedModules(result); + expect({ argv, deferred: modules.filter((name) => DEFERRED_MODULES.some((re) => re.test(name))) }).toEqual({ + argv, + deferred: [] + }); + expect(modules.length).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).filter((name) => DEFERRED_MODULES[0].test(name))).toHaveLength(1); + }, 60000); +}); diff --git a/common/changes/@rushstack/rush-cli-client/client-startup-perf_2026-09-24-03-05.json b/common/changes/@rushstack/rush-cli-client/client-startup-perf_2026-09-24-03-05.json new file mode 100644 index 00000000000..cc8859772a1 --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/client-startup-perf_2026-09-24-03-05.json @@ -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" +} diff --git a/common/changes/@rushstack/rush-client-core/client-startup-perf_2026-09-24-03-05.json b/common/changes/@rushstack/rush-client-core/client-startup-perf_2026-09-24-03-05.json new file mode 100644 index 00000000000..1b08dda7b91 --- /dev/null +++ b/common/changes/@rushstack/rush-client-core/client-startup-perf_2026-09-24-03-05.json @@ -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" +} diff --git a/libraries/rush-client-core/src/connectOrStartDaemon.ts b/libraries/rush-client-core/src/connectOrStartDaemon.ts index 0889608be28..866bde97c48 100644 --- a/libraries/rush-client-core/src/connectOrStartDaemon.ts +++ b/libraries/rush-client-core/src/connectOrStartDaemon.ts @@ -45,8 +45,13 @@ export interface IDaemonStartCommand { /** Detached startup options. @beta */ export interface IConnectOrStartDaemonOptions extends Omit { readonly paths: IDaemonPaths; - /** Omit to connect without auto-start. */ + /** Omit (together with resolveStartCommandAsync) to connect without auto-start. */ readonly startCommand?: IDaemonStartCommand; + /** + * Resolves the start command only when a daemon must be started or replaced, so a warm connect never + * loads launcher code. Ignored when startCommand is provided. + */ + readonly resolveStartCommandAsync?: () => Promise; /** * Ownership captured before acknowledged shutdown. Wait for this record to disappear, change owner, * or have a demonstrably dead owner before connecting or starting. A live/reused owner times out safely. @@ -76,7 +81,9 @@ export async function connectOrStartDaemonAsync( await waitForPreviousDaemonAsync(options.paths, options.previousDaemon, deadline, options.abortSignal); const initial: DaemonClient | undefined = await tryConnectAsync(options, deadline); if (initial) return initial; - if (!options.startCommand) { + const startCommand: IDaemonStartCommand | undefined = + options.startCommand ?? (await options.resolveStartCommandAsync?.()); + if (!startCommand) { if (options.previousDaemon) { while (Date.now() < deadline) { await delayAsync(Math.min(100, Math.max(1, deadline - Date.now())), undefined, { @@ -91,6 +98,13 @@ export async function connectOrStartDaemonAsync( `No ready daemon at ${options.paths.socketPath}; auto-start is disabled.` ); } + return await startDaemonAsync({ ...options, startCommand, resolveStartCommandAsync: undefined }, deadline); +} + +async function startDaemonAsync( + options: IConnectOrStartDaemonOptions & { readonly startCommand: IDaemonStartCommand }, + deadline: number +): Promise { ensureDaemonRuntimeDir(options.paths); let lock: IStartupLock | undefined; let backoffMs: number = 50; @@ -260,7 +274,7 @@ async function tryConnectAsync( if ( error instanceof DaemonClientError && error.code === 'versionMismatch' && - options.startCommand && + (options.startCommand || options.resolveStartCommandAsync) && options.expectedDaemonVersion !== undefined ) { return undefined; diff --git a/libraries/rush-client-core/src/test/connectOrStartDaemon.test.ts b/libraries/rush-client-core/src/test/connectOrStartDaemon.test.ts index df7fc390cd5..dc4275cd743 100644 --- a/libraries/rush-client-core/src/test/connectOrStartDaemon.test.ts +++ b/libraries/rush-client-core/src/test/connectOrStartDaemon.test.ts @@ -358,6 +358,28 @@ describe('detached daemon startup', () => { expect(fs.readFileSync(path.join(folder, 'starts'), 'utf8').trim().split('\n')).toHaveLength(1); }); + it('resolves a lazy start command only when no compatible daemon is ready', async () => { + const { startCommand, ...connectOnly } = options; + const resolveStartCommandAsync = jest.fn(async () => startCommand!); + const started = await connectOrStartDaemonAsync({ ...connectOnly, resolveStartCommandAsync }); + await started.closeAsync(); + expect(resolveStartCommandAsync).toHaveBeenCalledTimes(1); + const warm = await connectOrStartDaemonAsync({ ...connectOnly, resolveStartCommandAsync }); + await warm.closeAsync(); + expect(resolveStartCommandAsync).toHaveBeenCalledTimes(1); + const replaced = await connectOrStartDaemonAsync({ + ...connectOnly, + expectedDaemonVersion: 'replacement', + resolveStartCommandAsync: async () => ({ + ...startCommand!, + args: [...startCommand!.args, 'replacement'] + }) + }); + expect((await replaced.status).daemonVersion).toBe('replacement'); + await replaced.closeAsync(); + expect(fs.readFileSync(path.join(folder, 'starts'), 'utf8').trim().split('\n')).toHaveLength(2); + }); + it.each(['restart-once', 'restart-always', 'restart-held'])( 'retries only the typed pre-execution result for %s after ownership release', async (mode) => { From 1fc40ca04ec825c21416f90ba1ced1e09d3e4c84 Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 23 Sep 2026 20:13:57 -0700 Subject: [PATCH 2/3] [rush-cli-client] Keep the startup module probe report short Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/test/StartupModuleProbe.ts | 14 +++++++++----- .../src/test/startupBudget.test.ts | 15 ++++++++++----- common/reviews/api/rush-client-core.api.md | 1 + 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/rush-cli-client/src/test/StartupModuleProbe.ts b/apps/rush-cli-client/src/test/StartupModuleProbe.ts index e9ef0150f78..bbb76e8212b 100644 --- a/apps/rush-cli-client/src/test/StartupModuleProbe.ts +++ b/apps/rush-cli-client/src/test/StartupModuleProbe.ts @@ -1,11 +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 every module the client loaded. +// 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'; -export const STARTUP_MODULES_MARKER: string = 'rush-client-startup-modules:'; - process.once('exit', () => { - fs.writeSync(2, `\n${STARTUP_MODULES_MARKER}${JSON.stringify(Object.keys(require.cache))}\n`); -}); + 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`); +}); \ No newline at end of file diff --git a/apps/rush-cli-client/src/test/startupBudget.test.ts b/apps/rush-cli-client/src/test/startupBudget.test.ts index e5139919862..d08b214c7cf 100644 --- a/apps/rush-cli-client/src/test/startupBudget.test.ts +++ b/apps/rush-cli-client/src/test/startupBudget.test.ts @@ -23,7 +23,12 @@ const DEFERRED_MODULES: ReadonlyArray = [ // A warm connect loaded ~1260 modules before #6054 and ~BASELINE after it; leave headroom for growth. const WARM_CONNECT_MODULE_BUDGET: number = 600; -function readLoadedModules(result: INativeBuildResult): ReadonlyArray { +interface IStartupModules { + readonly count: number; + readonly rushModules: ReadonlyArray; +} + +function readLoadedModules(result: INativeBuildResult): IStartupModules { const line: string | undefined = result.stderr .split('\n') .find((candidate) => candidate.startsWith(STARTUP_MODULES_MARKER)); @@ -47,16 +52,16 @@ describe('rush-client startup budget', () => { for (const argv of [['build'], ['daemon', 'status']]) { const result: INativeBuildResult = await fixture.invokeAsync(argv, false, PROBE_ARGS); expect(result.code).toBe(0); - const modules: ReadonlyArray = readLoadedModules(result); - expect({ argv, deferred: modules.filter((name) => DEFERRED_MODULES.some((re) => re.test(name))) }).toEqual({ + const modules: IStartupModules = readLoadedModules(result); + expect({ argv, deferred: modules.rushModules.filter((name) => DEFERRED_MODULES.some((re) => re.test(name))) }).toEqual({ argv, deferred: [] }); - expect(modules.length).toBeLessThanOrEqual(WARM_CONNECT_MODULE_BUDGET); + 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).filter((name) => DEFERRED_MODULES[0].test(name))).toHaveLength(1); + expect(readLoadedModules(native).rushModules.filter((name) => DEFERRED_MODULES[0].test(name))).toHaveLength(1); }, 60000); }); diff --git a/common/reviews/api/rush-client-core.api.md b/common/reviews/api/rush-client-core.api.md index 2305d7bad2a..103df5d039a 100644 --- a/common/reviews/api/rush-client-core.api.md +++ b/common/reviews/api/rush-client-core.api.md @@ -76,6 +76,7 @@ export interface IConnectOrStartDaemonOptions extends Omit; + readonly resolveStartCommandAsync?: () => Promise; readonly startCommand?: IDaemonStartCommand; readonly startupTimeoutMs?: number; } From 2a3cfed01f486dad51b156142094b54f3480f3f9 Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 23 Sep 2026 20:16:01 -0700 Subject: [PATCH 3/3] [rush-cli-client] Record the measured startup module baseline Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/rush-cli-client/src/test/startupBudget.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/rush-cli-client/src/test/startupBudget.test.ts b/apps/rush-cli-client/src/test/startupBudget.test.ts index d08b214c7cf..7b92c3e3e8f 100644 --- a/apps/rush-cli-client/src/test/startupBudget.test.ts +++ b/apps/rush-cli-client/src/test/startupBudget.test.ts @@ -20,7 +20,7 @@ const DEFERRED_MODULES: ReadonlyArray = [ /[\\/]rush-daemon[\\/]lib-commonjs[\\/](index|VersionSelectedDaemonLauncher)\.js$/ ]; -// A warm connect loaded ~1260 modules before #6054 and ~BASELINE after it; leave headroom for growth. +// 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 {