Skip to content

Commit f017e4b

Browse files
authored
[rush] Support aborting phased command execution (#5362)
* [rush] Support aborting phased command execution * Disambiguate * Clarify abort special casing. * Adjust coloring of "aborted" to match "blocked" --------- Co-authored-by: David Michon <dmichon-msft@users.noreply.github.com>
1 parent afd2b72 commit f017e4b

14 files changed

Lines changed: 223 additions & 50 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/rush",
5+
"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.",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@microsoft/rush"
10+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,7 @@ export interface IEnvironmentConfigurationInitializeOptions {
477477

478478
// @alpha
479479
export interface IExecuteOperationsContext extends ICreateOperationsContext {
480+
readonly abortController: AbortController;
480481
readonly inputsSnapshot?: IInputsSnapshot;
481482
}
482483

@@ -744,6 +745,8 @@ export type IPhaseBehaviorForMissingScript = 'silent' | 'log' | 'error';
744745
export interface IPhasedCommand extends IRushCommand {
745746
// @alpha
746747
readonly hooks: PhasedCommandHooks;
748+
// @alpha
749+
readonly sessionAbortController: AbortController;
747750
}
748751

749752
// @public
@@ -1053,6 +1056,7 @@ export class _OperationStateFile {
10531056

10541057
// @beta
10551058
export enum OperationStatus {
1059+
Aborted = "ABORTED",
10561060
Blocked = "BLOCKED",
10571061
Executing = "EXECUTING",
10581062
Failure = "FAILURE",

libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ interface IRunPhasesOptions extends IInitialRunPhasesOptions {
9898
executionManagerOptions: IOperationExecutionManagerOptions;
9999
}
100100

101-
interface IExecutionOperationsOptions {
101+
interface IExecuteOperationsOptions {
102102
executeOperationsContext: IExecuteOperationsContext;
103103
executionManagerOptions: IOperationExecutionManagerOptions;
104104
ignoreHooks: boolean;
@@ -131,12 +131,13 @@ interface IPhasedCommandTelemetry {
131131
* and "rebuild" commands are also modeled as phased commands with a single phase that invokes the npm
132132
* "build" script for each project.
133133
*/
134-
export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> {
134+
export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> implements IPhasedCommand {
135135
/**
136136
* @internal
137137
*/
138138
public _runsBeforeInstall: boolean | undefined;
139139
public readonly hooks: PhasedCommandHooks;
140+
public readonly sessionAbortController: AbortController;
140141

141142
private readonly _enableParallelism: boolean;
142143
private readonly _isIncrementalBuildAllowed: boolean;
@@ -150,6 +151,7 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> {
150151
private readonly _knownPhases: ReadonlyMap<string, IPhase>;
151152
private readonly _terminal: ITerminal;
152153
private _changedProjectsOnly: boolean;
154+
private _executionAbortController: AbortController | undefined;
153155

154156
private readonly _changedProjectsOnlyParameter: CommandLineFlagParameter | undefined;
155157
private readonly _selectionParameters: SelectionParameterSet;
@@ -180,6 +182,16 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> {
180182
this._runsBeforeInstall = false;
181183
this._knownPhases = options.phases;
182184
this._changedProjectsOnly = false;
185+
this.sessionAbortController = new AbortController();
186+
this._executionAbortController = undefined;
187+
188+
this.sessionAbortController.signal.addEventListener(
189+
'abort',
190+
() => {
191+
this._executionAbortController?.abort();
192+
},
193+
{ once: true }
194+
);
183195

184196
this.hooks = new PhasedCommandHooks();
185197

@@ -655,9 +667,11 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> {
655667
}
656668
);
657669

670+
const abortController: AbortController = (this._executionAbortController = new AbortController());
658671
const initialExecuteOperationsContext: IExecuteOperationsContext = {
659672
...initialCreateOperationsContext,
660-
inputsSnapshot: initialSnapshot
673+
inputsSnapshot: initialSnapshot,
674+
abortController
661675
};
662676

663677
const executionManagerOptions: IOperationExecutionManagerOptions = {
@@ -670,7 +684,7 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> {
670684
}
671685
};
672686

673-
const initialOptions: IExecutionOperationsOptions = {
687+
const initialOptions: IExecuteOperationsOptions = {
674688
executeOperationsContext: initialExecuteOperationsContext,
675689
ignoreHooks: false,
676690
operations,
@@ -691,14 +705,12 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> {
691705
};
692706
}
693707

694-
private _registerWatchModeInterface(
695-
projectWatcher: ProjectWatcher,
696-
abortController: AbortController
697-
): void {
708+
private _registerWatchModeInterface(projectWatcher: ProjectWatcher): void {
698709
const buildOnceKey: 'b' = 'b';
699710
const changedProjectsOnlyKey: 'c' = 'c';
700711
const invalidateKey: 'i' = 'i';
701712
const quitKey: 'q' = 'q';
713+
const abortKey: 'a' = 'a';
702714
const toggleWatcherKey: 'w' = 'w';
703715
const shutdownProcessesKey: 'x' = 'x';
704716

@@ -707,6 +719,7 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> {
707719
projectWatcher.setPromptGenerator((isPaused: boolean) => {
708720
const promptLines: string[] = [
709721
` Press <${quitKey}> to gracefully exit.`,
722+
` Press <${abortKey}> to abort queued operations. Any that have started will finish.`,
710723
` Press <${toggleWatcherKey}> to ${isPaused ? 'resume' : 'pause'}.`,
711724
` Press <${invalidateKey}> to invalidate all projects.`,
712725
` Press <${changedProjectsOnlyKey}> to ${
@@ -725,11 +738,15 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> {
725738
const onKeyPress = (key: string): void => {
726739
switch (key) {
727740
case quitKey:
728-
terminal.writeLine(`Exiting watch mode...`);
741+
terminal.writeLine(`Exiting watch mode and aborting any scheduled work...`);
729742
process.stdin.setRawMode(false);
730743
process.stdin.off('data', onKeyPress);
731744
process.stdin.unref();
732-
abortController.abort();
745+
this.sessionAbortController.abort();
746+
break;
747+
case abortKey:
748+
terminal.writeLine(`Aborting current iteration...`);
749+
this._executionAbortController?.abort();
733750
break;
734751
case toggleWatcherKey:
735752
if (projectWatcher.isPaused) {
@@ -768,6 +785,7 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> {
768785
process.stdin.setRawMode(false);
769786
process.stdin.off('data', onKeyPress);
770787
process.stdin.unref();
788+
this.sessionAbortController.abort();
771789
process.kill(process.pid, 'SIGINT');
772790
break;
773791
}
@@ -814,8 +832,8 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> {
814832
'../../logic/ProjectWatcher'
815833
);
816834

817-
const abortController: AbortController = new AbortController();
818-
const abortSignal: AbortSignal = abortController.signal;
835+
const sessionAbortController: AbortController = this.sessionAbortController;
836+
const abortSignal: AbortSignal = sessionAbortController.signal;
819837

820838
const projectWatcher: typeof ProjectWatcher.prototype = new ProjectWatcher({
821839
getInputsSnapshotAsync,
@@ -829,7 +847,7 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> {
829847

830848
// Ensure process.stdin allows interactivity before using TTY-only APIs
831849
if (process.stdin.isTTY) {
832-
this._registerWatchModeInterface(projectWatcher, abortController);
850+
this._registerWatchModeInterface(projectWatcher);
833851
}
834852

835853
const onWaitingForChanges = (): void => {
@@ -877,9 +895,13 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> {
877895
terminal.writeLine(` ${Colorize.cyan(name)}`);
878896
}
879897

898+
const initialAbortController: AbortController = (this._executionAbortController =
899+
new AbortController());
900+
880901
// Account for consumer relationships
881902
const executeOperationsContext: IExecuteOperationsContext = {
882903
...initialCreateOperationsContext,
904+
abortController: initialAbortController,
883905
changedProjectsOnly: !!this._changedProjectsOnly,
884906
isInitial: false,
885907
inputsSnapshot: state,
@@ -893,7 +915,7 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> {
893915
this.hooks.createOperations.promise(new Set(), executeOperationsContext)
894916
);
895917

896-
const executeOptions: IExecutionOperationsOptions = {
918+
const executeOptions: IExecuteOperationsOptions = {
897919
executeOperationsContext,
898920
// For now, don't run pre-build or post-build in watch mode
899921
ignoreHooks: true,
@@ -928,29 +950,36 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> {
928950
/**
929951
* Runs a set of operations and reports the results.
930952
*/
931-
private async _executeOperationsAsync(options: IExecutionOperationsOptions): Promise<void> {
932-
const { executionManagerOptions, ignoreHooks, operations, stopwatch, terminal } = options;
953+
private async _executeOperationsAsync(options: IExecuteOperationsOptions): Promise<void> {
954+
const {
955+
executeOperationsContext,
956+
executionManagerOptions,
957+
ignoreHooks,
958+
operations,
959+
stopwatch,
960+
terminal
961+
} = options;
933962

934963
const executionManager: OperationExecutionManager = new OperationExecutionManager(
935964
operations,
936965
executionManagerOptions
937966
);
938967

939-
const { isInitial, isWatch } = options.executeOperationsContext;
968+
const { isInitial, isWatch, abortController, invalidateOperation } = executeOperationsContext;
940969

941970
let success: boolean = false;
942971
let result: IExecutionResult | undefined;
943972

944973
try {
945974
const definiteResult: IExecutionResult = await measureAsyncFn(
946975
`${PERF_PREFIX}:executeOperationsInner`,
947-
() => executionManager.executeAsync()
976+
() => executionManager.executeAsync(abortController)
948977
);
949978
success = definiteResult.status === OperationStatus.Success;
950979
result = definiteResult;
951980

952981
await measureAsyncFn(`${PERF_PREFIX}:afterExecuteOperations`, () =>
953-
this.hooks.afterExecuteOperations.promise(definiteResult, options.executeOperationsContext)
982+
this.hooks.afterExecuteOperations.promise(definiteResult, executeOperationsContext)
954983
);
955984

956985
stopwatch.stop();
@@ -980,6 +1009,20 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> {
9801009
}
9811010
}
9821011

1012+
this._executionAbortController = undefined;
1013+
1014+
if (invalidateOperation) {
1015+
const operationResults: ReadonlyMap<Operation, IOperationExecutionResult> | undefined =
1016+
result?.operationResults;
1017+
if (operationResults) {
1018+
for (const [operation, { status }] of operationResults) {
1019+
if (status === OperationStatus.Aborted) {
1020+
invalidateOperation(operation, 'aborted');
1021+
}
1022+
}
1023+
}
1024+
}
1025+
9831026
if (!ignoreHooks) {
9841027
measureFn(`${PERF_PREFIX}:doAfterTask`, () => this._doAfterTask());
9851028
}

libraries/rush-lib/src/logic/ProjectWatcher.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,13 @@ export class ProjectWatcher {
156156
* `waitForChange` is not allowed to be called multiple times concurrently.
157157
*/
158158
public async waitForChangeAsync(onWatchingFiles?: () => void): Promise<IProjectChangeResult> {
159+
if (this.isPaused) {
160+
this._setStatus(`Project watcher paused.`);
161+
await new Promise<void>((resolve) => {
162+
this._resolveIfChanged = async () => resolve();
163+
});
164+
}
165+
159166
const initialChangeResult: IProjectChangeResult = await this._computeChangedAsync();
160167
// Ensure that the new state is recorded so that we don't loop infinitely
161168
this._commitChanges(initialChangeResult.inputsSnapshot);

libraries/rush-lib/src/logic/operations/AsyncOperationQueue.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@ export class AsyncOperationQueue
104104
record.status === OperationStatus.SuccessWithWarning ||
105105
record.status === OperationStatus.FromCache ||
106106
record.status === OperationStatus.NoOp ||
107-
record.status === OperationStatus.Failure
107+
record.status === OperationStatus.Failure ||
108+
record.status === OperationStatus.Aborted
108109
) {
109110
// It shouldn't be on the queue, remove it
110111
queue.splice(i, 1);

libraries/rush-lib/src/logic/operations/ConsoleTimelinePlugin.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ const TIMELINE_CHART_SYMBOLS: Record<OperationStatus, string> = {
8686
[OperationStatus.Blocked]: '.',
8787
[OperationStatus.Skipped]: '%',
8888
[OperationStatus.FromCache]: '%',
89-
[OperationStatus.NoOp]: '%'
89+
[OperationStatus.NoOp]: '%',
90+
[OperationStatus.Aborted]: '@'
9091
};
9192

9293
const COBUILD_REPORTABLE_STATUSES: Set<OperationStatus> = new Set([
@@ -110,7 +111,8 @@ const TIMELINE_CHART_COLORIZER: Record<OperationStatus, (string: string) => stri
110111
[OperationStatus.Blocked]: Colorize.red,
111112
[OperationStatus.Skipped]: Colorize.green,
112113
[OperationStatus.FromCache]: Colorize.green,
113-
[OperationStatus.NoOp]: Colorize.gray
114+
[OperationStatus.NoOp]: Colorize.gray,
115+
[OperationStatus.Aborted]: Colorize.red
114116
};
115117

116118
interface ITimelineRecord {

libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export class OperationExecutionManager {
9090
// Variables for current status
9191
private _hasAnyFailures: boolean;
9292
private _hasAnyNonAllowedWarnings: boolean;
93+
private _hasAnyAborted: boolean;
9394
private _completedOperations: number;
9495
private _executionQueue: AsyncOperationQueue;
9596

@@ -109,6 +110,7 @@ export class OperationExecutionManager {
109110
this._quietMode = quietMode;
110111
this._hasAnyFailures = false;
111112
this._hasAnyNonAllowedWarnings = false;
113+
this._hasAnyAborted = false;
112114
this._parallelism = parallelism;
113115

114116
this._beforeExecuteOperation = beforeExecuteOperation;
@@ -232,9 +234,10 @@ export class OperationExecutionManager {
232234
* Executes all operations which have been registered, returning a promise which is resolved when all the
233235
* operations are completed successfully, or rejects when any operation fails.
234236
*/
235-
public async executeAsync(): Promise<IExecutionResult> {
237+
public async executeAsync(abortController: AbortController): Promise<IExecutionResult> {
236238
this._completedOperations = 0;
237239
const totalOperations: number = this._totalOperations;
240+
const abortSignal: AbortSignal = abortController.signal;
238241

239242
if (!this._quietMode) {
240243
const plural: string = totalOperations === 1 ? '' : 's';
@@ -287,10 +290,18 @@ export class OperationExecutionManager {
287290
await Async.forEachAsync(
288291
this._executionQueue,
289292
async (record: OperationExecutionRecord) => {
290-
await record.executeAsync({
291-
onStart: onOperationStartAsync,
292-
onResult: onOperationCompleteAsync
293-
});
293+
if (abortSignal.aborted) {
294+
record.status = OperationStatus.Aborted;
295+
// Bypass the normal completion handler, directly mark the operation as aborted and unblock the queue.
296+
// We do this to ensure that we aren't messing with the stopwatch or terminal.
297+
this._hasAnyAborted = true;
298+
this._executionQueue.complete(record);
299+
} else {
300+
await record.executeAsync({
301+
onStart: onOperationStartAsync,
302+
onResult: onOperationCompleteAsync
303+
});
304+
}
294305
},
295306
{
296307
concurrency: maxParallelism,
@@ -300,9 +311,11 @@ export class OperationExecutionManager {
300311

301312
const status: OperationStatus = this._hasAnyFailures
302313
? OperationStatus.Failure
303-
: this._hasAnyNonAllowedWarnings
304-
? OperationStatus.SuccessWithWarning
305-
: OperationStatus.Success;
314+
: this._hasAnyAborted
315+
? OperationStatus.Aborted
316+
: this._hasAnyNonAllowedWarnings
317+
? OperationStatus.SuccessWithWarning
318+
: OperationStatus.Success;
306319

307320
return {
308321
operationResults: this._executionRecords,
@@ -437,6 +450,11 @@ export class OperationExecutionManager {
437450
break;
438451
}
439452

453+
case OperationStatus.Aborted: {
454+
this._hasAnyAborted ||= true;
455+
break;
456+
}
457+
440458
default: {
441459
throw new InternalError(`Unexpected operation status: ${status}`);
442460
}

0 commit comments

Comments
 (0)