/*! * Copyright (c) Microsoft Corporation and contributors. All rights reserved. * Licensed under the MIT License. */ import { assert } from "./assert.js"; import { Deferred } from "./promises.js"; /** * @internal */ export interface ITimer { /** * True if timer is currently running */ readonly hasTimer: boolean; /** * Starts the timer */ start(): void; /** * Cancels the timer if already running */ clear(): void; } interface ITimeout { /** * Tick that timeout was started. */ startTick: number; /** * Timeout duration in ms. */ duration: number; /** * Handler to execute when timeout ends. */ handler: () => void; } interface IRunningTimerState extends ITimeout { /** * JavaScript Timeout object. */ timeout: ReturnType; /** * Intended duration in ms. */ intendedDuration: number; /** * Intended restart timeout. */ restart?: ITimeout | undefined; } const maxSetTimeoutMs = 0x7fffffff; // setTimeout limit is MAX_INT32=(2^31-1). /** * Sets timeouts like the setTimeout function allowing timeouts to exceed the setTimeout's max timeout limit. * Timeouts may not be exactly accurate due to browser implementations and the OS. * https://stackoverflow.com/questions/21097421/what-is-the-reason-javascript-settimeout-is-so-inaccurate * @param timeoutFn - Executed when the timeout expires * @param timeoutMs - Duration of the timeout in milliseconds * @param setTimeoutIdFn - Executed to update the timeout if multiple timeouts are required when * timeoutMs greater than maxTimeout * @returns The initial timeout * @internal */ export function setLongTimeout( timeoutFn: () => void, timeoutMs: number, setTimeoutIdFn?: (timeoutId: ReturnType) => void, ): ReturnType { // The setTimeout max is 24.8 days before looping occurs. let timeoutId: ReturnType; if (timeoutMs > maxSetTimeoutMs) { const newTimeoutMs = timeoutMs - maxSetTimeoutMs; timeoutId = setTimeout( () => setLongTimeout(timeoutFn, newTimeoutMs, setTimeoutIdFn), maxSetTimeoutMs, ); } else { timeoutId = setTimeout(() => timeoutFn(), Math.max(timeoutMs, 0)); } setTimeoutIdFn?.(timeoutId); return timeoutId; } /** * This class is a thin wrapper over setTimeout and clearTimeout which * makes it simpler to keep track of recurring timeouts with the same * or similar handlers and timeouts. This class supports long timeouts * or timeouts exceeding (2^31)-1 ms or approximately 24.8 days. * @internal */ export class Timer implements ITimer { /** * Returns true if the timer is running. */ public get hasTimer(): boolean { return !!this.runningState; } private runningState: IRunningTimerState | undefined; private readonly getCurrentTick: () => number = (): number => Date.now(); public constructor( private readonly defaultTimeout: number, private readonly defaultHandler: () => void, private readonly exceptionHandler?: (error: unknown) => void, ) {} /** * Calls setTimeout and tracks the resulting timeout. * @param ms - overrides default timeout in ms * @param handler - overrides default handler */ public start( ms: number = this.defaultTimeout, handler: () => void = this.defaultHandler, ): void { this.startCore(ms, handler, ms); } /** * Calls clearTimeout on the underlying timeout if running. */ public clear(): void { if (!this.runningState) { return; } clearTimeout(this.runningState.timeout); this.runningState = undefined; } /** * Restarts the timer with the new handler and duration. * If a new handler is passed, the original handler may * never execute. * This is a potentially more efficient way to clear and start * a new timer. * @param ms - overrides previous or default timeout in ms * @param handler - overrides previous or default handler */ public restart(ms?: number, handler?: () => void): void { if (this.runningState) { const duration = ms ?? this.runningState.intendedDuration; const handlerToUse = handler ?? this.runningState.restart?.handler ?? this.runningState.handler; const remainingTime = this.calculateRemainingTime(this.runningState); if (duration < remainingTime) { // If remaining time exceeds restart duration, do a hard restart. // The existing timeout time is too long. this.start(duration, handlerToUse); } else if (duration === remainingTime) { // The existing timeout time is perfect, just update handler and data. this.runningState.handler = handlerToUse; this.runningState.restart = undefined; this.runningState.intendedDuration = duration; } else { // If restart duration exceeds remaining time, set restart info. // Existing timeout will start a new timeout for remaining time. this.runningState.restart = { startTick: this.getCurrentTick(), duration, handler: handlerToUse, }; } } else { // If restart is called first, it behaves as a call to start this.start(ms, handler); } } private startCore(duration: number, handler: () => void, intendedDuration: number): void { this.clear(); this.runningState = { startTick: this.getCurrentTick(), duration, intendedDuration, handler, timeout: setLongTimeout( () => this.handler(), duration, (timer: number) => { if (this.runningState !== undefined) { this.runningState.timeout = timer; } }, ), }; } private handler(): void { assert(!!this.runningState, 0x764 /* Running timer missing handler */); const restart = this.runningState.restart; if (restart === undefined) { // Run clear first, in case the handler decides to start again const handler = this.runningState.handler; this.clear(); try { handler(); } catch (error) { if (this.exceptionHandler) { this.exceptionHandler(error); } else { // This will be unhandled exception, but it's better to have unhandled exception than swallow it. // Applications might have telemetry to report unhandled exceptions, letting us know where we are missing // exception handlers. throw error; } } } else { // Restart with remaining time const remainingTime = this.calculateRemainingTime(restart); this.startCore(remainingTime, () => restart.handler(), restart.duration); } } private calculateRemainingTime(runningTimeout: ITimeout): number { const elapsedTime = this.getCurrentTick() - runningTimeout.startTick; return runningTimeout.duration - elapsedTime; } } /** * @internal */ export interface IPromiseTimerResult { timerResult: "timeout" | "cancel"; } /** * Timer which offers a promise that fulfills when the timer * completes. * @internal */ export interface IPromiseTimer extends ITimer { /** * Starts the timer and returns a promise that * resolves when the timer times out or is canceled. */ // eslint-disable-next-line @typescript-eslint/no-misused-promises start(): Promise; } /** * This class is a wrapper over setTimeout and clearTimeout which * makes it simpler to keep track of recurring timeouts with the * same handlers and timeouts, while also providing a promise that * resolves when it times out. * @internal */ export class PromiseTimer implements IPromiseTimer { private deferred: Deferred | undefined; private readonly timer: Timer; public get hasTimer(): boolean { return this.timer.hasTimer; } public constructor(defaultTimeout: number, defaultHandler: () => void) { this.timer = new Timer(defaultTimeout, () => this.wrapHandler(defaultHandler)); } public async start(ms?: number, handler?: () => void): Promise { this.clear(); this.deferred = new Deferred(); this.timer.start(ms, handler ? (): void => this.wrapHandler(handler) : undefined); return this.deferred.promise; } public clear(): void { this.timer.clear(); if (this.deferred) { this.deferred.resolve({ timerResult: "cancel" }); this.deferred = undefined; } } protected wrapHandler(handler: () => void): void { handler(); assert(!!this.deferred, 0x765 /* Handler executed without deferred */); this.deferred.resolve({ timerResult: "timeout" }); this.deferred = undefined; } }