From 006563288823f22382e6e072999c0a696f8ea193 Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 23 Sep 2026 17:35:24 +0000 Subject: [PATCH 1/9] rush-lib: allow daemon builds with plugins scoped to other commands; add daemon dogfooding workflow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 2 + apps/rush-cli-client/README.md | 9 +- .../rush/deploy-rush-daemon-dogfood.json | 15 ++ docs/rush/dogfooding-rush-daemon.md | 230 ++++++++++++++++++ docs/rush/environment-variables.md | 5 + libraries/rush-daemon/README.md | 9 +- .../src/ProductionDaemonRequestResolver.ts | 5 +- .../rush-lib/src/api/PhasedCommandEngine.ts | 21 +- .../src/api/WorkspaceInputFingerprint.ts | 10 + .../src/api/test/PhasedCommandEngine.test.ts | 205 ++++++++++++++++ .../test/WorkspaceInputFingerprint.test.ts | 46 ++++ .../cli/scriptActions/PhasedScriptAction.ts | 11 + .../PluginLoader/AutoinstallerPluginLoader.ts | 52 +++- .../src/pluginFramework/PluginManager.ts | 64 +++++ 14 files changed, 666 insertions(+), 18 deletions(-) create mode 100644 common/config/rush/deploy-rush-daemon-dogfood.json create mode 100644 docs/rush/dogfooding-rush-daemon.md create mode 100644 libraries/rush-lib/src/api/test/PhasedCommandEngine.test.ts diff --git a/README.md b/README.md index 1f46bad3013..981421ea761 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ for large scale TypeScript monorepos. - [API Documenter](https://api-extractor.com/pages/setup/generating_docs/) - use TSDoc comments to publish an API documentation website - [Lockfile Explorer](https://lfx.rushstack.io/) - investigate and solve version conflicts for PNPM lockfiles - [TSDoc](https://tsdoc.org/) - the standard for doc comments in TypeScript code +- [Dogfooding the Rush daemon](./docs/rush/dogfooding-rush-daemon.md) - contributor guide for building this repo + with the opt-in `rush-client` daemon, built from source ## Related Repos diff --git a/apps/rush-cli-client/README.md b/apps/rush-cli-client/README.md index 1d4cf98eb57..190d565ca47 100644 --- a/apps/rush-cli-client/README.md +++ b/apps/rush-cli-client/README.md @@ -3,6 +3,9 @@ Separate `rush-client` and `rushx-client` binaries, opt-in until cutover. Existing `rush`, `rushx`, and their reporter entrypoints are unchanged. +To try the daemon on the rushstack repository itself before a release contains it, follow the +[contributor dogfooding guide](../../docs/rush/dogfooding-rush-daemon.md). + ## Native frontend dependency The dependency on `@microsoft/rush` is intentional: it is the version-selecting @@ -116,9 +119,11 @@ changed configuration or command shape replaces the session and graph in the sam process. Environment, installed dependencies, implementation content, or selected Rush version changes require a process restart rather than patching the existing engine. Direct, inherited, and rig-based project configuration uses private native -loaders and is rechecked before execution. External plugins, `.env`, phased +loaders and is rechecked before execution. External plugins that participate in the +requested command (unassociated plugins, plugins associated with it, or plugin command-line +files that define it, its phases or parameters for either), `.env`, phased watch/install options, and unsupported event-hook scripts still use typed -pre-execution fallback; this does not exclude the built-in `install` and `update` +pre-execution fallback; plugins scoped only to other commands are permitted. This does not exclude the built-in `install` and `update` commands described above. The native Rush lock is held for preparation and each coalesced iteration, not while idle; native commands and `--no-daemon` can run after a completed request without stopping the daemon. diff --git a/common/config/rush/deploy-rush-daemon-dogfood.json b/common/config/rush/deploy-rush-daemon-dogfood.json new file mode 100644 index 00000000000..c83062bf534 --- /dev/null +++ b/common/config/rush/deploy-rush-daemon-dogfood.json @@ -0,0 +1,15 @@ +/** + * This configuration file defines a deployment scenario for use with the "rush deploy" command. + * For full documentation, please see https://rushjs.io + * + * The "rush-daemon-dogfood" scenario extracts a stable, self-contained copy of the source-built + * "rush-client" and "rushx-client" binaries, including the daemon and the Rush engine they bundle. + * Contributors use it to try the opt-in Rush daemon on this repository before a release contains it: + * a daemon started from the copy keeps working while the workspace rebuilds its own Rush projects. + * See docs/rush/dogfooding-rush-daemon.md. + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/deploy-scenario.schema.json", + + "deploymentProjectNames": ["@rushstack/rush-cli-client"] +} diff --git a/docs/rush/dogfooding-rush-daemon.md b/docs/rush/dogfooding-rush-daemon.md new file mode 100644 index 00000000000..0dce3be30fb --- /dev/null +++ b/docs/rush/dogfooding-rush-daemon.md @@ -0,0 +1,230 @@ +# Dogfooding the Rush daemon in rushstack + +This guide is for contributors who want to build the rushstack repository with the opt-in Rush daemon +(`rush-client`, from [`apps/rush-cli-client`](../../apps/rush-cli-client/README.md)) **before** a published +Rush release contains it. Ordinary `rush`, `rushx`, and CI are unaffected: they keep running in-process +unless you opt in explicitly. + +Nothing described here is published yet, so the client is built from source. The daemon must not run +directly from the workspace's build outputs: rebuilding `@microsoft/rush-lib`, `@rushstack/rush-daemon`, or +`@rushstack/rush-cli-client` would replace modules that the long-lived daemon is executing. Instead, +`rush deploy` extracts a stable, self-contained snapshot of the built client closure into the gitignored +`common/temp/rush-daemon-dogfood` folder, and the daemon runs from that snapshot. + +## Prerequisites + +- A supported Node.js version (see `nodeSupportedVersionRange` in `rush.json`) and Git. +- A completed install: `node common/scripts/install-run-rush.js install`. +- No `.env` file in the repository root or in `~/.rush-user/`. The daemon does not support `.env` + initialization and would fall back to in-process Rush. +- The source-built Rush version must equal the repository's `rushVersion`. Compare the output of + `node -p "require('./apps/rush/package.json').version"` with the `rushVersion` field in `rush.json`. + If they differ, see [Version skew](#version-skew-and-rush_preview_version). + +> **Do not export `RUSH_DAEMON` in a shell that also runs ordinary `rush`.** The published Rush release that +> `rush.json` currently selects rejects unrecognized `RUSH_` environment variables, so +> `node common/scripts/install-run-rush.js ...` fails with +> `The following environment variables were found with the "RUSH_" prefix, but they are not recognized`. +> The shell functions below set `RUSH_DAEMON=1` only for `rush-client` invocations. +> +> **Do not add a `daemon` block to `rush.json` yet.** The published schema rejects it, which would break +> ordinary `rush` and CI. Opting in with the environment variable is sufficient. + +## 1. Build the client closure + +```bash +node common/scripts/install-run-rush.js build --to @rushstack/rush-cli-client +``` + +## 2. Create the snapshot + +If a daemon from an earlier snapshot is running, stop it first (see [Refresh](#refresh-the-snapshot)). +Then extract the snapshot with the `rush-daemon-dogfood` deployment scenario +([`common/config/rush/deploy-rush-daemon-dogfood.json`](../../common/config/rush/deploy-rush-daemon-dogfood.json)): + +```bash +node common/scripts/install-run-rush.js deploy --scenario rush-daemon-dogfood --target-folder common/temp/rush-daemon-dogfood --overwrite +``` + +The snapshot contains copies of the built Rush projects and their npm dependencies, with links that stay +inside the snapshot. To confirm that the client resolves its engine from the snapshot rather than from the +workspace, run the following from the repository root. It must print a path under +`common/temp/rush-daemon-dogfood`: + +```bash +node -p "require('fs').realpathSync(require.resolve('@microsoft/rush-lib', { paths: [require('path').resolve('common/temp/rush-daemon-dogfood/apps/rush-cli-client')] }))" +``` + +The built-in cloud build-cache plugins are not part of the snapshot. That is fine for this repository, whose +build cache is `local-only`. + +## 3. Opt in and build + +Define a `rush-client` function in the terminal you will use, from the repository root. + +Bash: + +```bash +DOGFOOD_SNAPSHOT="$PWD/common/temp/rush-daemon-dogfood" +rush-client() { RUSH_DAEMON=1 node "$DOGFOOD_SNAPSHOT/apps/rush-cli-client/bin/rush-client" "$@"; } +``` + +PowerShell: + +```powershell +$DogfoodSnapshot = "$PWD\common\temp\rush-daemon-dogfood" +function rush-client { + $env:RUSH_DAEMON = '1' + try { node "$DogfoodSnapshot\apps\rush-cli-client\bin\rush-client" @args } finally { Remove-Item Env:RUSH_DAEMON } +} +``` + +Then build as usual. The first request starts the daemon and constructs the all-project graph; later +compatible requests from the same terminal reuse it: + +```bash +rush-client build --to @rushstack/tree-pattern +rush-client build --to @rushstack/tree-pattern +``` + +Any project selection that `rush build` accepts works, including projects in the `build-tests-subspace` +subspace. `rush-client rebuild` is also supported. + +## 4. Confirm that the daemon served the build + +Enabling the daemon is not proof that a build used it: unsupported requests silently or explicitly use +in-process Rush. Check all of the following: + +1. **No fallback.** When the client does not use the daemon, it runs native Rush, which prints the + `Rush Multi-Project Build Tool` banner. If the daemon was selected but could not serve the request, stderr + also contains a line starting with `rush-client:` and ending with `using in-process Rush.` A + daemon-served build prints neither. +2. **A warm graph.** `rush-client daemon status` prints one JSON object. `workspace.graphInitialized` must be + `true`. +3. **The same process.** `pid` and `workspace.generationToken` stay the same across requests from the same + terminal. `workspace.lastReloadTier` is `0` when the request reused the existing graph, and `1` after an + in-process reload (for example, the first request, or a configuration change). +4. **Parity.** For the same selection, ordinary Rush produces the same outputs. For example, after a + daemon-served build, `node common/scripts/install-run-rush.js rebuild --only @rushstack/tree-pattern` + leaves the files in `lib-commonjs`, `lib-dts`, `lib-esm`, and `dist` unchanged. + +A warm request whose selected operations are all up to date can finish without printing anything, and exits +with code `0`. + +For example, in one Bash terminal after steps 1–3: + +```bash +rush-client build --to @rushstack/tree-pattern 2>&1 | tee /tmp/dogfood-build.log +grep -E 'in-process Rush|Rush Multi-Project Build Tool' /tmp/dogfood-build.log && echo 'NOT served by the daemon' +rush-client daemon status # graphInitialized: true; note pid and generationToken +echo "// throwaway" >> libraries/tree-pattern/src/index.ts +rush-client build --to @rushstack/tree-pattern # rebuilds tree-pattern in the same daemon +rush-client daemon status # same pid and generationToken +git checkout -- libraries/tree-pattern/src/index.ts +``` + +In PowerShell, use `rush-client build --to @rushstack/tree-pattern *>&1 | Tee-Object dogfood-build.log` and +`Select-String -Path dogfood-build.log -Pattern 'in-process Rush', 'Rush Multi-Project Build Tool'`. + +## Use native Rush for one command + +Pass `--no-daemon` to run a single `rush-client` command in-process: + +```bash +rush-client build --no-daemon --to @rushstack/tree-pattern +``` + +Ordinary `node common/scripts/install-run-rush.js ...` commands also keep working while the daemon is +running. The daemon holds the Rush lock only while it prepares or executes a request, not while idle. + +## Refresh the snapshot + +The snapshot does not pick up later source changes to Rush itself; after changing or pulling changes to +`libraries/rush-lib`, `libraries/rush-daemon`, `libraries/rush-client-core`, or `apps/rush-cli-client`, refresh it. +Stop the daemon first, because `--overwrite` deletes the files that a running daemon executes: + +```bash +rush-client daemon stop +node common/scripts/install-run-rush.js build --to @rushstack/rush-cli-client +node common/scripts/install-run-rush.js deploy --scenario rush-daemon-dogfood --target-folder common/temp/rush-daemon-dogfood --overwrite +``` + +The daemon's identity is the canonical repository root plus the selected Rush version, so each checkout or +worktree has its own daemon, and `rush-client daemon ...` commands address the daemon for the checkout that +contains the current directory. + +## Stop and clean up + +```bash +rush-client daemon stop # prints state "shutdownAccepted" +rush-client daemon status # exits with code 1: no daemon is listening +rush-client daemon logs # launcher log, available even after the daemon stopped +``` + +Remove the snapshot with `rm -rf common/temp/rush-daemon-dogfood` (or `rush purge`, which clears all of +`common/temp`). Stop the daemon before purging or reinstalling. + +## Known limits + +- **Environment identity.** The complete request environment is a daemon input. A request whose + environment differs from the daemon's, such as one from another terminal (with a different `WT_SESSION`, + `WSL_INTEROP`, `TERM_SESSION_ID`, or `VSCODE_*` value) or after changing `PATH`, restarts the daemon before + anything runs and is then served by the new process. This is not a fallback, but it costs a cold start and + changes `pid`. Run related requests from the same terminal. +- **Only phased `build` and `rebuild` use the warm engine.** `rush start` (which always watches), + `--watch`, `--install`, `--variant`, and `--node-diagnostic-dir` stay native, as do build event-hook + scripts and reporter controls such as `--output`. Keep using ordinary Rush for `install`, `update`, and + other commands. `rushx-client` keeps scripts attached to a TTY in-process. +- **No persistent Heft or TypeScript workers.** Each operation still starts its Heft process; the daemon + saves Rush startup and graph construction, not compilation. The `usePersistentIpcRunners`/`daemonIpc` + mode requires a bundled, self-contained worker entry point, and Heft is not packaged that way. +- **Plugins.** This repository's only configured plugin, `@rushstack/rush-published-versions-json-plugin`, + is associated only with `record-published-versions` and is inert for builds. A plugin without + `associatedCommands`, a plugin associated with `build` or `rebuild`, or a plugin command-line that defines + the command, one of its phases, or a parameter for either would make those builds fall back to native Rush. +- **Windows.** Native Windows validation of the daemon code saw unresolved, intermittent failures in which + Git `hash-object --stdin-paths` exited with `0xC0000142` (DLL initialization failed) while the daemon + captured workspace snapshots under Jest. Direct fixtures did not reproduce it. If a daemon-served build + fails this way, retry it with `--no-daemon` and report the failure. +- **CI** stays in-process unless `RUSH_DAEMON=1` is set; do not set it in CI workflows. + +## Version skew and `RUSH_PREVIEW_VERSION` + +`rush-client` runs the daemon with its bundled engine only if that engine's version equals the selected Rush +version (`RUSH_PREVIEW_VERSION`, otherwise `rushVersion` in `rush.json`). Today both are the same, so the +snapshot's engine is used. If a release bumps `apps/rush` and `libraries/rush-lib` without updating +`rushVersion`, the client instead looks for a daemon-capable published release of the selected version. +None exists, so every build falls back to native Rush, with a `rush-client: ... Using in-process Rush.` +message (see [Confirm](#4-confirm-that-the-daemon-served-the-build)). + +To keep dogfooding in that state, select the snapshot's exact version for `rush-client` invocations only: + +```bash +DOGFOOD_VERSION=$(node -p "require('./common/temp/rush-daemon-dogfood/libraries/rush-lib/package.json').version") +rush-client() { RUSH_DAEMON=1 RUSH_PREVIEW_VERSION="$DOGFOOD_VERSION" node "$DOGFOOD_SNAPSHOT/apps/rush-cli-client/bin/rush-client" "$@"; } +``` + +Side effects: + +- The build runs with the snapshot's engine rather than the repository's selected release. +- `RUSH_PREVIEW_VERSION` is part of the daemon's identity and environment, so `rush-client daemon status` + and `rush-client daemon stop` address the matching daemon only when the same value is set. +- A native fallback, including `--no-daemon`, also selects that version. Because it equals the snapshot's + own frontend version, the snapshot's source-built Rush runs in-process, printing the + `RUSH_PREVIEW_VERSION` warning banner, rather than the repository's selected release. Use ordinary + `node common/scripts/install-run-rush.js ...` for builds with the selected release. +- Never export `RUSH_PREVIEW_VERSION` to ordinary `rush`; it would try to install that version from npm. + +## After a release contains the daemon + +Once a published Rush release includes the daemon, the standalone client, and the plugin narrowing that lets +this repository's command-scoped plugin coexist with daemon builds: + +1. Update `rushVersion` in `rush.json` to that release (or a later one). +2. Optionally add `"daemon": { "enabled": true }` to `rush.json`, so that `rush-client` uses the daemon + without `RUSH_DAEMON=1`. CI still stays in-process unless it sets `RUSH_DAEMON=1`, and `RUSH_DAEMON=0` + opts a shell out. +3. Install the published client whose engine matches `rushVersion`, for example + `npm install --global @rushstack/rush-cli-client`, and run `rush-client` directly instead of the snapshot. +4. Stop any snapshot daemon and delete `common/temp/rush-daemon-dogfood`. The `rush-daemon-dogfood` + deployment scenario remains useful for trying unreleased daemon changes. diff --git a/docs/rush/environment-variables.md b/docs/rush/environment-variables.md index b1664e9c731..35f266c2f24 100644 --- a/docs/rush/environment-variables.md +++ b/docs/rush/environment-variables.md @@ -50,6 +50,11 @@ Environment-only opt-in avoids adding a `daemon` block that an older Rush schema `RUSH_PREVIEW_VERSION` continues to select the requested Rush engine; a matching daemon must actually be available. A client never relabels its bundled engine to satisfy a different version. +Releases that predate these variables reject unknown `RUSH_` names. While a repository's `rushVersion` +selects such a release, set `RUSH_DAEMON` only for `rush-client` invocations rather than exporting it +to a shell that also runs ordinary `rush`. The [contributor dogfooding guide](./dogfooding-rush-daemon.md) +shows this for the rushstack repository. + See the [client configuration and management reference](../../apps/rush-cli-client/README.md) for launcher selection, lifecycle commands, persistent-worker restrictions and status fields. diff --git a/libraries/rush-daemon/README.md b/libraries/rush-daemon/README.md index 713b69bec1f..fde6efde309 100644 --- a/libraries/rush-daemon/README.md +++ b/libraries/rush-daemon/README.md @@ -108,9 +108,14 @@ the graph/cache settings with the construction snapshot. Changed inherited or ri generation reload before execution, even outside watcher roots or in ignored `node_modules` files. The retained graph and its cache policy are never patched in place. -External Rush plugins, `.env` initialization, watch/install/variant +External Rush plugins that Rush would initialize for the requested command (plugins without `associatedCommands`, +or associated with that command) or whose cached command-line.json defines that command, one of its phases, or a +parameter associated with either are rejected, as are `.env` initialization, watch/install/variant and diagnostic-directory options, build event-hook scripts (unless explicitly ignored), and arbitrary global -commands are rejected by the phased path, not silently bypassed. Native Rushx is handled separately below. +commands; they are rejected by the phased path, not silently bypassed. Configured plugins that are scoped only to +other commands are inert for the build and are permitted; their autoinstaller `package.json` and cached manifest and +command-line files are workspace definitions, so changing them reloads the generation. An unreadable manifest or +command-line file is rejected. Native Rushx is handled separately below. For phased commands, a changed request environment requires a new process, including Rush/cache policy variables. These restrictions remain until the corresponding initialization, environment, and resource-lifetime contracts are request-scoped. diff --git a/libraries/rush-daemon/src/ProductionDaemonRequestResolver.ts b/libraries/rush-daemon/src/ProductionDaemonRequestResolver.ts index 7a1793510a4..9f81bba821a 100644 --- a/libraries/rush-daemon/src/ProductionDaemonRequestResolver.ts +++ b/libraries/rush-daemon/src/ProductionDaemonRequestResolver.ts @@ -39,8 +39,9 @@ import type { IWorkspaceResolverLifecycle } from './WorkspaceResolverLifecycle'; * @remarks * A host is pinned to its first command and non-selection parameters. Incompatible parameters, * environments, or graph inputs are rejected before scheduling; no request is retried automatically. - * The initial supported surface excludes external plugins, .env initialization, install/watch, - * event-hook scripts, and rushx/global commands. Use the unchanged native CLI for those surfaces. + * The initial supported surface excludes external plugins that participate in the requested command, + * .env initialization, install/watch, event-hook scripts, and rushx/global commands. Use the unchanged + * native CLI for those surfaces. * @beta */ export class ProductionDaemonRequestResolver implements IDaemonRequestResolver { diff --git a/libraries/rush-lib/src/api/PhasedCommandEngine.ts b/libraries/rush-lib/src/api/PhasedCommandEngine.ts index 0fefc3fddf4..98b48a9b4c6 100644 --- a/libraries/rush-lib/src/api/PhasedCommandEngine.ts +++ b/libraries/rush-lib/src/api/PhasedCommandEngine.ts @@ -49,8 +49,10 @@ export interface IParsePhasedCommandOptions { * * @remarks * The initial engine surface deliberately rejects watch/install, event-hook scripts, .env files, and - * externally supplied plugins. Those require request-scoped initialization and asynchronous disposal - * contracts before they can safely run in a shared process. Native graph/cache plugins are not replaced. + * external plugins that Rush would initialize for the command or whose command-line.json shapes it. + * Those require request-scoped initialization and asynchronous disposal contracts before they can + * safely run in a shared process. Plugins associated only with other commands are inert and permitted. + * Native graph/cache plugins are not replaced. * @alpha */ export class PhasedCommandEngine { @@ -74,9 +76,6 @@ export class PhasedCommandEngine { if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) { throw new Error('Command help must be handled by the native CLI, not by an engine request.'); } - if (rushConfiguration._rushPluginsConfiguration.configuration.plugins.length > 0) { - throw new Error('Daemon engine execution does not yet support external Rush plugins. Use --no-daemon.'); - } for (const folder of [rushConfiguration.rushJsonFolder, RushUserConfiguration.getRushUserFolderPath()]) { if (FileSystem.exists(path.join(folder, '.env'))) { throw new Error('Daemon engine execution does not yet support .env initialization. Use --no-daemon.'); @@ -91,6 +90,18 @@ export class PhasedCommandEngine { if (!(action instanceof PhasedScriptAction) || !['build', 'rebuild'].includes(action.actionName)) { throw new Error('The production daemon engine currently supports native build and rebuild only.'); } + // Plugins which the command would never initialize, and whose command-line.json does not shape + // this command, cannot affect a shared engine. Every other external plugin still requires native Rush. + const participatingPlugins: ReadonlyArray = parser.pluginManager.getPluginsParticipatingInCommand( + action.actionName, + action.schedulablePhaseNames + ); + if (participatingPlugins.length > 0) { + throw new Error( + `Daemon engine execution does not yet support Rush plugins that participate in "${action.actionName}": ` + + `${participatingPlugins.join('; ')}. Use --no-daemon.` + ); + } action.validateEngineCommand(); return new PhasedCommandEngine(parser, action); } diff --git a/libraries/rush-lib/src/api/WorkspaceInputFingerprint.ts b/libraries/rush-lib/src/api/WorkspaceInputFingerprint.ts index 3a6271a3bfe..dbfa93eb929 100644 --- a/libraries/rush-lib/src/api/WorkspaceInputFingerprint.ts +++ b/libraries/rush-lib/src/api/WorkspaceInputFingerprint.ts @@ -13,6 +13,7 @@ import type { RushConfiguration } from './RushConfiguration'; import type { RushConfigurationProject } from './RushConfigurationProject'; import { RushProjectConfiguration } from './RushProjectConfiguration'; import { getDaemonIpcImplementationIdentityAsync } from '../logic/operations/DaemonIpcConfiguration'; +import { AutoinstallerPluginLoader } from '../pluginFramework/PluginLoader/AutoinstallerPluginLoader'; /** Stable inputs which distinguish reusable, reloadable, and process-bound workspace state. @alpha */ export interface IWorkspaceInputFingerprint { @@ -152,6 +153,15 @@ export async function captureWorkspaceInputFingerprintAsync( for (const filename of configurationFiles) { (isProcessBoundConfiguration(filename) ? installation : definitions).add(filename); } + // Configured plugins shape the command-line parser even when they are never loaded for a command. + for (const pluginConfiguration of rushConfiguration._rushPluginsConfiguration.configuration.plugins) { + for (const filename of AutoinstallerPluginLoader.getPluginShapeFilePaths( + rushConfiguration, + pluginConfiguration + )) { + definitions.add(filename); + } + } for (const project of rushJson.projects) { const projectFolder: string = path.resolve(root, project.projectFolder); if (!Path.isUnderOrEqual(projectFolder, root)) { diff --git a/libraries/rush-lib/src/api/test/PhasedCommandEngine.test.ts b/libraries/rush-lib/src/api/test/PhasedCommandEngine.test.ts new file mode 100644 index 00000000000..1faae1ece93 --- /dev/null +++ b/libraries/rush-lib/src/api/test/PhasedCommandEngine.test.ts @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { NoOpTerminalProvider } from '@rushstack/terminal'; + +import { PhasedCommandEngine } from '../PhasedCommandEngine'; +import { RushConfiguration } from '../RushConfiguration'; + +const PACKAGE_NAME: string = '@example/rush-example-plugin'; +const PLUGIN_NAME: string = 'rush-example-plugin'; +const PLUGIN_COMMAND: string = 'record-example'; + +interface IPluginFixture { + readonly associatedCommands?: string[]; + readonly commandLineJson?: object; + readonly writeManifest?: boolean; +} + +// Mirrors the command-scoped plugin that rushstack configures in common/config/rush/rush-plugins.json. +const COMMAND_SCOPED_COMMAND_LINE_JSON: object = { + commands: [{ commandKind: 'globalPlugin', name: PLUGIN_COMMAND, summary: 'Records an example.' }], + parameters: [ + { + parameterKind: 'string', + longName: '--output-path', + argumentName: 'FILE_PATH', + description: 'The output path.', + associatedCommands: [PLUGIN_COMMAND] + } + ] +}; + +const REPO_COMMAND_LINE_JSON: object = { + commands: [ + { + commandKind: 'phased', + name: 'build', + summary: 'Build', + phases: ['_phase:build'], + enableParallelism: true, + incremental: true + } + ], + phases: [{ name: '_phase:build', dependencies: { upstream: ['_phase:build'] } }] +}; + +function createRepo(plugin: IPluginFixture): string { + const folder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-engine-plugins-')); + const write = (relativePath: string, json: object): void => { + const filename: string = path.join(folder, relativePath); + fs.mkdirSync(path.dirname(filename), { recursive: true }); + fs.writeFileSync(filename, JSON.stringify(json)); + }; + write('rush.json', { rushVersion: '5.179.0', pnpmVersion: '10.27.0', projects: [] }); + write('common/config/rush/command-line.json', REPO_COMMAND_LINE_JSON); + write('common/config/rush/rush-plugins.json', { + plugins: [{ packageName: PACKAGE_NAME, pluginName: PLUGIN_NAME, autoinstallerName: 'plugins' }] + }); + write('common/autoinstallers/plugins/package.json', { + name: 'plugins', + version: '1.0.0', + private: true, + dependencies: { [PACKAGE_NAME]: '1.0.0' } + }); + const storeFolder: string = `common/autoinstallers/plugins/rush-plugins/${PACKAGE_NAME}`; + if (plugin.writeManifest !== false) { + write(`${storeFolder}/rush-plugin-manifest.json`, { + plugins: [ + { + pluginName: PLUGIN_NAME, + description: 'An example plugin.', + entryPoint: './lib/index.js', + associatedCommands: plugin.associatedCommands, + commandLineJsonFilePath: './command-line.json' + } + ] + }); + } + if (plugin.commandLineJson) { + write(`${storeFolder}/${PLUGIN_NAME}/command-line.json`, plugin.commandLineJson); + } + return folder; +} + +async function parseBuildAsync(folder: string, argv: string[] = ['build']): Promise { + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + path.join(folder, 'rush.json') + ); + return await PhasedCommandEngine.parseAsync({ + argv, + cwd: folder, + rushConfiguration, + terminalProvider: new NoOpTerminalProvider() + }); +} + +describe(PhasedCommandEngine.name, () => { + const folders: string[] = []; + function createTestRepo(plugin: IPluginFixture): string { + const folder: string = createRepo(plugin); + folders.push(folder); + return folder; + } + + afterEach(() => { + for (const folder of folders.splice(0)) { + fs.rmSync(folder, { recursive: true, force: true }); + } + }); + + it('accepts a plugin that is associated with, and only shapes, a different command', async () => { + const folder: string = createTestRepo({ + associatedCommands: [PLUGIN_COMMAND], + commandLineJson: COMMAND_SCOPED_COMMAND_LINE_JSON + }); + for (const commandName of ['build', 'rebuild']) { + const command: PhasedCommandEngine = await parseBuildAsync(folder, [commandName]); + expect(command.commandName).toBe(commandName); + } + }); + + it('accepts a plugin associated with no commands and without a command-line.json', async () => { + const folder: string = createTestRepo({ associatedCommands: [] }); + const command: PhasedCommandEngine = await parseBuildAsync(folder); + expect(command.commandName).toBe('build'); + }); + + it('rejects an unassociated plugin, which Rush initializes for every command', async () => { + const folder: string = createTestRepo({ commandLineJson: COMMAND_SCOPED_COMMAND_LINE_JSON }); + await expect(parseBuildAsync(folder)).rejects.toThrow( + `Daemon engine execution does not yet support Rush plugins that participate in "build": ` + + `"${PLUGIN_NAME}" (${PACKAGE_NAME}) is initialized for every command. Use --no-daemon.` + ); + }); + + it('rejects a plugin associated with the requested command', async () => { + const folder: string = createTestRepo({ + associatedCommands: [PLUGIN_COMMAND, 'build'], + commandLineJson: COMMAND_SCOPED_COMMAND_LINE_JSON + }); + await expect(parseBuildAsync(folder)).rejects.toThrow( + `"${PLUGIN_NAME}" (${PACKAGE_NAME}) is associated with "build"` + ); + // The association is specific to the requested command. + await expect(parseBuildAsync(folder, ['rebuild'])).resolves.toBeInstanceOf(PhasedCommandEngine); + }); + + it('rejects a plugin parameter associated with the requested command', async () => { + const folder: string = createTestRepo({ + associatedCommands: [PLUGIN_COMMAND], + commandLineJson: { + commands: [ + { commandKind: 'globalPlugin', name: PLUGIN_COMMAND, summary: 'Records an example.' }, + { commandKind: 'bulk', name: 'build', summary: 'Build', enableParallelism: true } + ], + parameters: [ + { + parameterKind: 'flag', + longName: '--example-flag', + description: 'An example flag.', + associatedCommands: [PLUGIN_COMMAND, 'build'] + } + ] + } + }); + // A plugin-defined build replaces the repository build, so the repository must not also define it. + fs.rmSync(path.join(folder, 'common/config/rush/command-line.json')); + await expect(parseBuildAsync(folder)).rejects.toThrow( + `"${PLUGIN_NAME}" (${PACKAGE_NAME}) associates "--example-flag" with "build"` + ); + }); + + it('rejects a plugin parameter associated with a phase of the requested command', async () => { + const folder: string = createTestRepo({ + associatedCommands: [PLUGIN_COMMAND], + commandLineJson: { + ...COMMAND_SCOPED_COMMAND_LINE_JSON, + phases: [{ name: '_phase:build' }], + parameters: [ + { + parameterKind: 'flag', + longName: '--example-flag', + description: 'An example flag.', + associatedCommands: [PLUGIN_COMMAND], + associatedPhases: ['_phase:build'] + } + ] + } + }); + await expect(parseBuildAsync(folder)).rejects.toThrow( + `"${PLUGIN_NAME}" (${PACKAGE_NAME}) associates "--example-flag" with the "_phase:build" phase` + ); + }); + + it('fails closed when a configured plugin manifest cannot be read', async () => { + const folder: string = createTestRepo({ writeManifest: false }); + await expect(parseBuildAsync(folder)).rejects.toThrow( + `"${PLUGIN_NAME}" (${PACKAGE_NAME}): its manifest could not be read` + ); + }); +}); diff --git a/libraries/rush-lib/src/api/test/WorkspaceInputFingerprint.test.ts b/libraries/rush-lib/src/api/test/WorkspaceInputFingerprint.test.ts index 8a19aba368c..3d6e97dd0da 100644 --- a/libraries/rush-lib/src/api/test/WorkspaceInputFingerprint.test.ts +++ b/libraries/rush-lib/src/api/test/WorkspaceInputFingerprint.test.ts @@ -41,6 +41,52 @@ describe('workspace input fingerprints', () => { } }); + it('reloads when a configured plugin shape outside common/config changes', async () => { + const folder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-fingerprint-')); + try { + const write = (relativePath: string, content: string): void => { + const filename: string = path.join(folder, relativePath); + fs.mkdirSync(path.dirname(filename), { recursive: true }); + fs.writeFileSync(filename, content); + }; + write('rush.json', JSON.stringify({ rushVersion: '5.179.0', pnpmVersion: '10.27.0', projects: [] })); + write( + 'common/config/rush/rush-plugins.json', + JSON.stringify({ + plugins: [{ packageName: '@example/plugin', pluginName: 'example', autoinstallerName: 'plugins' }] + }) + ); + const store: string = 'common/autoinstallers/plugins/rush-plugins/@example/plugin'; + write('common/autoinstallers/plugins/package.json', '{"name":"plugins","version":"1.0.0"}'); + write(`${store}/rush-plugin-manifest.json`, '{"plugins":[]}'); + write(`${store}/example/command-line.json`, '{"commands":[]}'); + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + path.join(folder, 'rush.json') + ); + const runtimeCache: WorkspaceRuntimeFingerprintCache = new WorkspaceRuntimeFingerprintCache(); + const captureAsync = (): Promise => + captureWorkspaceInputFingerprintAsync({ rushConfiguration, runtimeCache, environment: {} }); + + let previous: IWorkspaceInputFingerprint = await captureAsync(); + for (const [relativePath, content] of [ + [`${store}/example/command-line.json`, '{"commands":[],"parameters":[]}'], + [`${store}/rush-plugin-manifest.json`, '{"plugins":[{}]}'], + ['common/autoinstallers/plugins/package.json', '{"name":"plugins","version":"1.0.1"}'] + ]) { + write(relativePath, content); + const next: IWorkspaceInputFingerprint = await captureAsync(); + expect(classifyWorkspaceInputChange(previous, next)).toBe(WorkspaceInputChangeTier.Reload); + previous = next; + } + fs.rmSync(path.join(folder, `${store}/example/command-line.json`)); + expect(classifyWorkspaceInputChange(previous, await captureAsync())).toBe( + WorkspaceInputChangeTier.Reload + ); + } finally { + fs.rmSync(folder, { recursive: true, force: true }); + } + }); + it('classifies content, configuration and process-bound identities', () => { const current: IWorkspaceInputFingerprint = { configurationHash: 'configuration', diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index 2818bca1f7a..2f1eb707b9e 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -345,6 +345,17 @@ export class PhasedScriptAction extends BaseScriptAction i await this.#runAsync(); } + /** The names of every phase this command can schedule, including dependency and watch phases. */ + public get schedulablePhaseNames(): ReadonlySet { + const phaseNames: Set = new Set(); + for (const phases of [this.#originalPhases, this.#initialPhases, this.#watchPhases]) { + for (const phase of phases) { + phaseNames.add(phase.name); + } + } + return phaseNames; + } + public validateEngineCommand(): void { if ( this.#alwaysWatch || diff --git a/libraries/rush-lib/src/pluginFramework/PluginLoader/AutoinstallerPluginLoader.ts b/libraries/rush-lib/src/pluginFramework/PluginLoader/AutoinstallerPluginLoader.ts index 7528fe72ea7..8c464bcc6de 100644 --- a/libraries/rush-lib/src/pluginFramework/PluginLoader/AutoinstallerPluginLoader.ts +++ b/libraries/rush-lib/src/pluginFramework/PluginLoader/AutoinstallerPluginLoader.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import { + FileConstants, FileSystem, JsonFile, NewlineKind, @@ -13,6 +14,7 @@ import { } from '@rushstack/node-core-library'; import type { IRushPluginConfiguration } from '../../api/RushPluginsConfiguration'; +import type { RushConfiguration } from '../../api/RushConfiguration'; import { Autoinstaller } from '../../logic/Autoinstaller'; import { RushConstants } from '../../logic/RushConstants'; import { @@ -53,7 +55,29 @@ export class AutoinstallerPluginLoader extends PluginLoaderBase\rush-plugins` */ public static getPluginAutoinstallerStorePath(autoinstaller: Autoinstaller): string { - return path.join(autoinstaller.folderFullPath, 'rush-plugins'); + return getStorePath(autoinstaller.folderFullPath); + } + + /** + * Files outside `common/config` from which Rush reads a configured plugin's manifest and command-line + * shape without loading the plugin: the autoinstaller package.json, and the cached manifest and + * command-line.json that `rush update` copies into the autoinstaller's store. + */ + public static getPluginShapeFilePaths( + rushConfiguration: RushConfiguration, + pluginConfiguration: IRushPluginConfiguration + ): string[] { + const { autoinstallerName, packageName, pluginName } = pluginConfiguration; + const autoinstallerFolder: string = path.join( + rushConfiguration.commonAutoinstallersFolder, + autoinstallerName + ); + const storePath: string = getStorePath(autoinstallerFolder); + return [ + path.join(autoinstallerFolder, FileConstants.PackageJson), + getCachedManifestPath(storePath, packageName), + getCachedCommandLineJsonFilePath(storePath, packageName, pluginName) + ]; } public update(): void { @@ -155,19 +179,33 @@ export class AutoinstallerPluginLoader extends PluginLoaderBase + ): ReadonlyArray { + const reasons: string[] = []; + for (const pluginLoader of this.#autoinstallerPluginLoaders) { + const pluginLabel: string = `"${pluginLoader.pluginName}" (${pluginLoader.packageName})`; + let associatedCommands: ReadonlyArray | undefined; + try { + associatedCommands = pluginLoader.pluginManifest.associatedCommands; + } catch (error) { + reasons.push(`${pluginLabel}: its manifest could not be read: ${(error as Error).message}`); + continue; + } + if (!associatedCommands) { + reasons.push(`${pluginLabel} is initialized for every command`); + } else if (associatedCommands.includes(commandName)) { + reasons.push(`${pluginLabel} is associated with "${commandName}"`); + } + + let commandLineConfiguration: CommandLineConfiguration | undefined; + try { + commandLineConfiguration = pluginLoader.getCommandLineConfiguration(); + } catch (error) { + reasons.push(`${pluginLabel}: its command-line.json could not be read: ${(error as Error).message}`); + continue; + } + if (!commandLineConfiguration) { + continue; + } + if (commandLineConfiguration.commands.has(commandName)) { + reasons.push(`${pluginLabel} defines the "${commandName}" command`); + } + for (const phaseName of commandLineConfiguration.phases.keys()) { + if (phaseNames.has(phaseName)) { + reasons.push(`${pluginLabel} defines the "${phaseName}" phase`); + } + } + for (const parameter of commandLineConfiguration.parameters) { + const { longName, associatedPhases } = parameter; + if (parameter.associatedCommands?.includes(commandName)) { + reasons.push(`${pluginLabel} associates "${longName}" with "${commandName}"`); + } + for (const phaseName of associatedPhases ?? []) { + if (phaseNames.has(phaseName)) { + reasons.push(`${pluginLabel} associates "${longName}" with the "${phaseName}" phase`); + } + } + } + } + return reasons; + } + #initializePlugins(pluginLoaders: PluginLoaderBase[]): void { for (const pluginLoader of pluginLoaders) { const pluginName: string = pluginLoader.pluginName; From eafab7e6f500ab4a8b7315d7c6f7bd4b0684ca72 Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 23 Sep 2026 17:36:32 +0000 Subject: [PATCH 2/9] Add change files Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...arkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json | 10 ++++++++++ ...arkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json | 10 ++++++++++ ...arkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json | 10 ++++++++++ 3 files changed, 30 insertions(+) create mode 100644 common/changes/@microsoft/rush/thelarkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json create mode 100644 common/changes/@rushstack/rush-cli-client/thelarkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json create mode 100644 common/changes/@rushstack/rush-daemon/thelarkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json diff --git a/common/changes/@microsoft/rush/thelarkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json b/common/changes/@microsoft/rush/thelarkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json new file mode 100644 index 00000000000..2cc94db24b4 --- /dev/null +++ b/common/changes/@microsoft/rush/thelarkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Allow daemon build and rebuild requests in repositories whose external Rush plugins are associated only with other commands, and reload the daemon generation when a configured plugin's autoinstaller package.json, cached manifest, or command-line.json changes.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush" +} diff --git a/common/changes/@rushstack/rush-cli-client/thelarkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json b/common/changes/@rushstack/rush-cli-client/thelarkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json new file mode 100644 index 00000000000..452883b2c56 --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/thelarkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Document the plugin narrowing and link the rushstack contributor guide for dogfooding the daemon from source.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-cli-client" +} diff --git a/common/changes/@rushstack/rush-daemon/thelarkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json b/common/changes/@rushstack/rush-daemon/thelarkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json new file mode 100644 index 00000000000..d8691f7d626 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/thelarkinn-dogfood-rush-daemon_2026-09-23-17-36-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Document that external Rush plugins scoped only to other commands no longer prevent daemon builds.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-daemon" +} From 0da26eae013b8fdd5e3ee46ffcf4c529fe8bcf9f Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 23 Sep 2026 18:02:16 +0000 Subject: [PATCH 3/9] docs: record Windows validation and daemon process check in dogfooding guide Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/rush/dogfooding-rush-daemon.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/rush/dogfooding-rush-daemon.md b/docs/rush/dogfooding-rush-daemon.md index 0dce3be30fb..ea347993885 100644 --- a/docs/rush/dogfooding-rush-daemon.md +++ b/docs/rush/dogfooding-rush-daemon.md @@ -103,7 +103,8 @@ in-process Rush. Check all of the following: `true`. 3. **The same process.** `pid` and `workspace.generationToken` stay the same across requests from the same terminal. `workspace.lastReloadTier` is `0` when the request reused the existing graph, and `1` after an - in-process reload (for example, the first request, or a configuration change). + in-process reload (for example, the first request, or a configuration change). The command line of that + `pid` runs `SelectedDaemonBootstrap.js` from `common/temp/rush-daemon-dogfood`, not from the workspace. 4. **Parity.** For the same selection, ordinary Rush produces the same outputs. For example, after a daemon-served build, `node common/scripts/install-run-rush.js rebuild --only @rushstack/tree-pattern` leaves the files in `lib-commonjs`, `lib-dts`, `lib-esm`, and `dist` unchanged. @@ -184,8 +185,9 @@ Remove the snapshot with `rm -rf common/temp/rush-daemon-dogfood` (or `rush purg the command, one of its phases, or a parameter for either would make those builds fall back to native Rush. - **Windows.** Native Windows validation of the daemon code saw unresolved, intermittent failures in which Git `hash-object --stdin-paths` exited with `0xC0000142` (DLL initialization failed) while the daemon - captured workspace snapshots under Jest. Direct fixtures did not reproduce it. If a daemon-served build - fails this way, retry it with `--no-daemon` and report the failure. + captured workspace snapshots under Jest. Direct fixtures did not reproduce it, and it did not occur while + this workflow was validated on native Windows. The cause is unresolved, so treat it as an open risk: if a + daemon-served build fails this way, retry it with `--no-daemon` and report the failure. - **CI** stays in-process unless `RUSH_DAEMON=1` is set; do not set it in CI workflows. ## Version skew and `RUSH_PREVIEW_VERSION` From ef9a3fcd48c1ec0ffd4cb9caf058404aced41013 Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 23 Sep 2026 21:21:07 +0000 Subject: [PATCH 4/9] rush-daemon: hide console windows of tools the detached Windows daemon starts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...bprocess-consoles_2026-09-23-21-30-00.json | 10 ++ .../src/SelectedDaemonBootstrap.ts | 5 + .../src/WindowsSubprocessConsoles.ts | 89 ++++++++++++++ .../test/WindowsSubprocessConsoles.test.ts | 112 ++++++++++++++++++ 4 files changed, 216 insertions(+) create mode 100644 common/changes/@rushstack/rush-daemon/thelarkinn-windows-hide-subprocess-consoles_2026-09-23-21-30-00.json create mode 100644 libraries/rush-daemon/src/WindowsSubprocessConsoles.ts create mode 100644 libraries/rush-daemon/src/test/WindowsSubprocessConsoles.test.ts diff --git a/common/changes/@rushstack/rush-daemon/thelarkinn-windows-hide-subprocess-consoles_2026-09-23-21-30-00.json b/common/changes/@rushstack/rush-daemon/thelarkinn-windows-hide-subprocess-consoles_2026-09-23-21-30-00.json new file mode 100644 index 00000000000..1ab4d195b38 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/thelarkinn-windows-hide-subprocess-consoles_2026-09-23-21-30-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "On Windows, keep tools started by the detached daemon (Git, tar and operation shells) from opening visible console windows.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-daemon" +} \ No newline at end of file diff --git a/libraries/rush-daemon/src/SelectedDaemonBootstrap.ts b/libraries/rush-daemon/src/SelectedDaemonBootstrap.ts index 7b8a6b1d2cf..ee8d7b1e977 100644 --- a/libraries/rush-daemon/src/SelectedDaemonBootstrap.ts +++ b/libraries/rush-daemon/src/SelectedDaemonBootstrap.ts @@ -13,6 +13,7 @@ import { type IDaemonInstallationMetadata, type IInstalledDaemonLauncher } from './DaemonInstallation'; +import { installWindowsHideDefault } from './WindowsSubprocessConsoles'; async function mainAsync(): Promise { const [mode, packageJsonPath, expectedVersion, repoRoot] = process.argv.slice(2); @@ -95,6 +96,10 @@ async function mainAsync(): Promise { configured.daemon, process.env ); + if (process.platform === 'win32') { + // This process was started detached, without a console; keep its tools from opening console windows. + installWindowsHideDefault(); + } await serveRushDaemonAsync({ repoRoot, rushVersion: installation.rushVersion, diff --git a/libraries/rush-daemon/src/WindowsSubprocessConsoles.ts b/libraries/rush-daemon/src/WindowsSubprocessConsoles.ts new file mode 100644 index 00000000000..9551a0f04b0 --- /dev/null +++ b/libraries/rush-daemon/src/WindowsSubprocessConsoles.ts @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import childProcessModule from 'node:child_process'; +import { syncBuiltinESMExports } from 'node:module'; + +type ChildProcessFunction = (...args: unknown[]) => unknown; + +/** + * The index of the options argument of a child_process function, given its arguments. + */ +type OptionsIndexSelector = (args: ReadonlyArray) => number; + +const installedTargets: WeakSet = new WeakSet(); + +/** `(command, args?, options?, callback?)`: spawn, spawnSync, execFile, execFileSync and fork. */ +function afterOptionalArgs(args: ReadonlyArray): number { + return Array.isArray(args[1]) || (args[1] === undefined && args.length > 2) || args[1] === null ? 2 : 1; +} + +/** `(command, options?, callback?)`: exec and execSync. */ +function afterCommand(): number { + return 1; +} + +const OPTIONS_INDEX_BY_FUNCTION: ReadonlyMap = new Map([ + ['spawn', afterOptionalArgs], + ['spawnSync', afterOptionalArgs], + ['execFile', afterOptionalArgs], + ['execFileSync', afterOptionalArgs], + ['fork', afterOptionalArgs], + ['exec', afterCommand], + ['execSync', afterCommand] +]); + +/** + * Returns the arguments with `windowsHide: true` added to the options, unless the caller specified it. + */ +export function withWindowsHideDefault(args: ReadonlyArray, optionsIndex: number): unknown[] { + const result: unknown[] = [...args]; + const options: unknown = result[optionsIndex]; + if (options === undefined || options === null) { + result[optionsIndex] = { windowsHide: true }; + } else if (typeof options === 'function') { + // A callback occupies the options position, as in `execFile(file, callback)`. + result.splice(optionsIndex, 0, { windowsHide: true }); + } else if ( + typeof options === 'object' && + (options as { windowsHide?: unknown }).windowsHide === undefined + ) { + result[optionsIndex] = { ...options, windowsHide: true }; + } + return result; +} + +/** + * Makes every child process started by this process default to `windowsHide: true`. + * + * @remarks + * The standalone daemon is started as a detached Windows process, which has no console. Windows then + * gives each console program it starts (Git, tar, the shell running an operation, and so on) a new, + * visible console window, unless that program is started with `windowsHide`. A hidden child instead + * owns a windowless console, which its own descendants inherit, so defaulting this process's direct + * children is sufficient. Library and plugin code starts those children, so the default is applied + * where they all resolve it: the `node:child_process` module. Explicit `windowsHide` values are kept. + * + * Only the dedicated daemon process calls this, and only on Windows. It is idempotent. + */ +export function installWindowsHideDefault( + target: Record = childProcessModule as unknown as Record +): void { + if (installedTargets.has(target)) { + return; + } + installedTargets.add(target); + for (const [name, getOptionsIndex] of OPTIONS_INDEX_BY_FUNCTION) { + const original: ChildProcessFunction = target[name] as ChildProcessFunction; + if (typeof original !== 'function') { + continue; + } + target[name] = function (this: unknown, ...args: unknown[]): unknown { + return original.apply(this, withWindowsHideDefault(args, getOptionsIndex(args))); + }; + } + if (target === (childProcessModule as unknown as Record)) { + // Also update the named exports seen by ECMAScript modules. + syncBuiltinESMExports(); + } +} diff --git a/libraries/rush-daemon/src/test/WindowsSubprocessConsoles.test.ts b/libraries/rush-daemon/src/test/WindowsSubprocessConsoles.test.ts new file mode 100644 index 00000000000..3241c18ca5e --- /dev/null +++ b/libraries/rush-daemon/src/test/WindowsSubprocessConsoles.test.ts @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as childProcess from 'node:child_process'; + +import { installWindowsHideDefault, withWindowsHideDefault } from '../WindowsSubprocessConsoles'; + +describe('Windows subprocess console defaults', () => { + const callback = (): void => {}; + + it('adds windowsHide to every child_process call shape unless the caller chose a value', () => { + expect(withWindowsHideDefault(['git'], 1)).toEqual(['git', { windowsHide: true }]); + expect(withWindowsHideDefault(['git', ['status']], 2)).toEqual([ + 'git', + ['status'], + { windowsHide: true } + ]); + expect(withWindowsHideDefault(['git', ['status'], { cwd: '/repo' }], 2)).toEqual([ + 'git', + ['status'], + { cwd: '/repo', windowsHide: true } + ]); + expect(withWindowsHideDefault(['git', { stdio: 'pipe' }], 1)).toEqual([ + 'git', + { stdio: 'pipe', windowsHide: true } + ]); + expect(withWindowsHideDefault(['git', callback], 1)).toEqual(['git', { windowsHide: true }, callback]); + expect(withWindowsHideDefault(['git', ['status'], callback], 2)).toEqual([ + 'git', + ['status'], + { windowsHide: true }, + callback + ]); + expect(withWindowsHideDefault(['git', ['status'], { windowsHide: false }], 2)).toEqual([ + 'git', + ['status'], + { windowsHide: false } + ]); + expect(withWindowsHideDefault(['git', ['status'], { windowsHide: undefined }], 2)).toEqual([ + 'git', + ['status'], + { windowsHide: true } + ]); + }); + + it('does not modify the caller options object', () => { + const options: childProcess.SpawnOptions = { cwd: '/repo' }; + withWindowsHideDefault(['git', [], options], 2); + expect(options).toEqual({ cwd: '/repo' }); + }); + + it('wraps each child_process function once, preserving results and argument positions', () => { + const calls: Array<[string, unknown[]]> = []; + const record = + (name: string): ((...args: unknown[]) => string) => + (...args: unknown[]): string => { + calls.push([name, args]); + return name; + }; + const target: Record = { + spawn: record('spawn'), + spawnSync: record('spawnSync'), + execFile: record('execFile'), + execFileSync: record('execFileSync'), + fork: record('fork'), + exec: record('exec'), + execSync: record('execSync'), + unrelated: record('unrelated') + }; + const unrelated: unknown = target.unrelated; + installWindowsHideDefault(target); + const spawn: unknown = target.spawn; + installWindowsHideDefault(target); + expect(target.spawn).toBe(spawn); + expect(target.unrelated).toBe(unrelated); + + const call = (name: string, ...args: unknown[]): unknown => + (target[name] as (...callArgs: unknown[]) => unknown)(...args); + expect(call('spawn', 'git', ['status'])).toBe('spawn'); + call('spawnSync', 'git', { encoding: 'utf8' }); + call('execFile', 'git', ['status'], callback); + call('execFileSync', 'git', ['status'], { windowsHide: false }); + call('fork', './worker.js', ['--flag']); + call('exec', 'git status', callback); + call('execSync', 'git status'); + call('unrelated', 'git'); + expect(calls).toEqual([ + ['spawn', ['git', ['status'], { windowsHide: true }]], + ['spawnSync', ['git', { encoding: 'utf8', windowsHide: true }]], + ['execFile', ['git', ['status'], { windowsHide: true }, callback]], + ['execFileSync', ['git', ['status'], { windowsHide: false }]], + ['fork', ['./worker.js', ['--flag'], { windowsHide: true }]], + ['exec', ['git status', { windowsHide: true }, callback]], + ['execSync', ['git status', { windowsHide: true }]], + ['unrelated', ['git']] + ]); + }); + + it('still runs real child processes through the wrapped functions', () => { + // A copy, so that this test does not change the child_process module shared with other tests. + const target: Record = { ...childProcess }; + installWindowsHideDefault(target); + const spawnSync: typeof childProcess.spawnSync = target.spawnSync as typeof childProcess.spawnSync; + const result: childProcess.SpawnSyncReturns = spawnSync( + process.execPath, + ['-e', 'process.stdout.write("ok")'], + { encoding: 'utf8' } + ); + expect(result.status).toBe(0); + expect(result.stdout).toBe('ok'); + }); +}); From 278a3d276f56ed67e0420abf8fa94d5598c7ea5a Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 23 Sep 2026 21:26:25 +0000 Subject: [PATCH 5/9] docs: describe hidden Windows tool consoles and silent up-to-date builds Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/rush/dogfooding-rush-daemon.md | 8 ++++++-- libraries/rush-daemon/README.md | 5 +++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/rush/dogfooding-rush-daemon.md b/docs/rush/dogfooding-rush-daemon.md index ea347993885..58a549ee197 100644 --- a/docs/rush/dogfooding-rush-daemon.md +++ b/docs/rush/dogfooding-rush-daemon.md @@ -109,8 +109,9 @@ in-process Rush. Check all of the following: daemon-served build, `node common/scripts/install-run-rush.js rebuild --only @rushstack/tree-pattern` leaves the files in `lib-commonjs`, `lib-dts`, `lib-esm`, and `dist` unchanged. -A warm request whose selected operations are all up to date can finish without printing anything, and exits -with code `0`. +A warm request whose selected operations are all up to date finishes **without printing anything**, and exits +with code `0`. Unlike ordinary Rush, the daemon client does not yet print a summary, so "no output" after a +build that already ran means everything was up to date; `rush-client daemon status` confirms that it was served. For example, in one Bash terminal after steps 1–3: @@ -188,6 +189,9 @@ Remove the snapshot with `rm -rf common/temp/rush-daemon-dogfood` (or `rush purg captured workspace snapshots under Jest. Direct fixtures did not reproduce it, and it did not occur while this workflow was validated on native Windows. The cause is unresolved, so treat it as an open risk: if a daemon-served build fails this way, retry it with `--no-daemon` and report the failure. + The daemon runs without a console, so it starts its tools (Git, tar and operation shells) with hidden + windows. Daemons started from a snapshot older than that change open a visible terminal window for each + tool; if you see that, [refresh the snapshot](#refresh-the-snapshot). - **CI** stays in-process unless `RUSH_DAEMON=1` is set; do not set it in CI workflows. ## Version skew and `RUSH_PREVIEW_VERSION` diff --git a/libraries/rush-daemon/README.md b/libraries/rush-daemon/README.md index fde6efde309..9c50aa7bb36 100644 --- a/libraries/rush-daemon/README.md +++ b/libraries/rush-daemon/README.md @@ -142,6 +142,11 @@ an installation using native Rush package-install APIs; the host does not instal packages during restart. Selection probes foreign runtimes in isolation, and launch rechecks the actual engine version and protocol before binding. An unavailable or incompatible installation is never impersonated by the bundled engine. + +On Windows the standalone launcher starts the daemon detached, without a console. Before serving, the launched +daemon therefore makes `windowsHide: true` the default for every `node:child_process` call that does not choose a +value, so Git, tar, operation shells and plugin tools do not each open a visible console window. Their descendants +inherit the resulting windowless console. Embedded hosts, which own their process, are not changed. Embedded `RushDaemonHost` users can provide `getSuccessorLaunchAsync`, returning the existing core `IDaemonStartCommand` plus the expected daemon implementation version. Selection is validated before shutdown; an unavailable selected Rush version fails explicitly and is never run by the current engine under a false version. From 8122fae49f321209cce44d95f0d530abb8a58550 Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 23 Sep 2026 21:43:33 +0000 Subject: [PATCH 6/9] rush-cli-client: start without loading the Rush engine; cheaper runtime fingerprint The client only needs to locate rush.json, read daemon settings, parse rushx arguments and build its daemon start command. Move those into small rush-lib and rush-daemon modules so the client no longer loads the whole engine for every invocation, and load version selection and rushx discovery only when needed. The runtime fingerprint now also covers the bundle chunks under dist, which contain the implementation, and recomputes a resolved path only when a file stat changes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../rush-cli-client/src/bundledRushVersion.ts | 11 ++ .../src/daemonConnectionOptions.ts | 18 +-- apps/rush-cli-client/src/launchClient.ts | 22 ++-- apps/rush-cli-client/src/routing.ts | 7 +- .../rush-daemon/src/DaemonLaunchCommand.ts | 49 ++++++++ .../src/VersionSelectedDaemonLauncher.ts | 51 ++------- .../rush-lib/src/api/RushConfiguration.ts | 33 +----- .../rush-lib/src/api/RushJsonLocation.ts | 44 +++++++ .../src/api/WorkspaceInputFingerprint.ts | 9 +- .../rush-lib/src/cli/RushXCommandLine.ts | 101 +---------------- .../src/cli/RushXCommandLineArguments.ts | 107 ++++++++++++++++++ 11 files changed, 266 insertions(+), 186 deletions(-) create mode 100644 apps/rush-cli-client/src/bundledRushVersion.ts create mode 100644 libraries/rush-daemon/src/DaemonLaunchCommand.ts create mode 100644 libraries/rush-lib/src/api/RushJsonLocation.ts create mode 100644 libraries/rush-lib/src/cli/RushXCommandLineArguments.ts diff --git a/apps/rush-cli-client/src/bundledRushVersion.ts b/apps/rush-cli-client/src/bundledRushVersion.ts new file mode 100644 index 00000000000..67a69f30bf9 --- /dev/null +++ b/apps/rush-cli-client/src/bundledRushVersion.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. + +import { JsonFile } from '@rushstack/node-core-library'; + +/** + * The version of the bundled Rush engine, which is `Rush.version`, read without loading the engine. + */ +export const BUNDLED_RUSH_VERSION: string = JsonFile.load( + require.resolve('@microsoft/rush-lib/package.json') +).version; diff --git a/apps/rush-cli-client/src/daemonConnectionOptions.ts b/apps/rush-cli-client/src/daemonConnectionOptions.ts index ab4b5297f85..61de58bd3c0 100644 --- a/apps/rush-cli-client/src/daemonConnectionOptions.ts +++ b/apps/rush-cli-client/src/daemonConnectionOptions.ts @@ -3,17 +3,17 @@ 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'; + getSelectedDaemonStartCommand +} from '@rushstack/rush-daemon/lib/DaemonLaunchCommand'; +import type { IVersionSelectedDaemonLaunch } from '@rushstack/rush-daemon/lib/VersionSelectedDaemonLauncher'; + +import { BUNDLED_RUSH_VERSION } from './bundledRushVersion'; export function getDaemonConnectionOptions( repoRoot: string, @@ -57,9 +57,13 @@ 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) + // The bundled runtime needs no selection; its bootstrap re-attests before binding. + if (rushVersion === BUNDLED_RUSH_VERSION) return getDaemonConnectionOptions(repoRoot, rushVersion, environment, true); + // Selecting another installation uses the engine's installer APIs, so it is loaded only when needed. + const { selectDaemonLauncherAsync } = await import( + '@rushstack/rush-daemon/lib/VersionSelectedDaemonLauncher' + ); const launch: IVersionSelectedDaemonLaunch = await selectDaemonLauncherAsync({ repoRoot: fs.realpathSync.native(repoRoot), rushVersion, diff --git a/apps/rush-cli-client/src/launchClient.ts b/apps/rush-cli-client/src/launchClient.ts index b739b9fadf7..a8ef4a97dde 100644 --- a/apps/rush-cli-client/src/launchClient.ts +++ b/apps/rush-cli-client/src/launchClient.ts @@ -4,12 +4,11 @@ import * as path from 'node:path'; import { - Rush, - RushConfiguration, daemonEnvironmentVariables, resolveDaemonConfiguration, type IDaemonConfigurationJson -} from '@microsoft/rush-lib'; +} from '@microsoft/rush-lib/lib/api/DaemonConfiguration'; +import { tryFindRushJsonLocation } from '@microsoft/rush-lib/lib/api/RushJsonLocation'; import { JsonFile } from '@rushstack/node-core-library'; import { DaemonClientError, @@ -22,9 +21,9 @@ 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 { DaemonLauncherUnavailableError } from '@rushstack/rush-daemon/lib/DaemonLaunchCommand'; +import { BUNDLED_RUSH_VERSION } from './bundledRushVersion'; import { executeDaemonCommandAsync } from './daemonCommands'; import { ClientOperationRenderer } from './ClientOperationRenderer'; import { getDaemonConnectionOptionsAsync } from './daemonConnectionOptions'; @@ -39,7 +38,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({ startingFolder: cwd }); const workspace: IWorkspaceJson | undefined = rushJsonPath ? JsonFile.load(rushJsonPath) : undefined; const config: Readonly> = resolveDaemonConfiguration( workspace?.daemon, @@ -52,7 +51,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 ?? BUNDLED_RUSH_VERSION; 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]}.`); @@ -145,7 +145,11 @@ export async function launchClientAsync(rushx: boolean): Promise { } }; try { - if (rushx) MinimalRushConfiguration.loadFromDefaultLocation((line) => discoveryLines.push(line)); + if (rushx) { + // Rushx discovery output needs the frontend's configuration loader, which loads the engine. + const { MinimalRushConfiguration } = await import('@microsoft/rush/lib/MinimalRushConfiguration'); + MinimalRushConfiguration.loadFromDefaultLocation((line) => discoveryLines.push(line)); + } await renderer.initializeAsync(); outcome = await executeWithDaemonRestartAsync(client, connection, { request, @@ -207,7 +211,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 !== BUNDLED_RUSH_VERSION) { // 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/routing.ts b/apps/rush-cli-client/src/routing.ts index 310960249bf..d08f192d970 100644 --- a/apps/rush-cli-client/src/routing.ts +++ b/apps/rush-cli-client/src/routing.ts @@ -2,7 +2,10 @@ // 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 { + parseRushXCommandLineArguments, + type IRushXCommandLineArguments +} from '@microsoft/rush-lib/lib/cli/RushXCommandLineArguments'; import { parseClientAdmissionControls, type IClientAdmissionControls } from './ClientAdmissionControls'; @@ -53,7 +56,7 @@ export function selectClientRoute(options: IClientRouteOptions): IClientRoute { ...(separator < 0 ? [] : controls.argv.slice(separator)) ]; const rushxArguments: IRushXCommandLineArguments | undefined = options.rushx - ? RushXCommand.parseArguments(argv, options.environment) + ? parseRushXCommandLineArguments(argv, options.environment) : undefined; const commandName: string | undefined = rushxArguments ? rushxArguments.commandName || undefined : argv[0]; const reporterControls: boolean = diff --git a/libraries/rush-daemon/src/DaemonLaunchCommand.ts b/libraries/rush-daemon/src/DaemonLaunchCommand.ts new file mode 100644 index 00000000000..950a8d3c4ce --- /dev/null +++ b/libraries/rush-daemon/src/DaemonLaunchCommand.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IDaemonStartCommand } from '@rushstack/rush-client-core'; + +import type { IInstalledDaemonLauncher } from './DaemonInstallation'; + +// These launch definitions do not load the Rush engine, so that a client can start or reach its +// bundled daemon cheaply. Version selection and installation remain in VersionSelectedDaemonLauncher. + +export interface IDaemonLauncherContext { + readonly repoRoot: string; + readonly rushVersion: string; + readonly environment: Readonly; +} + +export class DaemonLauncherUnavailableError extends Error { + public readonly installation: IInstalledDaemonLauncher | undefined; + + public constructor(rushVersion: string, reason: string, installation?: IInstalledDaemonLauncher) { + super(`Cannot launch selected Rush ${rushVersion}: ${reason} Use native Rush instead.`); + this.name = 'DaemonLauncherUnavailableError'; + this.installation = installation; + } +} + +export function getSelectedDaemonStartCommand( + daemonPackageJsonPath: string, + context: IDaemonLauncherContext +): IDaemonStartCommand { + return { + command: process.execPath, + args: [ + require.resolve('./SelectedDaemonBootstrap'), + '--launch', + daemonPackageJsonPath, + context.rushVersion, + context.repoRoot + ], + cwd: context.repoRoot, + environment: Object.freeze( + Object.fromEntries( + Object.entries(context.environment).filter( + (entry): entry is [string, string] => entry[1] !== undefined + ) + ) + ) + }; +} diff --git a/libraries/rush-daemon/src/VersionSelectedDaemonLauncher.ts b/libraries/rush-daemon/src/VersionSelectedDaemonLauncher.ts index f931b842775..4a4d8e0d5cb 100644 --- a/libraries/rush-daemon/src/VersionSelectedDaemonLauncher.ts +++ b/libraries/rush-daemon/src/VersionSelectedDaemonLauncher.ts @@ -22,29 +22,24 @@ import { type IDaemonInstallationMetadata, type IInstalledDaemonLauncher } from './DaemonInstallation'; +import { + DaemonLauncherUnavailableError, + getSelectedDaemonStartCommand, + type IDaemonLauncherContext +} from './DaemonLaunchCommand'; -const DAEMON_PACKAGE: string = '@rushstack/rush-daemon'; +export { + DaemonLauncherUnavailableError, + getSelectedDaemonStartCommand, + type IDaemonLauncherContext +} from './DaemonLaunchCommand'; -export interface IDaemonLauncherContext { - readonly repoRoot: string; - readonly rushVersion: string; - readonly environment: Readonly; -} +const DAEMON_PACKAGE: string = '@rushstack/rush-daemon'; export interface IVersionSelectedDaemonLaunch extends IInstalledDaemonLauncher { readonly startCommand: IDaemonStartCommand; } -export class DaemonLauncherUnavailableError extends Error { - public readonly installation: IInstalledDaemonLauncher | undefined; - - public constructor(rushVersion: string, reason: string, installation?: IInstalledDaemonLauncher) { - super(`Cannot launch selected Rush ${rushVersion}: ${reason} Use native Rush instead.`); - this.name = 'DaemonLauncherUnavailableError'; - this.installation = installation; - } -} - /** Native-style, node-specific cache, resolved against the captured environment without mutating process.env. */ export function getDaemonVersionCacheFolder(environment: Readonly): string { const globalFolder: string = @@ -63,30 +58,6 @@ export function assertExactDaemonVersion(version: string): void { } } -export function getSelectedDaemonStartCommand( - daemonPackageJsonPath: string, - context: IDaemonLauncherContext -): IDaemonStartCommand { - return { - command: process.execPath, - args: [ - require.resolve('./SelectedDaemonBootstrap'), - '--launch', - daemonPackageJsonPath, - context.rushVersion, - context.repoRoot - ], - cwd: context.repoRoot, - environment: Object.freeze( - Object.fromEntries( - Object.entries(context.environment).filter( - (entry): entry is [string, string] => entry[1] !== undefined - ) - ) - ) - }; -} - /** Probes in a fresh process so loading a foreign Rush engine cannot alter the caller's SDK/global state. */ export async function inspectDaemonLauncherAsync( daemonPackageJsonPath: string, diff --git a/libraries/rush-lib/src/api/RushConfiguration.ts b/libraries/rush-lib/src/api/RushConfiguration.ts index 2d2408a9eb5..e1c562677ab 100644 --- a/libraries/rush-lib/src/api/RushConfiguration.ts +++ b/libraries/rush-lib/src/api/RushConfiguration.ts @@ -47,6 +47,7 @@ import { CustomTipsConfiguration } from './CustomTipsConfiguration'; import { SubspacesConfiguration } from './SubspacesConfiguration'; import { Subspace } from './Subspace'; import { resolveDaemonConfiguration, type IDaemonConfigurationJson } from './DaemonConfiguration'; +import { tryFindRushJsonLocation } from './RushJsonLocation'; const MINIMUM_SUPPORTED_RUSH_JSON_VERSION: string = '0.0.0'; const DEFAULT_BRANCH: string = 'main'; @@ -1100,37 +1101,11 @@ export class RushConfiguration { * Find the rush.json location and return the path, or undefined if a rush.json can't be found. * * @privateRemarks - * Keep this in sync with `findRushJsonLocation` in `rush-sdk/src/index.ts`. + * Keep this in sync with `findRushJsonLocation` in `rush-sdk/src/index.ts`. The implementation lives in + * `RushJsonLocation.ts` so that callers can find rush.json without loading the rest of the engine. */ public static tryFindRushJsonLocation(options?: ITryFindRushJsonLocationOptions): string | undefined { - const optionsIn: ITryFindRushJsonLocationOptions = options || {}; - const verbose: boolean = optionsIn.showVerbose || false; - let currentFolder: string = optionsIn.startingFolder || process.cwd(); - let parentFolder: string = path.dirname(currentFolder); - - // look upwards at parent folders until we find a folder containing rush.json, - // or we reach the root directory without finding a rush.json file - while (parentFolder && parentFolder !== currentFolder) { - const rushJsonFilename: string = path.join(currentFolder, RushConstants.rushJsonFilename); - if (FileSystem.exists(rushJsonFilename)) { - if (currentFolder !== optionsIn.startingFolder && verbose) { - // eslint-disable-next-line no-console - console.log('Found configuration in ' + rushJsonFilename); - } - - if (verbose) { - // eslint-disable-next-line no-console - console.log(''); - } - - return rushJsonFilename; - } - currentFolder = parentFolder; - parentFolder = path.dirname(currentFolder); - } - - // no match - return undefined; + return tryFindRushJsonLocation(options); } /** diff --git a/libraries/rush-lib/src/api/RushJsonLocation.ts b/libraries/rush-lib/src/api/RushJsonLocation.ts new file mode 100644 index 00000000000..1c3758473ad --- /dev/null +++ b/libraries/rush-lib/src/api/RushJsonLocation.ts @@ -0,0 +1,44 @@ +// 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 { FileSystem } from '@rushstack/node-core-library'; + +import { RushConstants } from '../logic/RushConstants'; +import type { ITryFindRushJsonLocationOptions } from './RushConfiguration'; + +/** + * Implements `RushConfiguration.tryFindRushJsonLocation` without loading the rest of the Rush engine, + * for callers such as the standalone client that only need the location. + */ +export function tryFindRushJsonLocation(options?: ITryFindRushJsonLocationOptions): string | undefined { + const optionsIn: ITryFindRushJsonLocationOptions = options || {}; + const verbose: boolean = optionsIn.showVerbose || false; + let currentFolder: string = optionsIn.startingFolder || process.cwd(); + let parentFolder: string = path.dirname(currentFolder); + + // look upwards at parent folders until we find a folder containing rush.json, + // or we reach the root directory without finding a rush.json file + while (parentFolder && parentFolder !== currentFolder) { + const rushJsonFilename: string = path.join(currentFolder, RushConstants.rushJsonFilename); + if (FileSystem.exists(rushJsonFilename)) { + if (currentFolder !== optionsIn.startingFolder && verbose) { + // eslint-disable-next-line no-console + console.log('Found configuration in ' + rushJsonFilename); + } + + if (verbose) { + // eslint-disable-next-line no-console + console.log(''); + } + + return rushJsonFilename; + } + currentFolder = parentFolder; + parentFolder = path.dirname(currentFolder); + } + + // no match + return undefined; +} diff --git a/libraries/rush-lib/src/api/WorkspaceInputFingerprint.ts b/libraries/rush-lib/src/api/WorkspaceInputFingerprint.ts index dbfa93eb929..9852d782275 100644 --- a/libraries/rush-lib/src/api/WorkspaceInputFingerprint.ts +++ b/libraries/rush-lib/src/api/WorkspaceInputFingerprint.ts @@ -68,16 +68,17 @@ export class WorkspaceRuntimeFingerprintCache { const entries: ReadonlyArray[] = []; for (const filename of Array.from(filenames).sort()) { try { + // statSync follows links, so dev and ino identify the file that is loaded. Its resolved path is + // recomputed whenever that identity changes, which avoids a costly realpath for every unchanged file. const stat: fsSync.BigIntStats = fsSync.statSync(filename, { bigint: true }); - const realPath: string = fsSync.realpathSync(filename); - const stamp: string = `${realPath}:${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`; + const stamp: string = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`; let cached: { stamp: string; entry: ReadonlyArray } | undefined = this._files.get(filename); if (cached?.stamp !== stamp) { cached = { stamp, entry: [ filename, - realPath, + fsSync.realpathSync(filename), createHash('sha256').update(fsSync.readFileSync(filename)).digest('hex') ] }; @@ -183,6 +184,8 @@ export async function captureWorkspaceInputFingerprintAsync( const runtimePaths: string[] = [ path.join(packageFolder, 'package.json'), path.join(packageFolder, 'lib-commonjs'), + // In a bundled Rush, lib-commonjs only forwards to the bundle chunks, which contain the implementation. + path.join(packageFolder, 'dist'), ...(options.runtimePaths ?? []) ]; const runtimeHash: string = (options.runtimeCache ?? new WorkspaceRuntimeFingerprintCache())._hashPaths( diff --git a/libraries/rush-lib/src/cli/RushXCommandLine.ts b/libraries/rush-lib/src/cli/RushXCommandLine.ts index 60342cbfac3..8dd0ca6a3b2 100644 --- a/libraries/rush-lib/src/cli/RushXCommandLine.ts +++ b/libraries/rush-lib/src/cli/RushXCommandLine.ts @@ -37,39 +37,12 @@ import { RushConstants } from '../logic/RushConstants'; import { PnpmSyncUtilities } from '../utilities/PnpmSyncUtilities'; import { initializeDotEnv, loadDotEnvForEnvironment } from '../logic/dotenv'; import { escapeArgumentIfNeeded } from '../utilities/executionUtilities'; +import { + parseRushXCommandLineArguments as _parseCommandLineArguments, + type IRushXCommandLineArguments +} from './RushXCommandLineArguments'; -/** Native Rushx arguments. Options after the command belong to the script. @beta */ -export interface IRushXCommandLineArguments { - /** - * Flag indicating whether to suppress any rushx startup information. - */ - quiet: boolean; - - /** - * Flag indicating whether the user has asked for help. - */ - help: boolean; - - /** - * Flag indicating whether the user has requested debug mode. - */ - isDebug: boolean; - - /** - * Flag indicating whether the user wants to not call hooks. - */ - ignoreHooks: boolean; - - /** - * The command to run (i.e., the target "script" in package.json.) - */ - commandName: string; - - /** - * Any additional arguments/parameters passed after the command name. - */ - commandArgs: string[]; -} +export type { IRushXCommandLineArguments } from './RushXCommandLineArguments'; /** Explicit process state and an optional owned asynchronous spawn seam for native Rushx. @beta */ export interface IRushXCommandOptions { @@ -406,70 +379,6 @@ function _getLifecycleConfiguration( }; } -function _parseCommandLineArguments( - args: ReadonlyArray, - environment: Readonly, - reportUnknownArguments?: (message: string) => void -): IRushXCommandLineArguments { - const unknownArgs: string[] = []; - - let help: boolean = false; - let quiet: boolean = false; - let commandName: string = ''; - let isDebug: boolean = false; - let ignoreHooks: boolean = false; - const commandArgs: string[] = []; - - for (let index: number = 0; index < args.length; index++) { - const argValue: string = args[index]; - - if (!commandName) { - if (argValue === '-q' || argValue === '--quiet') { - quiet = true; - } else if (argValue === '-h' || argValue === '--help') { - help = true; - } else if (argValue === '-d' || argValue === '--debug') { - isDebug = true; - } else if (argValue === '--ignore-hooks') { - ignoreHooks = true; - } else if (argValue.startsWith('-')) { - unknownArgs.push(args[index]); - } else { - commandName = args[index]; - } - } else { - commandArgs.push(args[index]); - } - } - - const quietModeValue: string | undefined = environment[EnvironmentVariableNames.RUSH_QUIET_MODE]; - if (quietModeValue === '1' || quietModeValue === 'true') { - quiet = true; - } - - if (!commandName) { - help = true; - } - - if (unknownArgs.length > 0) { - // Future TODO: Instead of just displaying usage info, we could display a - // specific error about the unknown flag the user tried to pass to rushx. - reportUnknownArguments?.( - Colorize.red(`Unknown arguments: ${unknownArgs.map((x) => JSON.stringify(x)).join(', ')}`) - ); - help = true; - } - - return { - help, - quiet, - isDebug, - ignoreHooks, - commandName, - commandArgs - }; -} - function _showUsage( packageJson: IPackageJson, projectCommandSet: ProjectCommandSet, diff --git a/libraries/rush-lib/src/cli/RushXCommandLineArguments.ts b/libraries/rush-lib/src/cli/RushXCommandLineArguments.ts new file mode 100644 index 00000000000..376e0423bfe --- /dev/null +++ b/libraries/rush-lib/src/cli/RushXCommandLineArguments.ts @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { Colorize } from '@rushstack/terminal'; + +import { EnvironmentVariableNames } from '../api/EnvironmentConfiguration'; + +/** Native Rushx arguments. Options after the command belong to the script. @beta */ +export interface IRushXCommandLineArguments { + /** + * Flag indicating whether to suppress any rushx startup information. + */ + quiet: boolean; + + /** + * Flag indicating whether the user has asked for help. + */ + help: boolean; + + /** + * Flag indicating whether the user has requested debug mode. + */ + isDebug: boolean; + + /** + * Flag indicating whether the user wants to not call hooks. + */ + ignoreHooks: boolean; + + /** + * The command to run (i.e., the target "script" in package.json.) + */ + commandName: string; + + /** + * Any additional arguments/parameters passed after the command name. + */ + commandArgs: string[]; +} + +/** + * Parses native Rushx arguments. This module does not load the rest of the Rush engine, so that callers + * such as the standalone client can route a Rushx invocation cheaply. + */ +export function parseRushXCommandLineArguments( + args: ReadonlyArray, + environment: Readonly, + reportUnknownArguments?: (message: string) => void +): IRushXCommandLineArguments { + const unknownArgs: string[] = []; + + let help: boolean = false; + let quiet: boolean = false; + let commandName: string = ''; + let isDebug: boolean = false; + let ignoreHooks: boolean = false; + const commandArgs: string[] = []; + + for (let index: number = 0; index < args.length; index++) { + const argValue: string = args[index]; + + if (!commandName) { + if (argValue === '-q' || argValue === '--quiet') { + quiet = true; + } else if (argValue === '-h' || argValue === '--help') { + help = true; + } else if (argValue === '-d' || argValue === '--debug') { + isDebug = true; + } else if (argValue === '--ignore-hooks') { + ignoreHooks = true; + } else if (argValue.startsWith('-')) { + unknownArgs.push(args[index]); + } else { + commandName = args[index]; + } + } else { + commandArgs.push(args[index]); + } + } + + const quietModeValue: string | undefined = environment[EnvironmentVariableNames.RUSH_QUIET_MODE]; + if (quietModeValue === '1' || quietModeValue === 'true') { + quiet = true; + } + + if (!commandName) { + help = true; + } + + if (unknownArgs.length > 0) { + // Future TODO: Instead of just displaying usage info, we could display a + // specific error about the unknown flag the user tried to pass to rushx. + reportUnknownArguments?.( + Colorize.red(`Unknown arguments: ${unknownArgs.map((x) => JSON.stringify(x)).join(', ')}`) + ); + help = true; + } + + return { + help, + quiet, + isDebug, + ignoreHooks, + commandName, + commandArgs + }; +} From 667af71a51ab17d37e463d3c0dcf70445a2c91cd Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 23 Sep 2026 22:04:41 +0000 Subject: [PATCH 7/9] rush-client-core: let the startup helper finish a slow daemon start The detached helper waited for readiness only until the requesting client deadline (15 seconds). A slower first start, such as the first start from a freshly deployed snapshot on Windows, left its startup reservation behind. Every later client then refused to use the ready daemon and fell back to native Rush after 15 seconds. The helper now waits for a live launcher for at least 120 seconds; the reservation is still retained if the launcher exits or never becomes ready. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...ient-startup-perf_2026-09-23-22-20-00.json | 10 ++++++++++ ...ient-startup-perf_2026-09-23-22-20-00.json | 10 ++++++++++ ...ient-startup-perf_2026-09-23-22-20-00.json | 10 ++++++++++ ...ient-startup-perf_2026-09-23-22-20-00.json | 10 ++++++++++ libraries/rush-client-core/README.md | 5 ++++- .../src/connectOrStartDaemon.ts | 9 ++++++++- .../src/test/connectOrStartDaemon.test.ts | 19 +++++++++++++++++++ 7 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 common/changes/@microsoft/rush/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json create mode 100644 common/changes/@rushstack/rush-cli-client/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json create mode 100644 common/changes/@rushstack/rush-client-core/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json create mode 100644 common/changes/@rushstack/rush-daemon/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json diff --git a/common/changes/@microsoft/rush/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json b/common/changes/@microsoft/rush/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json new file mode 100644 index 00000000000..767d5081793 --- /dev/null +++ b/common/changes/@microsoft/rush/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Include the bundled implementation in the daemon's runtime fingerprint and avoid resolving every file's real path on each request.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush" +} diff --git a/common/changes/@rushstack/rush-cli-client/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json b/common/changes/@rushstack/rush-cli-client/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json new file mode 100644 index 00000000000..054f90cf9d9 --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Start rush-client without loading the Rush engine, which removes about 1.5 seconds from every invocation on Windows.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-cli-client" +} diff --git a/common/changes/@rushstack/rush-client-core/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json b/common/changes/@rushstack/rush-client-core/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json new file mode 100644 index 00000000000..da964e57177 --- /dev/null +++ b/common/changes/@rushstack/rush-client-core/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-client-core", + "comment": "Keep waiting for a slow detached daemon start after the requesting client gives up, so later clients can use the daemon instead of finding an abandoned startup reservation.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-client-core" +} diff --git a/common/changes/@rushstack/rush-daemon/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json b/common/changes/@rushstack/rush-daemon/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json new file mode 100644 index 00000000000..13b7809aac7 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Move the daemon start command and launcher error into a module that does not load the Rush engine.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-daemon" +} \ No newline at end of file diff --git a/libraries/rush-client-core/README.md b/libraries/rush-client-core/README.md index 6c1afe75e68..9341bb96d37 100644 --- a/libraries/rush-client-core/README.md +++ b/libraries/rush-client-core/README.md @@ -46,7 +46,10 @@ file sharing on Windows. The winning client rechecks readiness, reclaims only an absent/dead owner, and reserves `.starting` before handing the explicit command to a detached startup helper. The helper spawns without a shell and retains that reservation until the daemon completes hello/ping readiness, -independently of whether the requesting client survives. Clients still await +independently of whether the requesting client survives. It waits for a live launcher for +at least 120 seconds, even when the requesting client's own deadline is shorter, so a slow +first start (for example while Windows scans newly installed files) is still handed off to +later clients instead of leaving an abandoned reservation. Clients still await hello/pong under bounded backoff. Stdout/stderr go to `.log`. No PID is killed; a live (possibly reused) PID with an unreachable socket fails closed. The helper uses a stable tool cwd, and the starting client awaits its exit after diff --git a/libraries/rush-client-core/src/connectOrStartDaemon.ts b/libraries/rush-client-core/src/connectOrStartDaemon.ts index 0889608be28..f2c077d5c7a 100644 --- a/libraries/rush-client-core/src/connectOrStartDaemon.ts +++ b/libraries/rush-client-core/src/connectOrStartDaemon.ts @@ -34,6 +34,13 @@ interface IStartupHelper { readonly closed: Promise; } +/** + * The minimum time the detached helper waits for a live launcher to become ready. It is independent of the + * requesting client's deadline: a slow first start (for example while Windows scans newly installed files) + * would otherwise leave a retained reservation that keeps every later client from using the ready daemon. + */ +const STARTUP_HELPER_READINESS_TIMEOUT_MS: number = 120_000; + /** A version-selected launch command supplied by the embedding application, never guessed by the core. @beta */ export interface IDaemonStartCommand { readonly command: string; @@ -477,7 +484,7 @@ async function spawnDetachedAsync( paths: options.paths, startCommand: start, token, - timeoutMs: Math.max(1, deadline - Date.now()) + timeoutMs: Math.max(STARTUP_HELPER_READINESS_TIMEOUT_MS, deadline - Date.now()) }; let delivered: boolean = false; try { diff --git a/libraries/rush-client-core/src/test/connectOrStartDaemon.test.ts b/libraries/rush-client-core/src/test/connectOrStartDaemon.test.ts index df7fc390cd5..b61affb228e 100644 --- a/libraries/rush-client-core/src/test/connectOrStartDaemon.test.ts +++ b/libraries/rush-client-core/src/test/connectOrStartDaemon.test.ts @@ -147,6 +147,25 @@ describe('detached daemon startup', () => { expect(fs.existsSync(getDaemonStartupFilePath(paths))).toBe(false); }, 15000); + it('lets the detached helper finish a startup that outlasts the requesting client', async () => { + fs.writeFileSync(path.join(folder, 'hold-prebind'), ''); + const first = startClient({ ...options, startupTimeoutMs: 1000 }); + const barrier: string = path.join(folder, 'prebind'); + const deadline: number = Date.now() + 5000; + while (!fs.existsSync(barrier) && Date.now() < deadline) await delayAsync(20); + const daemonPid: number = Number(fs.readFileSync(barrier, 'utf8')); + expect((await first.result).code).not.toBe(0); + expect(fs.existsSync(getDaemonStartupFilePath(paths))).toBe(true); + + // The launcher becomes ready only after the first client has given up. + fs.unlinkSync(path.join(folder, 'hold-prebind')); + const client = await connectOrStartDaemonAsync(options); + expect((await client.status).pid).toBe(daemonPid); + await client.closeAsync(); + expect(fs.readFileSync(path.join(folder, 'starts'), 'utf8')).toBe(`${daemonPid}\n`); + expect(fs.existsSync(getDaemonStartupFilePath(paths))).toBe(false); + }, 15000); + it('preserves an unresolved startup reservation rather than trusting or reclaiming its contents', async () => { const startupPath: string = getDaemonStartupFilePath(paths); const contents: string = JSON.stringify({ pid: process.pid, startedAt: 'not an ownership contract' }); From a27762209fd00957977f82ac696ba4e46ed4d17a Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 23 Sep 2026 22:20:12 +0000 Subject: [PATCH 8/9] rush-lib: resolve rig profiles asynchronously in the daemon project loader The heft-config-file rig fallback resolves the rig profile synchronously, which serializes the daemon's concurrent uncached project-configuration loads (two per request). Resolve it asynchronously first; RigConfig caches the same result, and a resolution failure still surfaces through a fresh instance exactly as before. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...ient-startup-perf_2026-09-23-22-20-00.json | 4 +- .../src/api/RushProjectConfiguration.ts | 18 ++++- .../api/test/RushProjectConfiguration.test.ts | 71 +++++++++++++++++++ 3 files changed, 88 insertions(+), 5 deletions(-) diff --git a/common/changes/@microsoft/rush/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json b/common/changes/@microsoft/rush/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json index 767d5081793..ed7c2b1852f 100644 --- a/common/changes/@microsoft/rush/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json +++ b/common/changes/@microsoft/rush/thelarkinn-client-startup-perf_2026-09-23-22-20-00.json @@ -2,9 +2,9 @@ "changes": [ { "packageName": "@microsoft/rush", - "comment": "Include the bundled implementation in the daemon's runtime fingerprint and avoid resolving every file's real path on each request.", + "comment": "Speed up daemon requests: include the bundled implementation in the runtime fingerprint without resolving every file's real path on each request, and resolve rig profiles asynchronously when the daemon reloads project configuration.", "type": "patch" } ], "packageName": "@microsoft/rush" -} +} \ No newline at end of file diff --git a/libraries/rush-lib/src/api/RushProjectConfiguration.ts b/libraries/rush-lib/src/api/RushProjectConfiguration.ts index 9a621e544ac..d3e356756b2 100644 --- a/libraries/rush-lib/src/api/RushProjectConfiguration.ts +++ b/libraries/rush-lib/src/api/RushProjectConfiguration.ts @@ -6,7 +6,7 @@ import * as path from 'node:path'; import { AlreadyReportedError, Async, FileSystem, JsonFile, Path } from '@rushstack/node-core-library'; import type { ITerminal } from '@rushstack/terminal'; import { ProjectConfigurationFile, InheritanceType } from '@rushstack/heft-config-file'; -import { RigConfig, type IRigConfigJson } from '@rushstack/rig-package'; +import { RigConfig, type IRigConfigJson, type ILoadForProjectFolderOptions } from '@rushstack/rig-package'; import type { RushConfigurationProject } from './RushConfigurationProject'; import { RushConstants } from '../logic/RushConstants'; @@ -712,10 +712,22 @@ async function loadIsolatedRigConfigAsync(projectFolder: string): Promise { + describe(RushProjectConfiguration._tryLoadForProjectsUncachedAsync.name, () => { + let folder: string; + const write = (relativePath: string, json: object): void => { + const filename: string = path.join(folder, relativePath); + fs.mkdirSync(path.dirname(filename), { recursive: true }); + fs.writeFileSync(filename, JSON.stringify(json)); + }; + const project = (name: string): RushConfigurationProject => + ({ + packageName: name, + projectFolder: path.join(folder, name), + projectRelativeFolder: name + }) as RushConfigurationProject; + const loadAsync = ( + ...projects: RushConfigurationProject[] + ): Promise> => + RushProjectConfiguration._tryLoadForProjectsUncachedAsync( + projects, + new Terminal(new StringBufferTerminalProvider()) + ); + const getOutputFolderNames = ( + configuration: RushProjectConfiguration | undefined + ): string[] | undefined => { + const outputFolderNames: ReadonlyArray | undefined = + configuration?.operationSettingsByOperationName.get('_phase:build')?.outputFolderNames; + return outputFolderNames && [...outputFolderNames]; + }; + + beforeEach(() => { + folder = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-project-rigs-')); + for (const name of ['rigged', 'missing-profile', 'own-file']) { + write(`${name}/package.json`, { name, version: '1.0.0' }); + } + write('rigged/node_modules/example-rig/package.json', { name: 'example-rig', version: '1.0.0' }); + write('rigged/node_modules/example-rig/profiles/default/config/rush-project.json', { + operationSettings: [{ operationName: '_phase:build', outputFolderNames: ['from-rig'] }] + }); + write('rigged/config/rig.json', { rigPackageName: 'example-rig' }); + for (const name of ['missing-profile', 'own-file']) { + write(`${name}/node_modules/example-rig/package.json`, { name: 'example-rig', version: '1.0.0' }); + write(`${name}/config/rig.json`, { rigPackageName: 'example-rig', rigProfile: 'missing' }); + } + write('own-file/config/rush-project.json', { + operationSettings: [{ operationName: '_phase:build', outputFolderNames: ['from-project'] }] + }); + }); + + afterEach(() => { + fs.rmSync(folder, { recursive: true, force: true }); + }); + + it('loads configuration from a rig profile', async () => { + const rigged: RushConfigurationProject = project('rigged'); + const configurations = await loadAsync(rigged); + expect(getOutputFolderNames(configurations.get(rigged))).toEqual(['from-rig']); + }); + + it('reports a missing rig profile only when the rig provides the configuration', async () => { + await expect(loadAsync(project('missing-profile'))).rejects.toThrow( + 'The rig profile "missing" is not defined by the rig package "example-rig"' + ); + const ownFile: RushConfigurationProject = project('own-file'); + const configurations = await loadAsync(ownFile); + expect(getOutputFolderNames(configurations.get(ownFile))).toEqual(['from-project']); + }); + }); + describe('operationSettingsByOperationName', () => { it('loads a rush-project.json config that extends another config file', async () => { const rushProjectConfiguration: RushProjectConfiguration | undefined = From 3b948f2b5ba2cb0cab11fcfd7ee990d4b6c47c9d Mon Sep 17 00:00:00 2001 From: selarkin Date: Wed, 23 Sep 2026 22:27:12 +0000 Subject: [PATCH 9/9] docs: describe the first daemon start from a refreshed Windows snapshot Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/rush/dogfooding-rush-daemon.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/rush/dogfooding-rush-daemon.md b/docs/rush/dogfooding-rush-daemon.md index 58a549ee197..70c1fde31a3 100644 --- a/docs/rush/dogfooding-rush-daemon.md +++ b/docs/rush/dogfooding-rush-daemon.md @@ -151,6 +151,11 @@ node common/scripts/install-run-rush.js build --to @rushstack/rush-cli-client node common/scripts/install-run-rush.js deploy --scenario rush-daemon-dogfood --target-folder common/temp/rush-daemon-dogfood --overwrite ``` +On Windows, the first daemon start from a new or refreshed snapshot can take longer than the client's 15-second +startup deadline while Windows scans the newly written files. That request then uses native Rush (with a +`rush-client: ... Using in-process Rush.` message), but the daemon finishes starting in the background and the next +request uses it. + The daemon's identity is the canonical repository root plus the selected Rush version, so each checkout or worktree has its own daemon, and `rush-client daemon ...` commands address the daemon for the checkout that contains the current directory.