UNPKG

@rushstack/operation-graph

Version:

Library for managing and executing operations in a directed acyclic graph.

210 lines 7.91 kB
// 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