From 75b0df6b988150c006f99d7263d0d2a4646647ac Mon Sep 17 00:00:00 2001 From: David Michon Date: Tue, 16 Sep 2025 01:25:23 +0000 Subject: [PATCH 1/4] [rush] Support aborting phased command execution --- .../rush/rush-abort_2025-09-16-01-25.json | 10 +++ common/reviews/api/rush-lib.api.md | 4 + .../cli/scriptActions/PhasedScriptAction.ts | 79 +++++++++++++++---- .../rush-lib/src/logic/ProjectWatcher.ts | 7 ++ .../logic/operations/AsyncOperationQueue.ts | 3 +- .../logic/operations/ConsoleTimelinePlugin.ts | 6 +- .../operations/OperationExecutionManager.ts | 31 ++++++-- .../OperationResultSummarizerPlugin.ts | 9 +++ .../src/logic/operations/OperationStatus.ts | 9 ++- .../test/OperationExecutionManager.test.ts | 66 ++++++++++++++-- .../src/pluginFramework/PhasedCommandHooks.ts | 5 ++ .../src/pluginFramework/RushLifeCycle.ts | 7 ++ .../src/BridgeCachePlugin.ts | 17 ++-- .../src/phasedCommandHandler.ts | 15 +++- 14 files changed, 220 insertions(+), 48 deletions(-) create mode 100644 common/changes/@microsoft/rush/rush-abort_2025-09-16-01-25.json diff --git a/common/changes/@microsoft/rush/rush-abort_2025-09-16-01-25.json b/common/changes/@microsoft/rush/rush-abort_2025-09-16-01-25.json new file mode 100644 index 00000000000..eb3c877b9e3 --- /dev/null +++ b/common/changes/@microsoft/rush/rush-abort_2025-09-16-01-25.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Support aborting execution in phased commands. The CLI allows aborting via the \"a\" key in watch mode, and it is available to plugin authors for more advanced scenarios.", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index f3ffee7a543..afe6451f808 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -477,6 +477,7 @@ export interface IEnvironmentConfigurationInitializeOptions { // @alpha export interface IExecuteOperationsContext extends ICreateOperationsContext { + readonly abortController: AbortController; readonly inputsSnapshot?: IInputsSnapshot; } @@ -742,6 +743,8 @@ export type IPhaseBehaviorForMissingScript = 'silent' | 'log' | 'error'; // @beta export interface IPhasedCommand extends IRushCommand { + // @alpha + readonly abortController: AbortController; // @alpha readonly hooks: PhasedCommandHooks; } @@ -1053,6 +1056,7 @@ export class _OperationStateFile { // @beta export enum OperationStatus { + Aborted = "ABORTED", Blocked = "BLOCKED", Executing = "EXECUTING", Failure = "FAILURE", diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index 3846d9e912b..2fc7961f1e6 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -98,7 +98,7 @@ interface IRunPhasesOptions extends IInitialRunPhasesOptions { executionManagerOptions: IOperationExecutionManagerOptions; } -interface IExecutionOperationsOptions { +interface IExecuteOperationsOptions { executeOperationsContext: IExecuteOperationsContext; executionManagerOptions: IOperationExecutionManagerOptions; ignoreHooks: boolean; @@ -137,6 +137,7 @@ export class PhasedScriptAction extends BaseScriptAction { */ public _runsBeforeInstall: boolean | undefined; public readonly hooks: PhasedCommandHooks; + public readonly abortController: AbortController; private readonly _enableParallelism: boolean; private readonly _isIncrementalBuildAllowed: boolean; @@ -150,6 +151,7 @@ export class PhasedScriptAction extends BaseScriptAction { private readonly _knownPhases: ReadonlyMap; private readonly _terminal: ITerminal; private _changedProjectsOnly: boolean; + private _executionAbortController: AbortController; private readonly _changedProjectsOnlyParameter: CommandLineFlagParameter | undefined; private readonly _selectionParameters: SelectionParameterSet; @@ -180,6 +182,23 @@ export class PhasedScriptAction extends BaseScriptAction { this._runsBeforeInstall = false; this._knownPhases = options.phases; this._changedProjectsOnly = false; + this.abortController = new AbortController(); + this._executionAbortController = new AbortController(); + + this.abortController.signal.addEventListener( + 'abort', + () => { + this._executionAbortController.abort(); + }, + { once: true } + ); + const onAbortExecution = (): void => { + if (!this.abortController.signal.aborted) { + this._executionAbortController = new AbortController(); + this._executionAbortController.signal.addEventListener('abort', onAbortExecution, { once: true }); + } + }; + this._executionAbortController.signal.addEventListener('abort', onAbortExecution, { once: true }); this.hooks = new PhasedCommandHooks(); @@ -655,9 +674,11 @@ export class PhasedScriptAction extends BaseScriptAction { } ); + const abortController: AbortController = this._executionAbortController; const initialExecuteOperationsContext: IExecuteOperationsContext = { ...initialCreateOperationsContext, - inputsSnapshot: initialSnapshot + inputsSnapshot: initialSnapshot, + abortController }; const executionManagerOptions: IOperationExecutionManagerOptions = { @@ -670,7 +691,7 @@ export class PhasedScriptAction extends BaseScriptAction { } }; - const initialOptions: IExecutionOperationsOptions = { + const initialOptions: IExecuteOperationsOptions = { executeOperationsContext: initialExecuteOperationsContext, ignoreHooks: false, operations, @@ -691,14 +712,12 @@ export class PhasedScriptAction extends BaseScriptAction { }; } - private _registerWatchModeInterface( - projectWatcher: ProjectWatcher, - abortController: AbortController - ): void { + private _registerWatchModeInterface(projectWatcher: ProjectWatcher): void { const buildOnceKey: 'b' = 'b'; const changedProjectsOnlyKey: 'c' = 'c'; const invalidateKey: 'i' = 'i'; const quitKey: 'q' = 'q'; + const abortKey: 'a' = 'a'; const toggleWatcherKey: 'w' = 'w'; const shutdownProcessesKey: 'x' = 'x'; @@ -707,6 +726,7 @@ export class PhasedScriptAction extends BaseScriptAction { projectWatcher.setPromptGenerator((isPaused: boolean) => { const promptLines: string[] = [ ` Press <${quitKey}> to gracefully exit.`, + ` Press <${abortKey}> to abort queued operations. Any that have started will finish.`, ` Press <${toggleWatcherKey}> to ${isPaused ? 'resume' : 'pause'}.`, ` Press <${invalidateKey}> to invalidate all projects.`, ` Press <${changedProjectsOnlyKey}> to ${ @@ -725,11 +745,15 @@ export class PhasedScriptAction extends BaseScriptAction { const onKeyPress = (key: string): void => { switch (key) { case quitKey: - terminal.writeLine(`Exiting watch mode...`); + terminal.writeLine(`Exiting watch mode and aborting any scheduled work...`); process.stdin.setRawMode(false); process.stdin.off('data', onKeyPress); process.stdin.unref(); - abortController.abort(); + this.abortController.abort(); + break; + case abortKey: + terminal.writeLine(`Aborting current iteration...`); + this._executionAbortController.abort(); break; case toggleWatcherKey: if (projectWatcher.isPaused) { @@ -768,6 +792,7 @@ export class PhasedScriptAction extends BaseScriptAction { process.stdin.setRawMode(false); process.stdin.off('data', onKeyPress); process.stdin.unref(); + this.abortController.abort(); process.kill(process.pid, 'SIGINT'); break; } @@ -814,7 +839,7 @@ export class PhasedScriptAction extends BaseScriptAction { '../../logic/ProjectWatcher' ); - const abortController: AbortController = new AbortController(); + const abortController: AbortController = this.abortController; const abortSignal: AbortSignal = abortController.signal; const projectWatcher: typeof ProjectWatcher.prototype = new ProjectWatcher({ @@ -829,7 +854,7 @@ export class PhasedScriptAction extends BaseScriptAction { // Ensure process.stdin allows interactivity before using TTY-only APIs if (process.stdin.isTTY) { - this._registerWatchModeInterface(projectWatcher, abortController); + this._registerWatchModeInterface(projectWatcher); } const onWaitingForChanges = (): void => { @@ -880,6 +905,7 @@ export class PhasedScriptAction extends BaseScriptAction { // Account for consumer relationships const executeOperationsContext: IExecuteOperationsContext = { ...initialCreateOperationsContext, + abortController: this._executionAbortController, changedProjectsOnly: !!this._changedProjectsOnly, isInitial: false, inputsSnapshot: state, @@ -893,7 +919,7 @@ export class PhasedScriptAction extends BaseScriptAction { this.hooks.createOperations.promise(new Set(), executeOperationsContext) ); - const executeOptions: IExecutionOperationsOptions = { + const executeOptions: IExecuteOperationsOptions = { executeOperationsContext, // For now, don't run pre-build or post-build in watch mode ignoreHooks: true, @@ -928,15 +954,22 @@ export class PhasedScriptAction extends BaseScriptAction { /** * Runs a set of operations and reports the results. */ - private async _executeOperationsAsync(options: IExecutionOperationsOptions): Promise { - const { executionManagerOptions, ignoreHooks, operations, stopwatch, terminal } = options; + private async _executeOperationsAsync(options: IExecuteOperationsOptions): Promise { + const { + executeOperationsContext, + executionManagerOptions, + ignoreHooks, + operations, + stopwatch, + terminal + } = options; const executionManager: OperationExecutionManager = new OperationExecutionManager( operations, executionManagerOptions ); - const { isInitial, isWatch } = options.executeOperationsContext; + const { isInitial, isWatch, abortController, invalidateOperation } = executeOperationsContext; let success: boolean = false; let result: IExecutionResult | undefined; @@ -944,13 +977,13 @@ export class PhasedScriptAction extends BaseScriptAction { try { const definiteResult: IExecutionResult = await measureAsyncFn( `${PERF_PREFIX}:executeOperationsInner`, - () => executionManager.executeAsync() + () => executionManager.executeAsync(abortController) ); success = definiteResult.status === OperationStatus.Success; result = definiteResult; await measureAsyncFn(`${PERF_PREFIX}:afterExecuteOperations`, () => - this.hooks.afterExecuteOperations.promise(definiteResult, options.executeOperationsContext) + this.hooks.afterExecuteOperations.promise(definiteResult, executeOperationsContext) ); stopwatch.stop(); @@ -980,6 +1013,18 @@ export class PhasedScriptAction extends BaseScriptAction { } } + if (invalidateOperation) { + const operationResults: ReadonlyMap | undefined = + result?.operationResults; + if (operationResults) { + for (const [operation, { status }] of operationResults) { + if (status === OperationStatus.Aborted) { + invalidateOperation(operation, 'aborted'); + } + } + } + } + if (!ignoreHooks) { measureFn(`${PERF_PREFIX}:doAfterTask`, () => this._doAfterTask()); } diff --git a/libraries/rush-lib/src/logic/ProjectWatcher.ts b/libraries/rush-lib/src/logic/ProjectWatcher.ts index 6f910886fe9..07f52e82aec 100644 --- a/libraries/rush-lib/src/logic/ProjectWatcher.ts +++ b/libraries/rush-lib/src/logic/ProjectWatcher.ts @@ -156,6 +156,13 @@ export class ProjectWatcher { * `waitForChange` is not allowed to be called multiple times concurrently. */ public async waitForChangeAsync(onWatchingFiles?: () => void): Promise { + if (this.isPaused) { + this._setStatus(`Project watcher paused.`); + await new Promise((resolve) => { + this._resolveIfChanged = async () => resolve(); + }); + } + const initialChangeResult: IProjectChangeResult = await this._computeChangedAsync(); // Ensure that the new state is recorded so that we don't loop infinitely this._commitChanges(initialChangeResult.inputsSnapshot); diff --git a/libraries/rush-lib/src/logic/operations/AsyncOperationQueue.ts b/libraries/rush-lib/src/logic/operations/AsyncOperationQueue.ts index 12ac10c7e83..2616d46f3b7 100644 --- a/libraries/rush-lib/src/logic/operations/AsyncOperationQueue.ts +++ b/libraries/rush-lib/src/logic/operations/AsyncOperationQueue.ts @@ -104,7 +104,8 @@ export class AsyncOperationQueue record.status === OperationStatus.SuccessWithWarning || record.status === OperationStatus.FromCache || record.status === OperationStatus.NoOp || - record.status === OperationStatus.Failure + record.status === OperationStatus.Failure || + record.status === OperationStatus.Aborted ) { // It shouldn't be on the queue, remove it queue.splice(i, 1); diff --git a/libraries/rush-lib/src/logic/operations/ConsoleTimelinePlugin.ts b/libraries/rush-lib/src/logic/operations/ConsoleTimelinePlugin.ts index fc7bc901018..5b5916608d1 100644 --- a/libraries/rush-lib/src/logic/operations/ConsoleTimelinePlugin.ts +++ b/libraries/rush-lib/src/logic/operations/ConsoleTimelinePlugin.ts @@ -86,7 +86,8 @@ const TIMELINE_CHART_SYMBOLS: Record = { [OperationStatus.Blocked]: '.', [OperationStatus.Skipped]: '%', [OperationStatus.FromCache]: '%', - [OperationStatus.NoOp]: '%' + [OperationStatus.NoOp]: '%', + [OperationStatus.Aborted]: '@' }; const COBUILD_REPORTABLE_STATUSES: Set = new Set([ @@ -110,7 +111,8 @@ const TIMELINE_CHART_COLORIZER: Record stri [OperationStatus.Blocked]: Colorize.red, [OperationStatus.Skipped]: Colorize.green, [OperationStatus.FromCache]: Colorize.green, - [OperationStatus.NoOp]: Colorize.gray + [OperationStatus.NoOp]: Colorize.gray, + [OperationStatus.Aborted]: Colorize.gray }; interface ITimelineRecord { diff --git a/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts b/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts index 01cc0845889..72205b07686 100644 --- a/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts +++ b/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts @@ -90,6 +90,7 @@ export class OperationExecutionManager { // Variables for current status private _hasAnyFailures: boolean; private _hasAnyNonAllowedWarnings: boolean; + private _hasAnyAborted: boolean; private _completedOperations: number; private _executionQueue: AsyncOperationQueue; @@ -109,6 +110,7 @@ export class OperationExecutionManager { this._quietMode = quietMode; this._hasAnyFailures = false; this._hasAnyNonAllowedWarnings = false; + this._hasAnyAborted = false; this._parallelism = parallelism; this._beforeExecuteOperation = beforeExecuteOperation; @@ -232,9 +234,10 @@ export class OperationExecutionManager { * Executes all operations which have been registered, returning a promise which is resolved when all the * operations are completed successfully, or rejects when any operation fails. */ - public async executeAsync(): Promise { + public async executeAsync(abortController: AbortController): Promise { this._completedOperations = 0; const totalOperations: number = this._totalOperations; + const abortSignal: AbortSignal = abortController.signal; if (!this._quietMode) { const plural: string = totalOperations === 1 ? '' : 's'; @@ -287,10 +290,15 @@ export class OperationExecutionManager { await Async.forEachAsync( this._executionQueue, async (record: OperationExecutionRecord) => { - await record.executeAsync({ - onStart: onOperationStartAsync, - onResult: onOperationCompleteAsync - }); + if (abortSignal.aborted) { + record.status = OperationStatus.Aborted; + this._onOperationComplete(record); + } else { + await record.executeAsync({ + onStart: onOperationStartAsync, + onResult: onOperationCompleteAsync + }); + } }, { concurrency: maxParallelism, @@ -300,9 +308,11 @@ export class OperationExecutionManager { const status: OperationStatus = this._hasAnyFailures ? OperationStatus.Failure - : this._hasAnyNonAllowedWarnings - ? OperationStatus.SuccessWithWarning - : OperationStatus.Success; + : this._hasAnyAborted + ? OperationStatus.Aborted + : this._hasAnyNonAllowedWarnings + ? OperationStatus.SuccessWithWarning + : OperationStatus.Success; return { operationResults: this._executionRecords, @@ -437,6 +447,11 @@ export class OperationExecutionManager { break; } + case OperationStatus.Aborted: { + this._hasAnyAborted ||= true; + break; + } + default: { throw new InternalError(`Unexpected operation status: ${status}`); } diff --git a/libraries/rush-lib/src/logic/operations/OperationResultSummarizerPlugin.ts b/libraries/rush-lib/src/logic/operations/OperationResultSummarizerPlugin.ts index 296b15b09d5..b2adde73eb5 100644 --- a/libraries/rush-lib/src/logic/operations/OperationResultSummarizerPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/OperationResultSummarizerPlugin.ts @@ -69,6 +69,7 @@ export function _printOperationStatus(terminal: ITerminal, result: IExecutionRes case OperationStatus.Blocked: case OperationStatus.Failure: case OperationStatus.NoOp: + case OperationStatus.Aborted: break; default: // This should never happen @@ -127,6 +128,14 @@ export function _printOperationStatus(terminal: ITerminal, result: IExecutionRes 'WARNING' ); + writeCondensedSummary( + terminal, + OperationStatus.Aborted, + operationsByStatus, + Colorize.gray, + 'These operations were aborted:' + ); + writeCondensedSummary( terminal, OperationStatus.Blocked, diff --git a/libraries/rush-lib/src/logic/operations/OperationStatus.ts b/libraries/rush-lib/src/logic/operations/OperationStatus.ts index 4005de5227c..6bff6959ef4 100644 --- a/libraries/rush-lib/src/logic/operations/OperationStatus.ts +++ b/libraries/rush-lib/src/logic/operations/OperationStatus.ts @@ -49,7 +49,11 @@ export enum OperationStatus { /** * The Operation was a no-op (for example, it had an empty script) */ - NoOp = 'NO OP' + NoOp = 'NO OP', + /** + * The Operation was aborted before it could execute. + */ + Aborted = 'ABORTED' } /** @@ -63,5 +67,6 @@ export const TERMINAL_STATUSES: Set = new Set([ OperationStatus.Blocked, OperationStatus.FromCache, OperationStatus.Failure, - OperationStatus.NoOp + OperationStatus.NoOp, + OperationStatus.Aborted ]); diff --git a/libraries/rush-lib/src/logic/operations/test/OperationExecutionManager.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationExecutionManager.test.ts index 055e0eb5969..d86f2bc7f7b 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationExecutionManager.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationExecutionManager.test.ts @@ -117,7 +117,8 @@ describe(OperationExecutionManager.name, () => { }) ); - const result: IExecutionResult = await executionManager.executeAsync(); + const abortController = new AbortController(); + const result: IExecutionResult = await executionManager.executeAsync(abortController); _printOperationStatus(mockTerminal, result); expect(result.status).toEqual(OperationStatus.Failure); expect(result.operationResults.size).toEqual(1); @@ -141,7 +142,8 @@ describe(OperationExecutionManager.name, () => { }) ); - const result: IExecutionResult = await executionManager.executeAsync(); + const abortController = new AbortController(); + const result: IExecutionResult = await executionManager.executeAsync(abortController); _printOperationStatus(mockTerminal, result); expect(result.status).toEqual(OperationStatus.Failure); expect(result.operationResults.size).toEqual(1); @@ -157,6 +159,48 @@ describe(OperationExecutionManager.name, () => { }); }); + describe('Aborting', () => { + it('Aborted operations abort', async () => { + const mockRun: jest.Mock = jest.fn(); + + const firstOperation = new Operation({ + runner: new MockOperationRunner('1', mockRun), + phase: mockPhase, + project: getOrCreateProject('1'), + logFilenameIdentifier: '1' + }); + + const secondOperation = new Operation({ + runner: new MockOperationRunner('2', mockRun), + phase: mockPhase, + project: getOrCreateProject('2'), + logFilenameIdentifier: '2' + }); + + secondOperation.addDependency(firstOperation); + + const manager: OperationExecutionManager = new OperationExecutionManager( + new Set([firstOperation, secondOperation]), + { + quietMode: false, + debugMode: false, + parallelism: 1, + destination: mockWritable + } + ); + + const abortController = new AbortController(); + abortController.abort(); + + const result = await manager.executeAsync(abortController); + expect(result.status).toEqual(OperationStatus.Aborted); + expect(mockRun).not.toHaveBeenCalled(); + expect(result.operationResults.size).toEqual(2); + expect(result.operationResults.get(firstOperation)?.status).toEqual(OperationStatus.Aborted); + expect(result.operationResults.get(secondOperation)?.status).toEqual(OperationStatus.Aborted); + }); + }); + describe('Blocking', () => { it('Failed operations block', async () => { const failingOperation = new Operation({ @@ -189,7 +233,8 @@ describe(OperationExecutionManager.name, () => { } ); - const result = await manager.executeAsync(); + const abortController = new AbortController(); + const result = await manager.executeAsync(abortController); expect(result.status).toEqual(OperationStatus.Failure); expect(blockedRunFn).not.toHaveBeenCalled(); expect(result.operationResults.size).toEqual(2); @@ -219,7 +264,8 @@ describe(OperationExecutionManager.name, () => { }) ); - const result: IExecutionResult = await executionManager.executeAsync(); + const abortController = new AbortController(); + const result: IExecutionResult = await executionManager.executeAsync(abortController); _printOperationStatus(mockTerminal, result); expect(result.status).toEqual(OperationStatus.SuccessWithWarning); expect(result.operationResults.size).toEqual(1); @@ -259,7 +305,8 @@ describe(OperationExecutionManager.name, () => { ) ); - const result: IExecutionResult = await executionManager.executeAsync(); + const abortController = new AbortController(); + const result: IExecutionResult = await executionManager.executeAsync(abortController); _printOperationStatus(mockTerminal, result); expect(result.status).toEqual(OperationStatus.Success); expect(result.operationResults.size).toEqual(1); @@ -287,7 +334,8 @@ describe(OperationExecutionManager.name, () => { ) ); - const result: IExecutionResult = await executionManager.executeAsync(); + const abortController = new AbortController(); + const result: IExecutionResult = await executionManager.executeAsync(abortController); _printTimeline({ terminal: mockTerminal, result, cobuildConfiguration: undefined }); _printOperationStatus(mockTerminal, result); const allMessages: string = mockWritable.getAllOutput(); @@ -359,7 +407,8 @@ describe(OperationExecutionManager.name, () => { {} as unknown as RushConfigurationProject ); - const result: IExecutionResult = await executionManager.executeAsync(); + const abortController = new AbortController(); + const result: IExecutionResult = await executionManager.executeAsync(abortController); _printTimeline({ terminal: mockTerminal, result, @@ -384,7 +433,8 @@ describe(OperationExecutionManager.name, () => { {} as unknown as RushConfigurationProject ); - const result: IExecutionResult = await executionManager.executeAsync(); + const abortController = new AbortController(); + const result: IExecutionResult = await executionManager.executeAsync(abortController); _printTimeline({ terminal: mockTerminal, result, diff --git a/libraries/rush-lib/src/pluginFramework/PhasedCommandHooks.ts b/libraries/rush-lib/src/pluginFramework/PhasedCommandHooks.ts index b6599398df1..84f3fbd58ab 100644 --- a/libraries/rush-lib/src/pluginFramework/PhasedCommandHooks.ts +++ b/libraries/rush-lib/src/pluginFramework/PhasedCommandHooks.ts @@ -131,6 +131,11 @@ export interface IExecuteOperationsContext extends ICreateOperationsContext { * Not part of the creation context to avoid the overhead of Git calls when initializing the graph. */ readonly inputsSnapshot?: IInputsSnapshot; + + /** + * An abort controller that can be used to abort the current set of queued operations. + */ + readonly abortController: AbortController; } /** diff --git a/libraries/rush-lib/src/pluginFramework/RushLifeCycle.ts b/libraries/rush-lib/src/pluginFramework/RushLifeCycle.ts index dd0e274526b..481e441bdbf 100644 --- a/libraries/rush-lib/src/pluginFramework/RushLifeCycle.ts +++ b/libraries/rush-lib/src/pluginFramework/RushLifeCycle.ts @@ -36,6 +36,13 @@ export interface IPhasedCommand extends IRushCommand { * @alpha */ readonly hooks: PhasedCommandHooks; + + /** + * An abort controller that can be used to abort the command. + * Long-lived plugins should listen to the signal to handle any cleanup logic. + * @alpha + */ + readonly abortController: AbortController; } /** diff --git a/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts b/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts index 9552c35977f..ad3c810a19a 100644 --- a/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts +++ b/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts @@ -55,7 +55,8 @@ export class BridgeCachePlugin implements IRushPlugin { command.hooks.createOperations.tap( { name: PLUGIN_NAME, stage: Number.MAX_SAFE_INTEGER }, (operations: Set, context: ICreateOperationsContext): Set => { - cacheAction = this._getCacheAction(context); + const { customParameters } = context; + cacheAction = this._getCacheAction(customParameters); if (cacheAction !== undefined) { if (!context.buildCacheConfiguration?.buildCacheEnabled) { @@ -68,7 +69,7 @@ export class BridgeCachePlugin implements IRushPlugin { operation.enabled = false; } - requireOutputFolders = this._isRequireOutputFoldersFlagSet(context); + requireOutputFolders = this._isRequireOutputFoldersFlagSet(customParameters); } return operations; @@ -178,8 +179,10 @@ export class BridgeCachePlugin implements IRushPlugin { }); } - private _getCacheAction(context: IExecuteOperationsContext): CacheAction | undefined { - const cacheActionParameter: CommandLineParameter | undefined = context.customParameters.get( + private _getCacheAction( + customParameters: ReadonlyMap + ): CacheAction | undefined { + const cacheActionParameter: CommandLineParameter | undefined = customParameters.get( this._actionParameterName ); @@ -217,12 +220,14 @@ export class BridgeCachePlugin implements IRushPlugin { return undefined; } - private _isRequireOutputFoldersFlagSet(context: IExecuteOperationsContext): boolean { + private _isRequireOutputFoldersFlagSet( + customParameters: ReadonlyMap + ): boolean { if (!this._requireOutputFoldersParameterName) { return false; } - const requireOutputFoldersParam: CommandLineParameter | undefined = context.customParameters.get( + const requireOutputFoldersParam: CommandLineParameter | undefined = customParameters.get( this._requireOutputFoldersParameterName ); diff --git a/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts b/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts index 27ac7450720..d6ac6d0be59 100644 --- a/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts +++ b/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts @@ -243,9 +243,15 @@ export async function phasedCommandHandler(options: IPhasedCommandHandlerOptions webSocketServerUpgrader?.(server); server.listen(requestedPort); - // Don't let the HTTP/2 server keep the process alive if the user asks to quit. - // TODO: use some "user wants to exit" event to close the server. - server.unref(); + command.abortController.signal.addEventListener( + 'abort', + () => { + server.close(); + // Don't let the HTTP/2 server keep the process alive if the user asks to quit. + server.unref(); + }, + { once: true } + ); await once(server, 'listening'); const address: AddressInfo | undefined = server.address() as AddressInfo; @@ -298,7 +304,8 @@ function tryEnableBuildStatusWebSocketServer( [OperationStatus.FromCache]: 'FromCache', [OperationStatus.Failure]: 'Failure', [OperationStatus.Blocked]: 'Blocked', - [OperationStatus.NoOp]: 'NoOp' + [OperationStatus.NoOp]: 'NoOp', + [OperationStatus.Aborted]: 'Aborted' }; const { logServePath } = options; From 4a181da7c7c23368e16908d853eb8dcc7a3ab98c Mon Sep 17 00:00:00 2001 From: David Michon Date: Tue, 16 Sep 2025 01:40:06 +0000 Subject: [PATCH 2/4] Disambiguate --- common/reviews/api/rush-lib.api.md | 4 +- .../cli/scriptActions/PhasedScriptAction.ts | 40 +++++++++---------- .../src/pluginFramework/RushLifeCycle.ts | 2 +- .../src/phasedCommandHandler.ts | 2 +- 4 files changed, 23 insertions(+), 25 deletions(-) diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index afe6451f808..50b24af3f22 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -743,10 +743,10 @@ export type IPhaseBehaviorForMissingScript = 'silent' | 'log' | 'error'; // @beta export interface IPhasedCommand extends IRushCommand { - // @alpha - readonly abortController: AbortController; // @alpha readonly hooks: PhasedCommandHooks; + // @alpha + readonly sessionAbortController: AbortController; } // @public diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index 2fc7961f1e6..038e2386f31 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -131,13 +131,13 @@ interface IPhasedCommandTelemetry { * and "rebuild" commands are also modeled as phased commands with a single phase that invokes the npm * "build" script for each project. */ -export class PhasedScriptAction extends BaseScriptAction { +export class PhasedScriptAction extends BaseScriptAction implements IPhasedCommand { /** * @internal */ public _runsBeforeInstall: boolean | undefined; public readonly hooks: PhasedCommandHooks; - public readonly abortController: AbortController; + public readonly sessionAbortController: AbortController; private readonly _enableParallelism: boolean; private readonly _isIncrementalBuildAllowed: boolean; @@ -151,7 +151,7 @@ export class PhasedScriptAction extends BaseScriptAction { private readonly _knownPhases: ReadonlyMap; private readonly _terminal: ITerminal; private _changedProjectsOnly: boolean; - private _executionAbortController: AbortController; + private _executionAbortController: AbortController | undefined; private readonly _changedProjectsOnlyParameter: CommandLineFlagParameter | undefined; private readonly _selectionParameters: SelectionParameterSet; @@ -182,23 +182,16 @@ export class PhasedScriptAction extends BaseScriptAction { this._runsBeforeInstall = false; this._knownPhases = options.phases; this._changedProjectsOnly = false; - this.abortController = new AbortController(); - this._executionAbortController = new AbortController(); + this.sessionAbortController = new AbortController(); + this._executionAbortController = undefined; - this.abortController.signal.addEventListener( + this.sessionAbortController.signal.addEventListener( 'abort', () => { - this._executionAbortController.abort(); + this._executionAbortController?.abort(); }, { once: true } ); - const onAbortExecution = (): void => { - if (!this.abortController.signal.aborted) { - this._executionAbortController = new AbortController(); - this._executionAbortController.signal.addEventListener('abort', onAbortExecution, { once: true }); - } - }; - this._executionAbortController.signal.addEventListener('abort', onAbortExecution, { once: true }); this.hooks = new PhasedCommandHooks(); @@ -674,7 +667,7 @@ export class PhasedScriptAction extends BaseScriptAction { } ); - const abortController: AbortController = this._executionAbortController; + const abortController: AbortController = (this._executionAbortController = new AbortController()); const initialExecuteOperationsContext: IExecuteOperationsContext = { ...initialCreateOperationsContext, inputsSnapshot: initialSnapshot, @@ -749,11 +742,11 @@ export class PhasedScriptAction extends BaseScriptAction { process.stdin.setRawMode(false); process.stdin.off('data', onKeyPress); process.stdin.unref(); - this.abortController.abort(); + this.sessionAbortController.abort(); break; case abortKey: terminal.writeLine(`Aborting current iteration...`); - this._executionAbortController.abort(); + this._executionAbortController?.abort(); break; case toggleWatcherKey: if (projectWatcher.isPaused) { @@ -792,7 +785,7 @@ export class PhasedScriptAction extends BaseScriptAction { process.stdin.setRawMode(false); process.stdin.off('data', onKeyPress); process.stdin.unref(); - this.abortController.abort(); + this.sessionAbortController.abort(); process.kill(process.pid, 'SIGINT'); break; } @@ -839,8 +832,8 @@ export class PhasedScriptAction extends BaseScriptAction { '../../logic/ProjectWatcher' ); - const abortController: AbortController = this.abortController; - const abortSignal: AbortSignal = abortController.signal; + const sessionAbortController: AbortController = this.sessionAbortController; + const abortSignal: AbortSignal = sessionAbortController.signal; const projectWatcher: typeof ProjectWatcher.prototype = new ProjectWatcher({ getInputsSnapshotAsync, @@ -902,10 +895,13 @@ export class PhasedScriptAction extends BaseScriptAction { terminal.writeLine(` ${Colorize.cyan(name)}`); } + const initialAbortController: AbortController = (this._executionAbortController = + new AbortController()); + // Account for consumer relationships const executeOperationsContext: IExecuteOperationsContext = { ...initialCreateOperationsContext, - abortController: this._executionAbortController, + abortController: initialAbortController, changedProjectsOnly: !!this._changedProjectsOnly, isInitial: false, inputsSnapshot: state, @@ -1013,6 +1009,8 @@ export class PhasedScriptAction extends BaseScriptAction { } } + this._executionAbortController = undefined; + if (invalidateOperation) { const operationResults: ReadonlyMap | undefined = result?.operationResults; diff --git a/libraries/rush-lib/src/pluginFramework/RushLifeCycle.ts b/libraries/rush-lib/src/pluginFramework/RushLifeCycle.ts index 481e441bdbf..df190a0d598 100644 --- a/libraries/rush-lib/src/pluginFramework/RushLifeCycle.ts +++ b/libraries/rush-lib/src/pluginFramework/RushLifeCycle.ts @@ -42,7 +42,7 @@ export interface IPhasedCommand extends IRushCommand { * Long-lived plugins should listen to the signal to handle any cleanup logic. * @alpha */ - readonly abortController: AbortController; + readonly sessionAbortController: AbortController; } /** diff --git a/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts b/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts index d6ac6d0be59..e949bf14490 100644 --- a/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts +++ b/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts @@ -243,7 +243,7 @@ export async function phasedCommandHandler(options: IPhasedCommandHandlerOptions webSocketServerUpgrader?.(server); server.listen(requestedPort); - command.abortController.signal.addEventListener( + command.sessionAbortController.signal.addEventListener( 'abort', () => { server.close(); From dcd79f08ab30043203c46d3f49b83646fba860e8 Mon Sep 17 00:00:00 2001 From: David Michon Date: Tue, 16 Sep 2025 20:49:36 +0000 Subject: [PATCH 3/4] Clarify abort special casing. --- .../src/logic/operations/OperationExecutionManager.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts b/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts index 72205b07686..b88211eabb7 100644 --- a/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts +++ b/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts @@ -292,7 +292,10 @@ export class OperationExecutionManager { async (record: OperationExecutionRecord) => { if (abortSignal.aborted) { record.status = OperationStatus.Aborted; - this._onOperationComplete(record); + // Bypass the normal completion handler, directly mark the operation as aborted and unblock the queue. + // We do this to ensure that we aren't messing with the stopwatch or terminal. + this._hasAnyAborted = true; + this._executionQueue.complete(record); } else { await record.executeAsync({ onStart: onOperationStartAsync, From f84e5b500bce06fb1012756e234662b982984671 Mon Sep 17 00:00:00 2001 From: David Michon Date: Tue, 16 Sep 2025 20:55:21 +0000 Subject: [PATCH 4/4] Adjust coloring of "aborted" to match "blocked" --- .../rush-lib/src/logic/operations/ConsoleTimelinePlugin.ts | 2 +- .../src/logic/operations/OperationResultSummarizerPlugin.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/rush-lib/src/logic/operations/ConsoleTimelinePlugin.ts b/libraries/rush-lib/src/logic/operations/ConsoleTimelinePlugin.ts index 5b5916608d1..e800aaaf871 100644 --- a/libraries/rush-lib/src/logic/operations/ConsoleTimelinePlugin.ts +++ b/libraries/rush-lib/src/logic/operations/ConsoleTimelinePlugin.ts @@ -112,7 +112,7 @@ const TIMELINE_CHART_COLORIZER: Record stri [OperationStatus.Skipped]: Colorize.green, [OperationStatus.FromCache]: Colorize.green, [OperationStatus.NoOp]: Colorize.gray, - [OperationStatus.Aborted]: Colorize.gray + [OperationStatus.Aborted]: Colorize.red }; interface ITimelineRecord { diff --git a/libraries/rush-lib/src/logic/operations/OperationResultSummarizerPlugin.ts b/libraries/rush-lib/src/logic/operations/OperationResultSummarizerPlugin.ts index b2adde73eb5..4c5af75f6b8 100644 --- a/libraries/rush-lib/src/logic/operations/OperationResultSummarizerPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/OperationResultSummarizerPlugin.ts @@ -132,7 +132,7 @@ export function _printOperationStatus(terminal: ITerminal, result: IExecutionRes terminal, OperationStatus.Aborted, operationsByStatus, - Colorize.gray, + Colorize.white, 'These operations were aborted:' );