@rushstack/operation-graph
Version:
Library for managing and executing operations in a directed acyclic graph.
210 lines • 7.91 kB
JavaScript
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
import { once } from 'node:events';
import { AlreadyReportedError } from '@rushstack/node-core-library';
import { OperationStatus } from './OperationStatus';
/**
* This class implements a watch loop.
*
* @beta
*/
export class WatchLoop {
constructor(options) {
/**
* Requests that a new run occur.
*/
this.requestRun = (requestor, detail) => {
if (!this._runRequested) {
this._options.onRequestRun(requestor, detail);
this._runRequested = true;
if (this._isRunning) {
this._options.onAbort();
this._abortCurrent();
}
}
this._resolveRequestRun([requestor, detail]);
};
/**
* Cancels the current iteration (if possible).
*/
this._abortCurrent = () => {
this._abortController.abort();
};
this._options = options;
this._abortController = new AbortController();
this._isRunning = false;
// Always start as true, so that any requests prior to first run are silenced.
this._runRequested = true;
this._requestRunPromise = new Promise((resolve) => {
this._resolveRequestRun = resolve;
});
}
/**
* Runs the inner loop until the abort signal is cancelled or a run completes without a new run being requested.
*/
async runUntilStableAsync(abortSignal) {
if (abortSignal.aborted) {
return OperationStatus.Aborted;
}
abortSignal.addEventListener('abort', this._abortCurrent, { once: true });
try {
let result = OperationStatus.Ready;
do {
// Always check the abort signal first, in case it was aborted in the async tick since the last executeAsync() call.
if (abortSignal.aborted) {
return OperationStatus.Aborted;
}
result = await this._runIterationAsync();
} while (this._runRequested);
// Even if the run has finished, if the abort signal was aborted, we should return `Aborted` just in case.
return abortSignal.aborted ? OperationStatus.Aborted : result;
}
finally {
abortSignal.removeEventListener('abort', this._abortCurrent);
}
}
/**
* Runs the inner loop until the abort signal is aborted. Will otherwise wait indefinitely for a new run to be requested.
*/
async runUntilAbortedAsync(abortSignal, onWaiting) {
if (abortSignal.aborted) {
return;
}
const abortPromise = once(abortSignal, 'abort');
while (!abortSignal.aborted) {
await this.runUntilStableAsync(abortSignal);
onWaiting();
await Promise.race([this._requestRunPromise, abortPromise]);
}
}
/**
* Sets up an IPC handler that will run the inner loop when it receives a "run" message from the host.
* Runs until receiving an "exit" message from the host, or aborts early if an unhandled error is thrown.
*/
async runIPCAsync(host = process) {
await new Promise((resolve, reject) => {
let abortController = new AbortController();
let runRequestedFromHost = true;
let status = OperationStatus.Ready;
function tryMessageHost(message) {
if (!host.send) {
return reject(new Error('Host does not support IPC'));
}
try {
host.send(message);
}
catch (err) {
reject(new Error(`Unable to communicate with host: ${err}`));
}
}
function requestRunFromHost(requestor, detail) {
if (runRequestedFromHost) {
return;
}
runRequestedFromHost = true;
const requestRunMessage = {
event: 'requestRun',
requestor,
detail
};
tryMessageHost(requestRunMessage);
}
function sendSync() {
const syncMessage = {
event: 'sync',
status
};
tryMessageHost(syncMessage);
}
host.on('message', async (message) => {
switch (message.command) {
case 'exit': {
return resolve();
}
case 'cancel': {
if (this._isRunning) {
abortController.abort();
abortController = new AbortController();
// This will terminate the currently executing `runUntilStableAsync` call.
}
return;
}
case 'run': {
runRequestedFromHost = false;
status = OperationStatus.Executing;
try {
status = await this.runUntilStableAsync(abortController.signal);
// ESLINT: "Promises must be awaited, end with a call to .catch, end with a call to .then ..."
this._requestRunPromise.then(([requestor, detail]) => requestRunFromHost(requestor, detail), (error) => {
// Unreachable code. The promise will never be rejected.
});
}
catch (err) {
status = OperationStatus.Failure;
return reject(err);
}
finally {
const afterExecuteMessage = {
event: 'after-execute',
status
};
tryMessageHost(afterExecuteMessage);
}
return;
}
case 'sync': {
return sendSync();
}
default: {
return reject(new Error(`Unexpected command from host: ${message}`));
}
}
});
sendSync();
});
}
/**
* The abort signal for the current iteration.
*/
get abortSignal() {
return this._abortController.signal;
}
/**
* Resets the abort signal and run request state.
*/
_reset() {
if (this._abortController.signal.aborted) {
this._abortController = new AbortController();
}
if (this._runRequested) {
this._runRequested = false;
this._requestRunPromise = new Promise((resolve) => {
this._resolveRequestRun = resolve;
});
}
}
/**
* Runs a single iteration of the loop.
* @returns The status of the iteration.
*/
async _runIterationAsync() {
this._reset();
this._options.onBeforeExecute();
try {
this._isRunning = true;
return await this._options.executeAsync(this);
}
catch (err) {
if (!(err instanceof AlreadyReportedError)) {
throw err;
}
else {
return OperationStatus.Failure;
}
}
finally {
this._isRunning = false;
}
}
}
//# sourceMappingURL=WatchLoop.js.map