diff --git a/common/changes/@microsoft/rush/env-fingerprint_2026-09-23.json b/common/changes/@microsoft/rush/env-fingerprint_2026-09-23.json new file mode 100644 index 0000000000..f7f2a4e1ac --- /dev/null +++ b/common/changes/@microsoft/rush/env-fingerprint_2026-09-23.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Exclude volatile per-shell, terminal, session and daemon-routing environment variables (such as PWD, OLDPWD, SHLVL, TERM and WSL_INTEROP) from workspace input environment fingerprints, via the new `workspaceFingerprintIgnoredEnvironmentVariables` and `getWorkspaceFingerprintEnvironmentEntries` APIs.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "selarkin@microsoft.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/rush-daemon/env-fingerprint_2026-09-23.json b/common/changes/@rushstack/rush-daemon/env-fingerprint_2026-09-23.json new file mode 100644 index 0000000000..5f00d081ce --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/env-fingerprint_2026-09-23.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Stop restarting the daemon when a request differs only in volatile per-shell environment variables such as PWD, OLDPWD, SHLVL, TERM or WSL_INTEROP; the restart fingerprint and the production resolver's environment check now share rush-lib's normalization.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-daemon", + "email": "selarkin@microsoft.com" +} \ No newline at end of file diff --git a/common/reviews/api/rush-daemon.api.md b/common/reviews/api/rush-daemon.api.md index bbad30a4b9..99cc2ca6a5 100644 --- a/common/reviews/api/rush-daemon.api.md +++ b/common/reviews/api/rush-daemon.api.md @@ -28,7 +28,7 @@ import type { IDaemonWorkspaceStatus } from '@rushstack/rush-daemon-protocol'; import type { IInputsSnapshot } from '@microsoft/rush-lib'; import { IOperationGraph } from '@microsoft/rush-lib'; import type { ITerminal } from '@rushstack/terminal'; -import { LockFile } from '@rushstack/node-core-library'; +import type { LockFile } from '@rushstack/node-core-library'; import { Operation } from '@microsoft/rush-lib'; import { RushConfiguration } from '@microsoft/rush-lib'; import type { RushConfigurationProject } from '@microsoft/rush-lib'; diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 512a2dcf7d..2157f656c6 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -381,6 +381,9 @@ export type GetCacheEntryIdFunction = (options: IGenerateCacheEntryIdOptions) => // @beta export type GetInputsSnapshotAsyncFn = () => Promise; +// @alpha +export function getWorkspaceFingerprintEnvironmentEntries(environment: Readonly>): [string, string][]; + // @alpha (undocumented) export interface IBaseOperationExecutionResult { getStateHash(): string; @@ -2094,6 +2097,9 @@ export enum VersionPolicyDefinitionName { 'lockStepVersion' = 0 } +// @alpha +export const workspaceFingerprintIgnoredEnvironmentVariables: ReadonlySet; + // @alpha export enum WorkspaceInputChangeTier { // (undocumented) diff --git a/libraries/rush-daemon/README.md b/libraries/rush-daemon/README.md index 713b69bec1..5ad3b25ea9 100644 --- a/libraries/rush-daemon/README.md +++ b/libraries/rush-daemon/README.md @@ -86,8 +86,16 @@ The host uses stable fingerprints to classify native requests: Configuration fingerprints use contents rather than timestamps. Runtime content hashes are cached only behind file identity/size/mtime/ctime checks; touching unchanged content does not itself change a fingerprint. Native dispatch first copies the envelope and normalizes only engine-owned `_RUSH_LIB_PATH` to this daemon's -real engine, preventing false restarts or wrong SDK selection from a foreign client path. All other environment -inputs remain unchanged and are checked normally. +real engine, preventing false restarts or wrong SDK selection from a foreign client path. Environment +comparisons (the tier-2 fingerprint and the production resolver's startup-environment check) both use rush-lib's +`getWorkspaceFingerprintEnvironmentEntries()`, which omits `workspaceFingerprintIgnoredEnvironmentVariables`: +volatile per-shell, terminal, session and client-routing variables such as `PWD`, `OLDPWD`, `SHLVL`, `_`, +`TERM`, `COLUMNS`, `WSL_INTEROP`, `SSH_*`, `INIT_CWD`, `RUSH_DAEMON`, `RUSH_DAEMON_AUTO_START` and +`RUSH_DAEMON_EXPERIMENTAL`. Rush does not read these to configure the engine, build the graph or hash operations, +so running a command from a project subfolder or another shell reuses the warm workspace. All other environment +inputs, including every other `RUSH_*` variable, `NODE_*`, npm/pnpm configuration, `PATH` and `HOME`, remain +unchanged and are checked normally. Phased operation processes inherit the daemon's own environment, so they +see the daemon's startup values for the ignored variables rather than the submitting shell's values. Compatible selections reuse the same graph and records. An unchanged successful build schedules no work; rebuild still invalidates the graph on each request. Every execution refreshes operation inputs under its native lease. @@ -112,7 +120,7 @@ External Rush plugins, `.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. For phased commands, a changed request environment requires a new process, including Rush/cache -policy variables. These restrictions remain until the corresponding initialization, +policy variables (the volatile variables listed above excepted). These restrictions remain until the corresponding initialization, environment, and resource-lifetime contracts are request-scoped. The native Rush lock is held only during graph preparation and each coalesced iteration, not while the warm daemon diff --git a/libraries/rush-daemon/src/ProductionDaemonRequestResolver.ts b/libraries/rush-daemon/src/ProductionDaemonRequestResolver.ts index 7a1793510a..4bc77ac193 100644 --- a/libraries/rush-daemon/src/ProductionDaemonRequestResolver.ts +++ b/libraries/rush-daemon/src/ProductionDaemonRequestResolver.ts @@ -3,8 +3,9 @@ import * as path from 'node:path'; -import { Sort, type LockFile } from '@rushstack/node-core-library'; +import type { LockFile } from '@rushstack/node-core-library'; import { + getWorkspaceFingerprintEnvironmentEntries, PhasedCommandEngine, PhasedCommandEngineBusyError, PhasedCommandEngineConfigurationChangedError, @@ -241,11 +242,7 @@ export class ProductionDaemonRequestResolver implements IDaemonRequestResolver { } function environmentIdentity(environment: Readonly>): string { - return JSON.stringify( - Object.entries(environment) - .filter(([, value]) => value !== undefined) - .sort(([a], [b]) => Sort.compareByValue(a, b)) - ); + return JSON.stringify(getWorkspaceFingerprintEnvironmentEntries(environment)); } function getChangedOperations(options: IMapWorkspaceInvalidationsOptions): Iterable { diff --git a/libraries/rush-daemon/src/test/WorkspaceReloadTierStatus.test.ts b/libraries/rush-daemon/src/test/WorkspaceReloadTierStatus.test.ts index 28f099e3f8..afc7bc6a13 100644 --- a/libraries/rush-daemon/src/test/WorkspaceReloadTierStatus.test.ts +++ b/libraries/rush-daemon/src/test/WorkspaceReloadTierStatus.test.ts @@ -59,6 +59,52 @@ it('uses zero when a host has no native workspace lifecycle, without inventing a } }); +it('reuses the warm generation when only volatile per-shell environment variables differ', async () => { + const fixture = await DaemonGraphTestFixture.createAsync((created) => { + setDaemonPolicy(created, {}); + created.getSuccessorLaunchAsync = getInstalledWorkspaceSuccessorLaunchAsync; + }); + try { + expect((await fixture.buildAsync()).terminal).toMatchObject({ payload: { exitCode: 0 } }); + expect((await fixture.buildAsync()).terminal).toMatchObject({ payload: { exitCode: 0 } }); + expect(fixture.host.workspaceStatus.lastReloadTier).toBe(WorkspaceInputChangeTier.Reuse); + const before = await pongAsync(fixture); + const generation: number = fixture.host.workspaceGeneration; + const graph = fixture.session.operationGraph; + for (const [label, environment] of Object.entries({ + same: fixture.environment, + shell: { + ...fixture.environment, + OLDPWD: '/elsewhere', + PWD: `${fixture.folder}/b`, + SHLVL: '7', + _: '/usr/bin/env' + }, + terminal: { ...fixture.environment, TERM: 'dumb', COLUMNS: '91', WSL_INTEROP: '/run/WSL/1_interop' }, + routing: { ...fixture.environment, RUSH_DAEMON: '1', RUSH_DAEMON_EXPERIMENTAL: '1' } + })) { + const result = await fixture.runAsync(['build', '--to', 'b', '--parallelism', '3'], { environment }); + expect(result.terminal).toMatchObject({ kind: 'requestResult', payload: { exitCode: 0 } }); + expect({ label, tier: fixture.host.workspaceStatus.lastReloadTier }).toEqual({ + label, + tier: WorkspaceInputChangeTier.Reuse + }); + } + expect(fixture.host.workspaceGeneration).toBe(generation); + expect(fixture.session.operationGraph).toBe(graph); + expect((await pongAsync(fixture)).pid).toBe(before.pid); + expect(fixture.runs()).toEqual(['a', 'b']); + } finally { + try { + await fixture.host.closeAsync(); + await fixture.host.restartCompleted; + } finally { + await stopSuccessorAsync(fixture.host.paths); + await fixture[Symbol.asyncDispose](); + } + } +}); + it('retains the requested restart tier on the old host while a real successor starts cold', async () => { const fixture = await DaemonGraphTestFixture.createAsync((created) => { setDaemonPolicy(created, {}); diff --git a/libraries/rush-lib/src/api/WorkspaceInputFingerprint.ts b/libraries/rush-lib/src/api/WorkspaceInputFingerprint.ts index 3a6271a3bf..edb2b0bc26 100644 --- a/libraries/rush-lib/src/api/WorkspaceInputFingerprint.ts +++ b/libraries/rush-lib/src/api/WorkspaceInputFingerprint.ts @@ -33,6 +33,92 @@ export interface IWorkspaceInputFingerprintOptions { readonly runtimeCache?: WorkspaceRuntimeFingerprintCache; } +/** + * Environment variable names that are excluded from {@link IWorkspaceInputFingerprint.environmentHash}. + * + * @remarks + * These variables are maintained per shell, terminal, remote session or client invocation. Rush never reads them + * to configure the engine, construct the operation graph or compute operation hashes, so a difference must not + * discard a warm workspace: + * + * - shell bookkeeping: `_`, `PWD`, `OLDPWD`, `SHLVL`, `PS1`, `HISTFILE`, `HISTSIZE` + * (a child shell recomputes `PWD`/`SHLVL`/`_` for its own working directory) + * - terminal presentation: `TERM`, `TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `TERM_SESSION_ID`, `COLORTERM`, + * `COLUMNS`, `LINES`, `LS_COLORS`, `WINDOWID` + * - session and multiplexer handles: `WSL_INTEROP`, `WSLENV`, `SSH_CLIENT`, `SSH_CONNECTION`, `SSH_TTY`, + * `SSH_AUTH_SOCK`, `TMUX`, `TMUX_PANE`, `STY`, `XDG_SESSION_ID`, `XDG_SESSION_TYPE`, `DBUS_SESSION_BUS_ADDRESS` + * - `INIT_CWD`, which Rush removes from every lifecycle script environment and sets explicitly where needed + * - client routing: `RUSH_DAEMON` and `RUSH_DAEMON_AUTO_START` only select and start a daemon, and + * `RUSH_DAEMON_EXPERIMENTAL` is read from each request rather than from the process + * + * Every other variable remains a process-bound input, including the remaining `RUSH_*` settings (such as + * `RUSH_BUILD_CACHE_*` and the daemon's own `RUSH_DAEMON_*` resource settings), `NODE_*`, npm/pnpm + * configuration, `PATH` and `HOME`. On Windows, names are matched case-insensitively. + * + * A long-lived host that ignores these variables keeps the values from its own startup environment for the + * processes it launches. Projects that need one of these values as an operation input should not rely on it + * being request-specific in such a host. + * + * @alpha + */ +export const workspaceFingerprintIgnoredEnvironmentVariables: ReadonlySet = new Set([ + '_', + 'PWD', + 'OLDPWD', + 'SHLVL', + 'PS1', + 'HISTFILE', + 'HISTSIZE', + 'TERM', + 'TERM_PROGRAM', + 'TERM_PROGRAM_VERSION', + 'TERM_SESSION_ID', + 'COLORTERM', + 'COLUMNS', + 'LINES', + 'LS_COLORS', + 'WINDOWID', + 'WSL_INTEROP', + 'WSLENV', + 'SSH_CLIENT', + 'SSH_CONNECTION', + 'SSH_TTY', + 'SSH_AUTH_SOCK', + 'TMUX', + 'TMUX_PANE', + 'STY', + 'XDG_SESSION_ID', + 'XDG_SESSION_TYPE', + 'DBUS_SESSION_BUS_ADDRESS', + 'INIT_CWD', + 'RUSH_DAEMON', + 'RUSH_DAEMON_AUTO_START', + 'RUSH_DAEMON_EXPERIMENTAL' +]); + +/** + * Returns the defined environment entries that participate in workspace fingerprints, sorted by name. + * + * @remarks + * Omits undefined values and {@link workspaceFingerprintIgnoredEnvironmentVariables}. Hosts that compare + * environments outside {@link captureWorkspaceInputFingerprintAsync} must use this function so that every + * comparison applies the same normalization. + * + * @alpha + */ +export function getWorkspaceFingerprintEnvironmentEntries( + environment: Readonly> +): [string, string][] { + const isWindows: boolean = process.platform === 'win32'; + return Object.entries(environment) + .filter( + (entry): entry is [string, string] => + entry[1] !== undefined && + !workspaceFingerprintIgnoredEnvironmentVariables.has(isWindows ? entry[0].toUpperCase() : entry[0]) + ) + .sort(([left], [right]) => Sort.compareByValue(left, right)); +} + /** * Memoizes runtime content digests behind file identity, size, nanosecond mtime and ctime checks. * Changes to metadata alone still produce the same content fingerprint. @@ -180,13 +266,7 @@ export async function captureWorkspaceInputFingerprintAsync( ); return { configurationHash: await hashFilesAsync(definitions), - environmentHash: hashText( - JSON.stringify( - Object.entries(environment) - .filter(([, value]) => value !== undefined) - .sort(([left], [right]) => Sort.compareByValue(left, right)) - ) - ), + environmentHash: hashText(JSON.stringify(getWorkspaceFingerprintEnvironmentEntries(environment))), installationHash: await hashFilesAsync(installation), runtimeHash: hashText(JSON.stringify([process.execPath, process.version, runtimeHash])), selectedRushVersion: environment.RUSH_PREVIEW_VERSION ?? rushJson.rushVersion diff --git a/libraries/rush-lib/src/api/test/WorkspaceInputFingerprint.test.ts b/libraries/rush-lib/src/api/test/WorkspaceInputFingerprint.test.ts index 8a19aba368..96ec77fc72 100644 --- a/libraries/rush-lib/src/api/test/WorkspaceInputFingerprint.test.ts +++ b/libraries/rush-lib/src/api/test/WorkspaceInputFingerprint.test.ts @@ -8,6 +8,7 @@ import * as path from 'node:path'; import { captureWorkspaceInputFingerprintAsync, classifyWorkspaceInputChange, + getWorkspaceFingerprintEnvironmentEntries, WorkspaceInputChangeTier, WorkspaceRuntimeFingerprintCache, type IWorkspaceInputFingerprint @@ -41,6 +42,60 @@ describe('workspace input fingerprints', () => { } }); + it('ignores volatile per-shell variables but not engine, Node.js or tool resolution inputs', async () => { + const folder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-fingerprint-')); + try { + const rushJsonPath: string = path.join(folder, 'rush.json'); + fs.writeFileSync( + rushJsonPath, + JSON.stringify({ rushVersion: '5.179.0', pnpmVersion: '10.27.0', projects: [] }) + ); + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonPath); + const runtimeCache: WorkspaceRuntimeFingerprintCache = new WorkspaceRuntimeFingerprintCache(); + const base: Record = { + HOME: '/home/user', + PATH: '/usr/local/bin:/usr/bin', + PWD: '/repo', + SHLVL: '1', + TERM: 'xterm-256color' + }; + const getHashAsync = async (environment: Record): Promise => + (await captureWorkspaceInputFingerprintAsync({ rushConfiguration, runtimeCache, environment })) + .environmentHash; + const baseHash: string = await getHashAsync(base); + for (const volatile of [ + { PWD: '/repo/packages/p03', OLDPWD: '/repo' }, + { SHLVL: '7', _: '/usr/bin/env' }, + { TERM: 'dumb', COLUMNS: '91', LINES: '40', COLORTERM: 'truecolor' }, + { WSL_INTEROP: '/run/WSL/12345_interop', WSLENV: 'WT_SESSION' }, + { SSH_CONNECTION: '10.0.0.1 1 10.0.0.2 22', SSH_AUTH_SOCK: '/tmp/agent', TMUX: '/tmp/tmux' }, + { INIT_CWD: '/repo/packages/p03' }, + { RUSH_DAEMON: '1', RUSH_DAEMON_AUTO_START: '0', RUSH_DAEMON_EXPERIMENTAL: '1' }, + { TERM: undefined, PWD: undefined } + ]) { + expect(await getHashAsync({ ...base, ...volatile })).toBe(baseHash); + } + for (const relevant of [ + { FOO: '1' }, + { RUSH_BUILD_CACHE_ENABLED: '1' }, + { RUSH_BUILD_CACHE_WRITE_ALLOWED: '0' }, + { RUSH_DAEMON_WATCH: '1' }, + { NODE_OPTIONS: '--max-old-space-size=8192' }, + { NPM_CONFIG_REGISTRY: 'https://example.invalid/' }, + { PATH: '/usr/bin:/usr/local/bin' }, + { HOME: '/home/other' } + ]) { + expect(await getHashAsync({ ...base, ...relevant })).not.toBe(baseHash); + } + expect(getWorkspaceFingerprintEnvironmentEntries({ ...base, OLDPWD: '/x', FOO: undefined })).toEqual([ + ['HOME', '/home/user'], + ['PATH', '/usr/local/bin:/usr/bin'] + ]); + } 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/index.ts b/libraries/rush-lib/src/index.ts index 352a7ae512..ebd267247c 100644 --- a/libraries/rush-lib/src/index.ts +++ b/libraries/rush-lib/src/index.ts @@ -185,6 +185,8 @@ export { captureWorkspaceInputFingerprintAsync, captureProjectConfigurationFingerprintAsync, classifyWorkspaceInputChange, + getWorkspaceFingerprintEnvironmentEntries, + workspaceFingerprintIgnoredEnvironmentVariables, WorkspaceInputChangeTier, WorkspaceRuntimeFingerprintCache, type IWorkspaceInputFingerprint, diff --git a/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap b/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap index 489e2f4841..799804d99f 100644 --- a/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap +++ b/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap @@ -75,8 +75,10 @@ Loaded @microsoft/rush-lib from process.env._RUSH_LIB_PATH 'classifyWorkspaceInputChange', 'createRushDiagnostic', 'daemonEnvironmentVariables', + 'getWorkspaceFingerprintEnvironmentEntries', 'parseReporterExtensionEventName', - 'resolveDaemonConfiguration' + 'resolveDaemonConfiguration', + 'workspaceFingerprintIgnoredEnvironmentVariables' ]" `;