Skip to content

[rush-cli-client] Connect to a warm daemon without loading @microsoft/rush-lib - #6090

Merged
Sean Larkin (TheLarkInn) merged 3 commits into
mainfrom
thelarkinn-fix-client-startup-perf
Sep 24, 2026
Merged

Sean Larkin (TheLarkInn) merged 3 commits into
mainfrom
thelarkinn-fix-client-startup-perf

Conversation

@TheLarkInn

@TheLarkInn Sean Larkin (TheLarkInn) commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Summary

Before connecting to the daemon, every rush-client / rushx-client run require()d all of @microsoft/rush-lib (about 600 ms, ~1360 modules). With this change the warm connect path never evaluates the rush-lib entry point. Heavy modules now load only on the paths that need them: in-process fallback, rushx discovery, daemon auto-start/replacement, and Rush version selection.

Root cause

  • launchClient.ts imported Rush, RushConfiguration, daemonEnvironmentVariables and resolveDaemonConfiguration from the rush-lib index. It also imported MinimalRushConfiguration and VersionSelectedDaemonLauncher at the top level, and the launcher itself imports rush-lib.
  • routing.ts imported RushXCommand from rush-lib even for rush commands.
  • daemonConnectionOptions.ts imported Rush (only for .version). On every auto-start-enabled invocation it also computed the daemon start command eagerly through VersionSelectedDaemonLauncher, although that command is only needed when no compatible daemon is running.

Fix

  • @rushstack/rush-client-core: new optional IConnectOrStartDaemonOptions.resolveStartCommandAsync(). connectOrStartDaemonAsync calls it only after the first connect attempt fails, i.e. when it has to start or replace a daemon. A version mismatch with a resolver present is treated the same as one with a startCommand, so the start/replace semantics do not change. The resolver survives the { ...connection } spread in executeWithDaemonRestartAsync, so the restart path still starts a successor. (API report updated.)
  • @rushstack/rush-cli-client:
    • New lazyRushModules.ts. It loads rush-lib, MinimalRushConfiguration and VersionSelectedDaemonLauncher on demand. It also provides a tryFindRushJsonLocation with the same search as RushConfiguration.tryFindRushJsonLocation, and a getBundledRushVersion() (= Rush.version) that reads rush-lib's package.json.
    • launchClient.ts gets resolveDaemonConfiguration/daemonEnvironmentVariables from the deep import @microsoft/rush-lib/lib/api/DaemonConfiguration, which evaluates only that module. MinimalRushConfiguration is loaded only for rushx. The DaemonLauncherUnavailableError class is loaded only in the error path, and only when the error is not a DaemonClientError.
    • daemonConnectionOptions.ts: when the selected version is the bundled one, it returns resolveStartCommandAsync instead of an eager startCommand. The installation-metadata check still runs eagerly, so the synchronous-launcher error is unchanged. Version-selected launches (a different rushVersion) are unchanged.
    • routing.ts: RushXCommand is loaded only for rushx.
    • bin/rush-client and bin/rushx-client call require('node:module').enableCompileCache?.() (a no-op below Node 22.1). I verified that it does not set NODE_COMPILE_CACHE for child processes, so the daemon environment fingerprint is unaffected (board Fix Rush build break. #205).

Compatibility with #6082

#6082 (agent reporter) rewrites start.ts and adds lines to launchClient.ts and routing.ts. This PR doesn't touch start.ts. I placed the new imports so they don't overlap #6082's hunks. The only textual overlap is the catch block's instanceof DaemonLauncherUnavailableError check, where #6082 adds agentRenderer?.dispose() on the next line. Resolving that means keeping both changes. #6082's new modules (AgentProgressRenderer, outputSelection) are already rush-lib-free, so the combined start path stays lean.

Tests

  • New apps/rush-cli-client/src/test/startupBudget.test.ts, the startup-budget test:
    • It auto-starts a real daemon in the native-build fixture, then runs warm build and daemon status under a --require module probe.
    • It asserts that neither run loads the rush-lib entry point (lib-commonjs/index.js), @microsoft/rush's start/MinimalRushConfiguration, or rush-daemon's index/VersionSelectedDaemonLauncher, and that the warm run stays within 600 modules (it measures about 310).
    • It also checks that the same probe does see rush-lib on the --no-daemon path, so the test can fail when it should.
  • New rush-client-core test: the lazy resolver is called once for a cold start, not for a warm connect, and it replaces a mismatched daemon.
  • daemonConnectionSelection.test.ts now covers the lazy start command.

Linux validation (WSL Ubuntu-24.04, Node 22.23.2)

Commands:

  • Build (includes lint), in a lab clone with this patch applied: node common/scripts/install-run-rush.js build --to @rushstack/rush-cli-client. Passed.
  • Targeted tests: heft test --test-path-pattern "startupBudget|daemonConnectionSelection|versionSelectionFallback". Passed.
  • Bench: bench --runs 12 --warmup 2, interleaved, on the same warm daemon in a 12-project synthetic workspace. "before" is the unfixed toolchain rush-client. Load average was 18 to 10 during the run.
median wall ms (round 1 / round 2) before after
daemon status 828 / 702 250 / 209
daemon status, without the compile cache – 291 / 260
warm build --to p05 866 / 793 337 / 329
modules in require.cache (warm build) 1363 306
node -e 0 36 36

Behavior checks with the fixed client:

  • --no-daemon build --to p01 (in-process fallback): exit 0.

  • daemon stop, then build --to p01: cold auto-start works through the lazy resolver, exit 0, same collated output.

  • daemon status / daemon stop afterwards: normal.

  • rush test --only @rushstack/rush-cli-client --only @rushstack/rush-client-core: passed (all suites, 59 s / 37 s).

Optional follow-ups

  • @rushstack/node-core-library's index (~90 ms) is still pulled in by JsonFile/LockFile imports in the client, rush-daemon's DaemonInstallation, and rush-client-core's StartupLock.
  • The deep DaemonConfiguration import still makes the rush-lib webpack runtime compile commons.js (without evaluating it). Moving DaemonConfiguration into a tiny standalone module would remove that too.

Fixes #6054

This came out of the automated rushd Linux performance/behavior analysis ("Rushd Hive", board threads #26 / #196 / #205).

…/rush-lib

Fixes #6054

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

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.

Copilot review overview

🟢 Approval recommended

The lazy-loading paths preserve existing daemon behavior and are covered by targeted startup, replacement, fallback, and integration tests.

Review effort: Balanced
Findings: None

What changed in this PR

Optimizes warm daemon connections by deferring expensive Rush module loading until startup, fallback, version selection, or rushx execution requires it.

Changes:

  • Adds lazy daemon start-command resolution to client core.
  • Lazily loads Rush libraries and enables Node’s compile cache.
  • Adds startup-budget and daemon-selection coverage.
File Description
libraries/​rush-client-core/​src/​connectOrStartDaemon.ts Adds lazy start-command resolution.
libraries/​rush-client-core/​src/​test/​connectOrStartDaemon.test.ts Tests cold, warm, and replacement behavior.
common/​reviews/​api/​rush-client-core.api.md Updates the public API report.
common/​changes/​@rushstack/​rush-client-core/​client-startup-perf_2026-09-24-03-05.json Records the core patch.
common/​changes/​@rushstack/​rush-cli-client/​client-startup-perf_2026-09-24-03-05.json Records the CLI optimization.
apps/​rush-cli-client/​src/​test/​StartupModuleProbe.ts Reports startup module loading.
apps/​rush-cli-client/​src/​test/​startupBudget.test.ts Enforces the warm-start module budget.
apps/​rush-cli-client/​src/​test/​NativeBuildTestFixture.ts Supports Node preload arguments.
apps/​rush-cli-client/​src/​test/​daemonConnectionSelection.test.ts Covers lazy launcher selection.
apps/​rush-cli-client/​src/​routing.ts Defers RushXCommand loading.
apps/​rush-cli-client/​src/​lazyRushModules.ts Centralizes lazy Rush imports.
apps/​rush-cli-client/​src/​launchClient.ts Removes eager Rush entry-point loading.
apps/​rush-cli-client/​src/​daemonConnectionOptions.ts Lazily creates bundled start commands.
apps/​rush-cli-client/​bin/​rush-client Enables compile caching when available.
apps/​rush-cli-client/​bin/​rushx-client Enables compile caching when available.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@TheLarkInn
Sean Larkin (TheLarkInn) merged commit b82e256 into main Sep 24, 2026
11 checks passed
@TheLarkInn
Sean Larkin (TheLarkInn) deleted the thelarkinn-fix-client-startup-perf branch September 24, 2026 18:27
@github-project-automation github-project-automation Bot moved this from Needs triage to Closed in Bug Triage Sep 24, 2026
Sean Larkin (TheLarkInn) added a commit that referenced this pull request Sep 24, 2026
…sh-daemon

main (#6090) now keeps rush-client from loading @microsoft/rush-lib with on-demand
requires and lazy start-command resolution. Take that implementation for the client
and drop this branch's overlapping client-only modules (RushJsonLocation,
RushXCommandLineArguments, DaemonLaunchCommand and bundledRushVersion) and their
change files. The daemon-side changes in this branch are unaffected.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Closed

Development

Successfully merging this pull request may close these issues.

[rush] rush-cli-client: every invocation loads all of @microsoft/rush-lib (~600 ms) before connecting to the daemon

3 participants