@eclipse-emfcloud/model-service-theia
Version:
Model service Theia
230 lines (204 loc) • 7.17 kB
text/typescript
// *****************************************************************************
// Copyright (C) 2024 STMicroelectronics.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: MIT License which is
// available at https://opensource.org/licenses/MIT.
//
// SPDX-License-Identifier: EPL-2.0 OR MIT
// *****************************************************************************
import { CancellationError, Emitter } from '@theia/core';
import { wait, waitForEvent } from '@theia/core/lib/common/promise-util';
const DEFAULT_TIMEOUT = 30_000 * 4;
const stateProp = Symbol('state');
/** Protocol of the optional call-back function in the promise {@link timeout()} API. */
export type TimeoutHandler<T> = (
reason: 'timeout' | T | Error
) => string | undefined;
type TimeoutPending<T> = {
state: 'pending';
promise: Promise<T>;
timeoutMillis: number;
callback?: TimeoutHandler<T>;
};
type TimedOut = { state: 'timeout' };
type Failed = { state: 'failed'; reason: Error };
type Completed<T> = { state: 'completed'; result: T };
type TimeoutPromiseState<T> =
| TimeoutPending<T>
| TimedOut
| Failed
| Completed<T>;
type TimeoutPromise<T> = Promise<T> & { [stateProp]: TimeoutPromiseState<T> };
type PendingPromise<T> = Promise<T> & { [stateProp]: TimeoutPending<T> };
class TimeoutError extends Error {
constructor(message?: string) {
super(message ?? 'Promise timed out.');
}
}
/**
* Wrap a promise in a timeout that will reject if the promised value
* is not provided within a given number of milliseconds.
*
* @param promise the promise to wrap
* @param an optional timeout, in milliseconds. If not specified, the default is 30 seconds
* @param callback an optional call-back to be invoked on completion of the wrapped provider
* either by success, failure, or timeout. If it returns a string, it will be used for the
* message of the returned promise's rejection error (if applicable)
*/
export function timeout<T>(
promise: Promise<T>,
timeout?: number,
callback?: TimeoutHandler<T>
): Promise<T> {
const provided = new Emitter<T | Error>();
const timeoutMillis = timeout ?? DEFAULT_TIMEOUT;
if (timeoutMillis <= 0) {
// Already timed out
return Promise.reject(new TimeoutError(callback?.('timeout')));
}
const result: PendingPromise<T> = setState(
waitForEvent(provided.event, timeoutMillis)
.then(
/* completed on time or failed */ (outcome) => {
invokeCallback(
result,
// whether failing by error or completing normally, it didn't time out
outcome
);
if (outcome instanceof Error) {
throw outcome;
}
setState(result, { state: 'completed', result: outcome });
return outcome;
}
)
.catch((error) => {
/* timed out or failed */
if (!(error instanceof CancellationError)) {
// Initialization failed
setState(result, { state: 'failed', reason: error });
throw error;
}
// Timed out
const message = invokeCallback(result, 'timeout');
setState(result, { state: 'timeout' });
throw new TimeoutError(message);
})
.finally(() => provided.dispose()),
<TimeoutPending<T>>{
state: 'pending',
promise,
timeoutMillis,
callback,
}
);
// Attempt to await the provided value
promise
.then((result) => provided.fire(result))
.catch((error) => provided.fire(error));
return result;
}
function isTimeoutPromise<T>(
promise: Promise<T>
): promise is TimeoutPromise<T> {
return stateProp in promise;
}
export async function retryUntilFulfilled<T>(fn: () => Promise<T>): Promise<T> {
const timeoutPromise = wrap(fn());
if (timeoutPromise[stateProp].state !== 'pending') {
// If it has already completed, there's nothing to retry
return timeoutPromise;
}
const { promise, timeoutMillis, callback } = timeoutPromise[stateProp];
// Don't let the then/catch clauses attached by the timeout() function
// invoke the call-back because we'll do it, ourselves
timeoutPromise[stateProp].callback = undefined;
const retryDelay = retryInterval(timeoutMillis);
let nonTimeoutFailure: Error | undefined;
let remainingTimeout = timeoutMillis;
let nextTry = promise;
for (;;) {
try {
const result = await timeout(nextTry, remainingTimeout);
callback?.(result);
return result;
} catch (error) {
if (error instanceof TimeoutError) {
// Timed out. Give up
if (nonTimeoutFailure !== undefined) {
callback?.(nonTimeoutFailure);
throw nonTimeoutFailure;
} else {
throw new TimeoutError(callback?.('timeout'));
}
}
nonTimeoutFailure = error;
// Try again
remainingTimeout -= retryDelay;
await wait(retryDelay);
nextTry = unwrap(fn());
}
}
}
// re-try three times per second-or-less
function retryInterval(timeoutMillis: number): number {
return timeoutMillis > 1000 ? 300 : timeoutMillis / 3.5;
}
/** Wrap a promise as a timeout promise if it isn't already one. */
function wrap<T>(promise: Promise<T>): TimeoutPromise<T> {
return isTimeoutPromise(promise)
? promise
: (timeout(promise) as TimeoutPromise<T>);
}
/** Unwrap a timeout promise to retrieve the underlying promise. */
function unwrap<T>(promise: Promise<T>): Promise<T> {
if (!isTimeoutPromise(promise)) {
return promise;
}
const state = promise[stateProp];
switch (state.state) {
case 'pending':
// Don't let the then/catch clauses attached by the timeout() function
// invoke the call-back because we'll do it, ourselves
state.callback = undefined;
return state.promise;
case 'failed':
return Promise.reject(state.reason);
case 'completed':
return Promise.resolve(state.result);
default:
return Promise.reject(new TimeoutError());
}
}
/**
* Invoke the call-function attached to a timeout `promise`, if it has one.
*
* @param promise the timeout promise on which to invoke the call-back
* @param args the arguments to the call-back function
* @returns the return result of the call-back, if there is one
*/
function invokeCallback<T>(
promise: PendingPromise<T>,
...args: Parameters<TimeoutHandler<T>>
): ReturnType<TimeoutHandler<T>> {
return promise[stateProp].callback?.(...args);
}
/**
* Update the state of a timeout-promise.
*
* @param promise the promise on which to update the timeout state
* @param state the state to set
* @return the updated `promise`
*/
function setState<T, S extends TimeoutPromiseState<T>>(
promise: Promise<T>,
state: S
): Promise<T> & { [stateProp]: typeof state } {
return Object.assign(promise, { [stateProp]: state });
}