Skip to content

Commit dd739c2

Browse files
mojazayeriCopilot
andcommitted
[rush-daemon] Fail closed on graph-shape changes
Classify graph-defining invalidations before reconciliation and require whole-session recreation without acknowledging stale state. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent b54b030 commit dd739c2

8 files changed

Lines changed: 360 additions & 7 deletions

File tree

common/changes/@rushstack/rush-daemon/mojazayeri-warm-graph-components_2026-08-19-22-30.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"changes": [
33
{
44
"packageName": "@rushstack/rush-daemon",
5-
"comment": "Add an opt-in all-project engine component factory with explicit phase/plugin shape, retained invalidation reconciliation, and a deterministic engine shutdown contract.",
5+
"comment": "Add an opt-in all-project engine component factory with explicit phase/plugin shape, retained invalidation reconciliation, fail-closed graph recreation boundaries, and a deterministic engine shutdown contract.",
66
"type": "minor"
77
}
88
],

common/reviews/api/rush-daemon.api.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ export type CreateWorkspaceEngineComponentsAsync = (options: ICreateWorkspaceEng
2121
// @beta
2222
export type CreateWorkspaceSessionComponentsAsync = (options: ICreateWorkspaceSessionComponentsOptions) => Promise<IWorkspaceSessionComponents>;
2323

24+
// @beta
25+
export interface IClassifyWorkspaceInvalidationsOptions {
26+
// (undocumented)
27+
readonly changedPaths: ReadonlyArray<string>;
28+
// (undocumented)
29+
readonly rushConfiguration: RushConfiguration;
30+
}
31+
2432
// @beta
2533
export interface ICreateWorkspaceEngineComponentsOptions extends IWorkspaceEngineShape {
2634
readonly projectSelection: ReadonlySet<RushConfigurationProject>;
@@ -83,11 +91,16 @@ export interface IRushDaemonServeOptions extends IRushDaemonHostOptions {
8391
readonly shutdownSignal?: AbortSignal;
8492
}
8593

94+
// @beta
95+
export type IsWorkspaceEngineRecreationRequiredAsync = (options: IClassifyWorkspaceInvalidationsOptions) => Promise<boolean>;
96+
8697
// @beta
8798
export interface IWorkspaceEngineComponentFactoryOptions {
8899
// (undocumented)
89100
readonly createEngineComponentsAsync: CreateWorkspaceEngineComponentsAsync;
90101
// (undocumented)
102+
readonly isEngineRecreationRequiredAsync?: IsWorkspaceEngineRecreationRequiredAsync;
103+
// (undocumented)
91104
readonly mapInvalidationsToOperationsAsync: MapWorkspaceInvalidationsToOperationsAsync;
92105
// (undocumented)
93106
readonly shape: IWorkspaceEngineShape;
@@ -259,11 +272,20 @@ export class WorkspaceEngineComponentFactory {
259272
readonly shape: IWorkspaceEngineShape;
260273
}
261274

275+
// @beta
276+
export class WorkspaceEngineRecreationRequiredError extends Error {
277+
constructor();
278+
}
279+
262280
// @beta
263281
export class WorkspaceInvalidationTracker {
264282
acknowledgeThrough(sequence: number): void;
265283
getSnapshot(): IWorkspaceInvalidationSnapshot;
284+
// @internal (undocumented)
285+
get hasUnattributedUnknownChanges(): boolean;
266286
invalidate(changedPath?: string): void;
287+
// @internal (undocumented)
288+
invalidateForInitialization(): void;
267289
markWatcherUnhealthy(): void;
268290
}
269291

libraries/rush-daemon/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,10 @@ declare the complete phase and plugin shape because Rush plugins can currently v
2020
The factory validates graph ownership, serializes retained invalidation reconciliation, and maps path-specific
2121
changes through the integration. The engine owner must supply one deterministic async disposer because
2222
`IOperationGraph` does not yet expose an operation that both stops the lifetime and awaits runner cleanup.
23+
After the initial conservative startup reconciliation, changes to Rush configuration, project package manifests,
24+
or integration-classified plugin graph inputs fail closed with `WorkspaceEngineRecreationRequiredError` before
25+
the input baseline advances or the invalidation is acknowledged. The startup watcher-registration boundary has
26+
no paths to classify and therefore remains a full invalidation. The routing layer must replace the complete
27+
workspace session rather than run a stale graph.
2328
The default daemon executable does not construct or route this graph while the command-independent plugin shape and per-iteration runner
2429
lifetime tracked by [rushstack#5895](https://github.com/microsoft/rushstack/issues/5895) remain incomplete.

libraries/rush-daemon/src/WorkspaceEngineComponentFactory.ts

Lines changed: 150 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
22
// See LICENSE in the project root for license information.
33

4+
import * as path from 'node:path';
5+
46
import type {
57
GetInputsSnapshotAsyncFn,
68
IInputsSnapshot,
@@ -21,7 +23,7 @@ import type {
2123
WorkspaceInvalidationTracker
2224
} from './WorkspaceInvalidationTracker';
2325

24-
const INVALIDATION_REASON: string = 'workspace-inputs-changed';
26+
const INVALIDATION_REASON: 'workspace-inputs-changed' = 'workspace-inputs-changed';
2527

2628
/**
2729
* The command-dependent phase and plugin shape used to construct a reusable engine graph.
@@ -89,6 +91,29 @@ export interface IMapWorkspaceInvalidationsOptions {
8991
readonly operationGraph: IOperationGraph;
9092
}
9193

94+
/**
95+
* Context for identifying changes that require a new workspace engine.
96+
*
97+
* @beta
98+
*/
99+
export interface IClassifyWorkspaceInvalidationsOptions {
100+
readonly changedPaths: ReadonlyArray<string>;
101+
readonly rushConfiguration: RushConfiguration;
102+
}
103+
104+
/**
105+
* Identifies integration-specific graph inputs that cannot be reconciled against an existing graph.
106+
*
107+
* @remarks
108+
* Rush configuration files and project package manifests are classified automatically. Use this callback
109+
* for plugin-specific graph inputs outside those locations.
110+
*
111+
* @beta
112+
*/
113+
export type IsWorkspaceEngineRecreationRequiredAsync = (
114+
options: IClassifyWorkspaceInvalidationsOptions
115+
) => Promise<boolean>;
116+
92117
/**
93118
* Maps path-specific watcher invalidations onto operations in the reusable graph.
94119
*
@@ -113,27 +138,55 @@ export interface IWorkspaceInvalidationReconciliation {
113138
readonly sequence: number;
114139
}
115140

141+
/**
142+
* Indicates that retained changes require the owning workspace session to be recreated.
143+
*
144+
* @remarks
145+
* The invalidations remain unacknowledged. Callers must not execute the existing operation graph after
146+
* receiving this error.
147+
*
148+
* @beta
149+
*/
150+
export class WorkspaceEngineRecreationRequiredError extends Error {
151+
public constructor() {
152+
super('Workspace changes require the reusable engine and session to be recreated.');
153+
this.name = 'WorkspaceEngineRecreationRequiredError';
154+
}
155+
}
156+
116157
/**
117158
* Options for {@link WorkspaceEngineComponentFactory}.
118159
*
119160
* @beta
120161
*/
121162
export interface IWorkspaceEngineComponentFactoryOptions {
122163
readonly createEngineComponentsAsync: CreateWorkspaceEngineComponentsAsync;
164+
readonly isEngineRecreationRequiredAsync?: IsWorkspaceEngineRecreationRequiredAsync;
123165
readonly mapInvalidationsToOperationsAsync: MapWorkspaceInvalidationsToOperationsAsync;
124166
readonly shape: IWorkspaceEngineShape;
125167
}
126168

127169
interface IWorkspaceEngineLifecycleOptions {
128170
readonly components: IWorkspaceEngineComponents;
171+
readonly graphDefiningPaths: IGraphDefiningPaths;
129172
readonly invalidations: WorkspaceInvalidationTracker;
173+
readonly isEngineRecreationRequiredAsync: IsWorkspaceEngineRecreationRequiredAsync | undefined;
130174
readonly mapInvalidationsToOperationsAsync: MapWorkspaceInvalidationsToOperationsAsync;
175+
readonly rushConfiguration: RushConfiguration;
176+
}
177+
178+
interface IGraphDefiningPaths {
179+
readonly filePaths: ReadonlySet<string>;
180+
readonly folderPaths: ReadonlyArray<string>;
131181
}
132182

133183
class WorkspaceEngineLifecycle {
134184
readonly #components: IWorkspaceEngineComponents;
185+
readonly #graphDefiningPaths: IGraphDefiningPaths;
135186
readonly #invalidations: WorkspaceInvalidationTracker;
187+
readonly #isEngineRecreationRequiredAsync: IsWorkspaceEngineRecreationRequiredAsync | undefined;
136188
readonly #mapInvalidationsToOperationsAsync: MapWorkspaceInvalidationsToOperationsAsync;
189+
readonly #rushConfiguration: RushConfiguration;
137190
#currentInputsSnapshot: IInputsSnapshot;
138191
#disposePromise: Promise<void> | undefined;
139192
#isDisposing: boolean = false;
@@ -142,9 +195,12 @@ class WorkspaceEngineLifecycle {
142195

143196
public constructor(options: IWorkspaceEngineLifecycleOptions) {
144197
this.#components = options.components;
198+
this.#graphDefiningPaths = options.graphDefiningPaths;
145199
this.#currentInputsSnapshot = options.components.inputsSnapshot;
146200
this.#invalidations = options.invalidations;
201+
this.#isEngineRecreationRequiredAsync = options.isEngineRecreationRequiredAsync;
147202
this.#mapInvalidationsToOperationsAsync = options.mapInvalidationsToOperationsAsync;
203+
this.#rushConfiguration = options.rushConfiguration;
148204
}
149205

150206
public get inputsSnapshot(): IInputsSnapshot {
@@ -173,6 +229,9 @@ class WorkspaceEngineLifecycle {
173229

174230
async #reconcileOnceAsync(): Promise<IWorkspaceInvalidationReconciliation> {
175231
const invalidationSnapshot: IWorkspaceInvalidationSnapshot = this.#invalidations.getSnapshot();
232+
if (await this.#requiresEngineRecreationAsync(invalidationSnapshot)) {
233+
throw new WorkspaceEngineRecreationRequiredError();
234+
}
176235
const isFullInvalidation: boolean =
177236
this.#requiresFullInvalidation ||
178237
invalidationSnapshot.hasUnknownChanges ||
@@ -229,6 +288,43 @@ class WorkspaceEngineLifecycle {
229288
await this.#reconciliationTail;
230289
await this.#components[Symbol.asyncDispose]();
231290
}
291+
292+
async #requiresEngineRecreationAsync(
293+
invalidationSnapshot: IWorkspaceInvalidationSnapshot
294+
): Promise<boolean> {
295+
if (
296+
invalidationSnapshot.changedPaths.length === 0 &&
297+
!invalidationSnapshot.hasUnknownChanges &&
298+
invalidationSnapshot.isWatcherHealthy
299+
) {
300+
return false;
301+
}
302+
if (
303+
this.#invalidations.hasUnattributedUnknownChanges ||
304+
!invalidationSnapshot.isWatcherHealthy
305+
) {
306+
return true;
307+
}
308+
309+
if (
310+
invalidationSnapshot.changedPaths.some((changedPath: string) =>
311+
isBuiltInGraphDefiningPath(
312+
changedPath,
313+
this.#rushConfiguration.rushJsonFolder,
314+
this.#graphDefiningPaths
315+
)
316+
)
317+
) {
318+
return true;
319+
}
320+
321+
return (
322+
(await this.#isEngineRecreationRequiredAsync?.({
323+
changedPaths: invalidationSnapshot.changedPaths,
324+
rushConfiguration: this.#rushConfiguration
325+
})) ?? false
326+
);
327+
}
232328
}
233329

234330
/**
@@ -242,13 +338,15 @@ class WorkspaceEngineLifecycle {
242338
*/
243339
export class WorkspaceEngineComponentFactory {
244340
readonly #createEngineComponentsAsync: CreateWorkspaceEngineComponentsAsync;
341+
readonly #isEngineRecreationRequiredAsync: IsWorkspaceEngineRecreationRequiredAsync | undefined;
245342
readonly #mapInvalidationsToOperationsAsync: MapWorkspaceInvalidationsToOperationsAsync;
246343

247344
public readonly createAsync: CreateWorkspaceSessionComponentsAsync;
248345
public readonly shape: IWorkspaceEngineShape;
249346

250347
public constructor(options: IWorkspaceEngineComponentFactoryOptions) {
251348
this.#createEngineComponentsAsync = options.createEngineComponentsAsync;
349+
this.#isEngineRecreationRequiredAsync = options.isEngineRecreationRequiredAsync;
252350
this.#mapInvalidationsToOperationsAsync = options.mapInvalidationsToOperationsAsync;
253351
this.shape = normalizeShape(options.shape);
254352
this.createAsync = (createOptions: ICreateWorkspaceSessionComponentsOptions) =>
@@ -259,6 +357,9 @@ export class WorkspaceEngineComponentFactory {
259357
options: ICreateWorkspaceSessionComponentsOptions
260358
): Promise<IWorkspaceSessionComponents> {
261359
const projects: ReadonlySet<RushConfigurationProject> = new Set(options.rushConfiguration.projects);
360+
const graphDefiningPaths: IGraphDefiningPaths = createGraphDefiningPaths(
361+
options.rushConfiguration
362+
);
262363
const components: IWorkspaceEngineComponents = await this.#createEngineComponentsAsync({
263364
phaseNames: this.shape.phaseNames,
264365
pluginNames: this.shape.pluginNames,
@@ -274,8 +375,11 @@ export class WorkspaceEngineComponentFactory {
274375

275376
const lifecycle: WorkspaceEngineLifecycle = new WorkspaceEngineLifecycle({
276377
components,
378+
graphDefiningPaths,
277379
invalidations: options.invalidations,
278-
mapInvalidationsToOperationsAsync: this.#mapInvalidationsToOperationsAsync
380+
isEngineRecreationRequiredAsync: this.#isEngineRecreationRequiredAsync,
381+
mapInvalidationsToOperationsAsync: this.#mapInvalidationsToOperationsAsync,
382+
rushConfiguration: options.rushConfiguration
279383
});
280384
return {
281385
[Symbol.asyncDispose]: () => lifecycle[Symbol.asyncDispose](),
@@ -290,6 +394,50 @@ export class WorkspaceEngineComponentFactory {
290394
}
291395
}
292396

397+
function createGraphDefiningPaths(rushConfiguration: RushConfiguration): IGraphDefiningPaths {
398+
const filePaths: Set<string> = new Set([path.resolve(rushConfiguration.rushJsonFile)]);
399+
for (const project of rushConfiguration.projects) {
400+
filePaths.add(path.join(project.projectFolder, 'package.json'));
401+
filePaths.add(path.join(project.projectFolder, 'config', 'rush-project.json'));
402+
}
403+
return {
404+
filePaths,
405+
folderPaths: [
406+
path.resolve(rushConfiguration.commonRushConfigFolder),
407+
...Array.from(rushConfiguration.subspaces, (subspace) =>
408+
path.resolve(subspace.getSubspaceConfigFolderPath())
409+
)
410+
]
411+
};
412+
}
413+
414+
function isBuiltInGraphDefiningPath(
415+
changedPath: string,
416+
rushJsonFolder: string,
417+
graphDefiningPaths: IGraphDefiningPaths
418+
): boolean {
419+
const absoluteChangedPath: string = path.resolve(rushJsonFolder, changedPath);
420+
if (graphDefiningPaths.filePaths.has(absoluteChangedPath)) {
421+
return true;
422+
}
423+
for (const folderPath of graphDefiningPaths.folderPaths) {
424+
if (isPathInside(absoluteChangedPath, folderPath)) {
425+
return true;
426+
}
427+
}
428+
return false;
429+
}
430+
431+
function isPathInside(candidatePath: string, folderPath: string): boolean {
432+
const relativePath: string = path.relative(path.resolve(folderPath), candidatePath);
433+
return (
434+
relativePath === '' ||
435+
(!path.isAbsolute(relativePath) &&
436+
relativePath !== '..' &&
437+
!relativePath.startsWith(`..${path.sep}`))
438+
);
439+
}
440+
293441
function normalizeShape(shape: IWorkspaceEngineShape): IWorkspaceEngineShape {
294442
return Object.freeze({
295443
phaseNames: normalizeNames(shape.phaseNames, 'phase', true),

libraries/rush-daemon/src/WorkspaceInvalidationTracker.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
export interface IWorkspaceInvalidationSnapshot {
1010
/** Paths reported by the watcher, sorted for deterministic consumption. */
1111
readonly changedPaths: ReadonlyArray<string>;
12-
/** True when the watcher reported a change without a path or encountered a watcher error. */
12+
/** True at initialization, or when the watcher reported a change without a path or encountered an error. */
1313
readonly hasUnknownChanges: boolean;
1414
/** False after a watcher error makes subsequent change detection unreliable. */
1515
readonly isWatcherHealthy: boolean;
@@ -26,6 +26,7 @@ const MAX_TRACKED_CHANGED_PATHS: number = 10_000;
2626
*/
2727
export class WorkspaceInvalidationTracker {
2828
readonly #sequenceByPath: Map<string, number> = new Map();
29+
#initializationSequence: number | undefined;
2930
#latestSequence: number = 0;
3031
#unknownChangeSequence: number | undefined;
3132
#watcherHealthy: boolean = true;
@@ -50,6 +51,16 @@ export class WorkspaceInvalidationTracker {
5051
this.#sequenceByPath.set(changedPath, sequence);
5152
}
5253

54+
/** @internal */
55+
public invalidateForInitialization(): void {
56+
this.#initializationSequence = ++this.#latestSequence;
57+
}
58+
59+
/** @internal */
60+
public get hasUnattributedUnknownChanges(): boolean {
61+
return this.#unknownChangeSequence !== undefined;
62+
}
63+
5364
/**
5465
* Permanently marks the current watcher as unhealthy.
5566
*
@@ -66,7 +77,8 @@ export class WorkspaceInvalidationTracker {
6677
public getSnapshot(): IWorkspaceInvalidationSnapshot {
6778
return {
6879
changedPaths: Array.from(this.#sequenceByPath.keys()).sort(),
69-
hasUnknownChanges: this.#unknownChangeSequence !== undefined,
80+
hasUnknownChanges:
81+
this.#initializationSequence !== undefined || this.#unknownChangeSequence !== undefined,
7082
isWatcherHealthy: this.#watcherHealthy,
7183
sequence: this.#latestSequence
7284
};
@@ -94,5 +106,8 @@ export class WorkspaceInvalidationTracker {
94106
) {
95107
this.#unknownChangeSequence = undefined;
96108
}
109+
if (this.#initializationSequence !== undefined && this.#initializationSequence <= sequence) {
110+
this.#initializationSequence = undefined;
111+
}
97112
}
98113
}

libraries/rush-daemon/src/WorkspaceSession.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ export class WorkspaceSession implements IWorkspaceSession {
211211
invalidations.invalidate(changedPath)
212212
);
213213
// Changes before the watcher registered its callbacks cannot be observed path-by-path.
214-
invalidations.invalidate();
214+
invalidations.invalidateForInitialization();
215215
return session;
216216
} catch (error) {
217217
const cleanupErrors: unknown[] = [];

0 commit comments

Comments
 (0)