diff --git a/common/changes/@microsoft/rush/graceful-exit_2025-04-16-19-31.json b/common/changes/@microsoft/rush/graceful-exit_2025-04-16-19-31.json new file mode 100644 index 00000000000..3b94781ed66 --- /dev/null +++ b/common/changes/@microsoft/rush/graceful-exit_2025-04-16-19-31.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Bind \"q\" to gracefully exit the watcher.", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index 6a893a6b062..4d09afbba02 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -581,6 +581,7 @@ export class PhasedScriptAction extends BaseScriptAction { } await this._runWatchPhasesAsync(internalOptions); + terminal.writeDebugLine(`Watch mode exited.`); } } finally { await cobuildConfiguration?.destroyLockProviderAsync(); @@ -653,16 +654,21 @@ export class PhasedScriptAction extends BaseScriptAction { }; } - private _registerWatchModeInterface(projectWatcher: ProjectWatcher): void { + private _registerWatchModeInterface( + projectWatcher: ProjectWatcher, + abortController: AbortController + ): void { const toggleWatcherKey: 'w' = 'w'; const buildOnceKey: 'b' = 'b'; const invalidateKey: 'i' = 'i'; - const shutdownKey: 'x' = 'x'; + const shutdownProcessesKey: 'x' = 'x'; + const quitKey: 'q' = 'q'; const terminal: ITerminal = this._terminal; projectWatcher.setPromptGenerator((isPaused: boolean) => { const promptLines: string[] = [ + ` Press <${quitKey}> to gracefully exit.`, ` Press <${toggleWatcherKey}> to ${isPaused ? 'resume' : 'pause'}.`, ` Press <${invalidateKey}> to invalidate all projects.` ]; @@ -670,16 +676,20 @@ export class PhasedScriptAction extends BaseScriptAction { promptLines.push(` Press <${buildOnceKey}> to build once.`); } if (this._noIPCParameter?.value === false) { - promptLines.push(` Press <${shutdownKey}> to reset child processes.`); + promptLines.push(` Press <${shutdownProcessesKey}> to reset child processes.`); } return promptLines; }); - process.stdin.setRawMode(true); - process.stdin.resume(); - process.stdin.setEncoding('utf8'); - process.stdin.on('data', (key: string) => { + const onKeyPress = (key: string): void => { switch (key) { + case quitKey: + terminal.writeLine(`Exiting watch mode...`); + process.stdin.setRawMode(false); + process.stdin.off('data', onKeyPress); + process.stdin.unref(); + abortController.abort(); + break; case toggleWatcherKey: if (projectWatcher.isPaused) { projectWatcher.resume(); @@ -703,17 +713,25 @@ export class PhasedScriptAction extends BaseScriptAction { projectWatcher.resume(); } break; - case shutdownKey: + case shutdownProcessesKey: projectWatcher.clearStatus(); terminal.writeLine(`Shutting down long-lived child processes...`); // TODO: Inject this promise into the execution queue somewhere so that it gets waited on between runs void this.hooks.shutdownAsync.promise(); break; case '\u0003': + process.stdin.setRawMode(false); + process.stdin.off('data', onKeyPress); + process.stdin.unref(); process.kill(process.pid, 'SIGINT'); break; } - }); + }; + + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.setEncoding('utf8'); + process.stdin.on('data', onKeyPress); } /** @@ -751,18 +769,22 @@ export class PhasedScriptAction extends BaseScriptAction { '../../logic/ProjectWatcher' ); + const abortController: AbortController = new AbortController(); + const abortSignal: AbortSignal = abortController.signal; + const projectWatcher: typeof ProjectWatcher.prototype = new ProjectWatcher({ getInputsSnapshotAsync, initialSnapshot, debounceMs: this._watchDebounceMs, rushConfiguration: this.rushConfiguration, projectsToWatch, + abortSignal, terminal }); // Ensure process.stdin allows interactivity before using TTY-only APIs if (process.stdin.isTTY) { - this._registerWatchModeInterface(projectWatcher); + this._registerWatchModeInterface(projectWatcher, abortController); } const onWaitingForChanges = (): void => { @@ -786,11 +808,15 @@ export class PhasedScriptAction extends BaseScriptAction { // Loop until Ctrl+C // eslint-disable-next-line no-constant-condition - while (true) { + while (!abortSignal.aborted) { // On the initial invocation, this promise will return immediately with the full set of projects const { changedProjects, inputsSnapshot: state } = await projectWatcher.waitForChangeAsync(onWaitingForChanges); + if (abortSignal.aborted) { + return; + } + if (stopwatch.state === StopwatchState.Stopped) { // Clear and reset the stopwatch so that we only report time from a single execution at a time stopwatch.reset(); diff --git a/libraries/rush-lib/src/logic/ProjectWatcher.ts b/libraries/rush-lib/src/logic/ProjectWatcher.ts index 83dd92b904c..af588838404 100644 --- a/libraries/rush-lib/src/logic/ProjectWatcher.ts +++ b/libraries/rush-lib/src/logic/ProjectWatcher.ts @@ -15,6 +15,7 @@ import type { RushConfiguration } from '../api/RushConfiguration'; import type { RushConfigurationProject } from '../api/RushConfigurationProject'; export interface IProjectWatcherOptions { + abortSignal: AbortSignal; getInputsSnapshotAsync: GetInputsSnapshotAsyncFn; debounceMs?: number; rushConfiguration: RushConfiguration; @@ -53,6 +54,7 @@ interface IPathWatchOptions { * more projects differ from the value the previous time it was invoked. The first time will always resolve with the full selection. */ export class ProjectWatcher { + private readonly _abortSignal: AbortSignal; private readonly _getInputsSnapshotAsync: GetInputsSnapshotAsyncFn; private readonly _debounceMs: number; private readonly _repoRoot: string; @@ -64,6 +66,7 @@ export class ProjectWatcher { private _previousSnapshot: IInputsSnapshot | undefined; private _forceChangedProjects: Map = new Map(); private _resolveIfChanged: undefined | (() => Promise); + private _onAbort: undefined | (() => void); private _getPromptLines: undefined | IPromptGeneratorFunction; private _renderedStatusLines: number; @@ -72,6 +75,7 @@ export class ProjectWatcher { public constructor(options: IProjectWatcherOptions) { const { + abortSignal, getInputsSnapshotAsync: snapshotProvider, debounceMs = 1000, rushConfiguration, @@ -80,6 +84,10 @@ export class ProjectWatcher { initialSnapshot: initialState } = options; + this._abortSignal = abortSignal; + abortSignal.addEventListener('abort', () => { + this._onAbort?.(); + }); this._debounceMs = debounceMs; this._rushConfiguration = rushConfiguration; this._projectsToWatch = projectsToWatch; @@ -87,6 +95,8 @@ export class ProjectWatcher { const gitPath: string = new Git(rushConfiguration).getGitPathOrThrow(); this._repoRoot = Path.convertToSlashes(getRepoRoot(rushConfiguration.rushJsonFolder, gitPath)); + this._resolveIfChanged = undefined; + this._onAbort = undefined; this._initialSnapshot = initialState; this._previousSnapshot = initialState; @@ -191,7 +201,12 @@ export class ProjectWatcher { } } + if (this._abortSignal.aborted) { + return initialChangeResult; + } + const watchers: Map = new Map(); + const closePromises: Promise[] = []; const watchedResult: IProjectChangeResult = await new Promise( (resolve: (result: IProjectChangeResult) => void, reject: (err: Error) => void) => { @@ -202,8 +217,22 @@ export class ProjectWatcher { const debounceMs: number = this._debounceMs; + const abortSignal: AbortSignal = this._abortSignal; + this.clearStatus(); + this._onAbort = function onAbort(): void { + if (timeout) { + clearTimeout(timeout); + } + terminated = true; + resolve(initialChangeResult); + }; + + if (abortSignal.aborted) { + return this._onAbort(); + } + const resolveIfChanged: () => Promise = (this._resolveIfChanged = async (): Promise => { timeout = undefined; if (terminated) { @@ -296,15 +325,23 @@ export class ProjectWatcher { watchedPath, { encoding: 'utf-8', - recursive: recursive && useNativeRecursiveWatch + recursive: recursive && useNativeRecursiveWatch, + signal: abortSignal }, listener ); watchers.set(watchedPath, watcher); - watcher.on('error', (err) => { - watchers.delete(watchedPath); + watcher.once('error', (err) => { + watcher.close(); onError(err); }); + closePromises.push( + once(watcher, 'close').then(() => { + watchers.delete(watchedPath); + watcher.removeAllListeners(); + watcher.unref(); + }) + ); } function innerListener( @@ -362,20 +399,18 @@ export class ProjectWatcher { } } ).finally(() => { + this._onAbort = undefined; this._resolveIfChanged = undefined; }); - const closePromises: Promise[] = []; - for (const [watchedPath, watcher] of watchers) { - closePromises.push( - once(watcher, 'close').then(() => { - watchers.delete(watchedPath); - }) - ); + this._terminal.writeDebugLine(`Closing watchers...`); + + for (const watcher of watchers.values()) { watcher.close(); } await Promise.all(closePromises); + this._terminal.writeDebugLine(`Closed ${closePromises.length} watchers`); return watchedResult; }