Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions common/changes/@microsoft/rush/env-fingerprint_2026-09-23.json
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"
}
2 changes: 1 addition & 1 deletion common/reviews/api/rush-daemon.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
6 changes: 6 additions & 0 deletions common/reviews/api/rush-lib.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,9 @@ export type GetCacheEntryIdFunction = (options: IGenerateCacheEntryIdOptions) =>
// @beta
export type GetInputsSnapshotAsyncFn = () => Promise<IInputsSnapshot | undefined>;

// @alpha
export function getWorkspaceFingerprintEnvironmentEntries(environment: Readonly<Record<string, string | undefined>>): [string, string][];

// @alpha (undocumented)
export interface IBaseOperationExecutionResult {
getStateHash(): string;
Expand Down Expand Up @@ -2094,6 +2097,9 @@ export enum VersionPolicyDefinitionName {
'lockStepVersion' = 0
}

// @alpha
export const workspaceFingerprintIgnoredEnvironmentVariables: ReadonlySet<string>;

// @alpha
export enum WorkspaceInputChangeTier {
// (undocumented)
Expand Down
14 changes: 11 additions & 3 deletions libraries/rush-daemon/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -241,11 +242,7 @@ export class ProductionDaemonRequestResolver implements IDaemonRequestResolver {
}

function environmentIdentity(environment: Readonly<Record<string, string | undefined>>): 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<Operation> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, {});
Expand Down
94 changes: 87 additions & 7 deletions libraries/rush-lib/src/api/WorkspaceInputFingerprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = 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<Record<string, string | undefined>>
): [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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as path from 'node:path';
import {
captureWorkspaceInputFingerprintAsync,
classifyWorkspaceInputChange,
getWorkspaceFingerprintEnvironmentEntries,
WorkspaceInputChangeTier,
WorkspaceRuntimeFingerprintCache,
type IWorkspaceInputFingerprint
Expand Down Expand Up @@ -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<string, string> = {
HOME: '/home/user',
PATH: '/usr/local/bin:/usr/bin',
PWD: '/repo',
SHLVL: '1',
TERM: 'xterm-256color'
};
const getHashAsync = async (environment: Record<string, string | undefined>): Promise<string> =>
(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',
Expand Down
2 changes: 2 additions & 0 deletions libraries/rush-lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ export {
captureWorkspaceInputFingerprintAsync,
captureProjectConfigurationFingerprintAsync,
classifyWorkspaceInputChange,
getWorkspaceFingerprintEnvironmentEntries,
workspaceFingerprintIgnoredEnvironmentVariables,
WorkspaceInputChangeTier,
WorkspaceRuntimeFingerprintCache,
type IWorkspaceInputFingerprint,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,10 @@ Loaded @microsoft/rush-lib from process.env._RUSH_LIB_PATH
'classifyWorkspaceInputChange',
'createRushDiagnostic',
'daemonEnvironmentVariables',
'getWorkspaceFingerprintEnvironmentEntries',
'parseReporterExtensionEventName',
'resolveDaemonConfiguration'
'resolveDaemonConfiguration',
'workspaceFingerprintIgnoredEnvironmentVariables'
]"
`;

Expand Down
Loading