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
10 changes: 10 additions & 0 deletions common/changes/@microsoft/rush/graceful-exit_2025-04-16-19-31.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@microsoft/rush",
"comment": "Bind \"q\" to gracefully exit the watcher.",
"type": "none"
}
],
"packageName": "@microsoft/rush"
}
48 changes: 37 additions & 11 deletions libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,7 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> {
}

await this._runWatchPhasesAsync(internalOptions);
terminal.writeDebugLine(`Watch mode exited.`);
}
} finally {
await cobuildConfiguration?.destroyLockProviderAsync();
Expand Down Expand Up @@ -653,33 +654,42 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> {
};
}

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.`
];
if (isPaused) {
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();
Expand All @@ -703,17 +713,25 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> {
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);
}

/**
Expand Down Expand Up @@ -751,18 +769,22 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> {
'../../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 => {
Expand All @@ -786,11 +808,15 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> {

// 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();
Expand Down
55 changes: 45 additions & 10 deletions libraries/rush-lib/src/logic/ProjectWatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -64,6 +66,7 @@ export class ProjectWatcher {
private _previousSnapshot: IInputsSnapshot | undefined;
private _forceChangedProjects: Map<RushConfigurationProject, string> = new Map();
private _resolveIfChanged: undefined | (() => Promise<void>);
private _onAbort: undefined | (() => void);
private _getPromptLines: undefined | IPromptGeneratorFunction;

private _renderedStatusLines: number;
Expand All @@ -72,6 +75,7 @@ export class ProjectWatcher {

public constructor(options: IProjectWatcherOptions) {
const {
abortSignal,
getInputsSnapshotAsync: snapshotProvider,
debounceMs = 1000,
rushConfiguration,
Expand All @@ -80,13 +84,19 @@ export class ProjectWatcher {
initialSnapshot: initialState
} = options;

this._abortSignal = abortSignal;
abortSignal.addEventListener('abort', () => {
this._onAbort?.();
});
this._debounceMs = debounceMs;
this._rushConfiguration = rushConfiguration;
this._projectsToWatch = projectsToWatch;
this._terminal = terminal;

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;
Expand Down Expand Up @@ -191,7 +201,12 @@ export class ProjectWatcher {
}
}

if (this._abortSignal.aborted) {
return initialChangeResult;
}

const watchers: Map<string, fs.FSWatcher> = new Map();
const closePromises: Promise<void>[] = [];

const watchedResult: IProjectChangeResult = await new Promise(
(resolve: (result: IProjectChangeResult) => void, reject: (err: Error) => void) => {
Expand All @@ -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<void> = (this._resolveIfChanged = async (): Promise<void> => {
timeout = undefined;
if (terminated) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -362,20 +399,18 @@ export class ProjectWatcher {
}
}
).finally(() => {
this._onAbort = undefined;
this._resolveIfChanged = undefined;
});

const closePromises: Promise<void>[] = [];
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;
}
Expand Down
Loading