Refactor build expiration checks
This commit is contained in:
parent
b0634f9a9d
commit
9a4972d59e
7 changed files with 323 additions and 83 deletions
|
@ -204,6 +204,7 @@ import { getParametersForRedux, loadAll } from './services/allLoaders';
|
|||
import { checkFirstEnvelope } from './util/checkFirstEnvelope';
|
||||
import { BLOCKED_UUIDS_ID } from './textsecure/storage/Blocked';
|
||||
import { ReleaseNotesFetcher } from './services/releaseNotesFetcher';
|
||||
import { BuildExpirationService } from './services/buildExpiration';
|
||||
import {
|
||||
maybeQueueDeviceNameFetch,
|
||||
onDeviceNameChangeSync,
|
||||
|
@ -522,11 +523,18 @@ export async function startApp(): Promise<void> {
|
|||
|
||||
window.Whisper.events.on('firstEnvelope', checkFirstEnvelope);
|
||||
|
||||
const buildExpirationService = new BuildExpirationService();
|
||||
|
||||
server = window.WebAPI.connect({
|
||||
...window.textsecure.storage.user.getWebAPICredentials(),
|
||||
hasBuildExpired: buildExpirationService.hasBuildExpired(),
|
||||
hasStoriesDisabled: window.storage.get('hasStoriesDisabled', false),
|
||||
});
|
||||
|
||||
buildExpirationService.on('expired', () => {
|
||||
drop(server?.onExpiration('build'));
|
||||
});
|
||||
|
||||
window.textsecure.server = server;
|
||||
window.textsecure.messaging = new window.textsecure.MessageSender(server);
|
||||
|
||||
|
@ -1428,7 +1436,7 @@ export async function startApp(): Promise<void> {
|
|||
|
||||
log.error('background: remote expiration detected, disabling reconnects');
|
||||
drop(window.storage.put('remoteBuildExpiration', Date.now()));
|
||||
drop(server?.onRemoteExpiration());
|
||||
drop(server?.onExpiration('remote'));
|
||||
remotelyExpired = true;
|
||||
});
|
||||
|
||||
|
@ -1717,6 +1725,7 @@ export async function startApp(): Promise<void> {
|
|||
|
||||
if (remotelyExpired) {
|
||||
log.info('afterAuthSocketConnect: remotely expired');
|
||||
drop(onEmpty({ isFromMessageReceiver: false })); // this ensures that the inbox loading progress bar is dismissed
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
89
ts/services/buildExpiration.ts
Normal file
89
ts/services/buildExpiration.ts
Normal file
|
@ -0,0 +1,89 @@
|
|||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import EventEmitter from 'node:events';
|
||||
|
||||
import {
|
||||
hasBuildExpired,
|
||||
getBuildExpirationTimestamp,
|
||||
} from '../util/buildExpiration';
|
||||
import { LongTimeout } from '../util/timeout';
|
||||
import * as log from '../logging/log';
|
||||
|
||||
export class BuildExpirationService extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Let API users subscribe to `expired` event before firing it.
|
||||
queueMicrotask(() => this.#startTimer());
|
||||
}
|
||||
|
||||
hasBuildExpired(): boolean {
|
||||
const autoDownloadUpdate = window.storage.get('auto-download-update', true);
|
||||
|
||||
return hasBuildExpired({
|
||||
buildExpirationTimestamp: this.#getBuildExpirationTimestamp(),
|
||||
autoDownloadUpdate,
|
||||
now: Date.now(),
|
||||
logger: log,
|
||||
});
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
#getBuildExpirationTimestamp(): number {
|
||||
const autoDownloadUpdate = window.storage.get('auto-download-update', true);
|
||||
|
||||
return getBuildExpirationTimestamp({
|
||||
version: window.getVersion(),
|
||||
packagedBuildExpiration: window.getBuildExpiration(),
|
||||
remoteBuildExpiration: window.storage.get('remoteBuildExpiration'),
|
||||
autoDownloadUpdate,
|
||||
logger: log,
|
||||
});
|
||||
}
|
||||
|
||||
#startTimer(): void {
|
||||
const timestamp = this.#getBuildExpirationTimestamp();
|
||||
const now = Date.now();
|
||||
if (timestamp <= now) {
|
||||
if (this.hasBuildExpired()) {
|
||||
log.warn('buildExpirationService: expired');
|
||||
this.emit('expired');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const delayMs = timestamp - now;
|
||||
log.info(`buildExpirationService: expires in ${delayMs}ms`);
|
||||
|
||||
// eslint-disable-next-line no-new
|
||||
new LongTimeout(() => {
|
||||
if (this.hasBuildExpired()) {
|
||||
log.warn('buildExpirationService: expired');
|
||||
this.emit('expired');
|
||||
} else {
|
||||
this.#startTimer();
|
||||
}
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
// EventEmitter types
|
||||
|
||||
public override on(type: 'expired', callback: () => void): this;
|
||||
|
||||
public override on(
|
||||
type: string | symbol,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
listener: (...args: Array<any>) => void
|
||||
): this {
|
||||
return super.on(type, listener);
|
||||
}
|
||||
|
||||
public override emit(type: 'expired'): boolean;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
public override emit(type: string | symbol, ...args: Array<any>): boolean {
|
||||
return super.emit(type, ...args);
|
||||
}
|
||||
}
|
|
@ -3,18 +3,14 @@
|
|||
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { Environment, getEnvironment } from '../../environment';
|
||||
import { isInPast } from '../../util/timestamp';
|
||||
import { DAY } from '../../util/durations';
|
||||
import * as log from '../../logging/log';
|
||||
import type { StateType } from '../reducer';
|
||||
import type { ExpirationStateType } from '../ducks/expiration';
|
||||
import { getRemoteBuildExpiration, getAutoDownloadUpdate } from './items';
|
||||
import { isNotUpdatable } from '../../util/version';
|
||||
|
||||
const NINETY_ONE_DAYS = 91 * DAY;
|
||||
const THIRTY_ONE_DAYS = 31 * DAY;
|
||||
const SIXTY_DAYS = 60 * DAY;
|
||||
import {
|
||||
getBuildExpirationTimestamp,
|
||||
hasBuildExpired,
|
||||
} from '../../util/buildExpiration';
|
||||
|
||||
export const getExpiration = (state: StateType): ExpirationStateType =>
|
||||
state.expiration;
|
||||
|
@ -29,28 +25,17 @@ export const getExpirationTimestamp = createSelector(
|
|||
getRemoteBuildExpiration,
|
||||
getAutoDownloadUpdate,
|
||||
(
|
||||
buildExpiration: number,
|
||||
packagedBuildExpiration: number,
|
||||
remoteBuildExpiration: number | undefined,
|
||||
autoDownloadUpdate: boolean
|
||||
): number => {
|
||||
const localBuildExpiration =
|
||||
isNotUpdatable(window.getVersion()) || autoDownloadUpdate
|
||||
? buildExpiration
|
||||
: buildExpiration - SIXTY_DAYS;
|
||||
|
||||
// Log the expiration date in this selector because it invalidates only
|
||||
// if one of the arguments changes.
|
||||
let result: number;
|
||||
let type: string;
|
||||
if (remoteBuildExpiration && remoteBuildExpiration < localBuildExpiration) {
|
||||
type = 'remote';
|
||||
result = remoteBuildExpiration;
|
||||
} else {
|
||||
type = 'local';
|
||||
result = localBuildExpiration;
|
||||
}
|
||||
log.info(`Build expires (${type}): ${new Date(result).toISOString()}`);
|
||||
return result;
|
||||
return getBuildExpirationTimestamp({
|
||||
version: window.getVersion(),
|
||||
packagedBuildExpiration,
|
||||
remoteBuildExpiration,
|
||||
autoDownloadUpdate,
|
||||
logger: log,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -62,29 +47,16 @@ export const hasExpired = createSelector(
|
|||
getExpirationTimestamp,
|
||||
getAutoDownloadUpdate,
|
||||
(_: StateType, { now = Date.now() }: HasExpiredOptionsType = {}) => now,
|
||||
(buildExpiration: number, autoDownloadUpdate: boolean, now: number) => {
|
||||
if (getEnvironment() !== Environment.PackagedApp && buildExpiration === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isInPast(buildExpiration)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const safeExpirationMs = autoDownloadUpdate
|
||||
? NINETY_ONE_DAYS
|
||||
: THIRTY_ONE_DAYS;
|
||||
|
||||
const buildExpirationDuration = buildExpiration - now;
|
||||
const tooFarIntoFuture = buildExpirationDuration > safeExpirationMs;
|
||||
|
||||
if (tooFarIntoFuture) {
|
||||
log.error(
|
||||
'Build expiration is set too far into the future',
|
||||
buildExpiration
|
||||
);
|
||||
}
|
||||
|
||||
return tooFarIntoFuture || isInPast(buildExpiration);
|
||||
(
|
||||
buildExpirationTimestamp: number,
|
||||
autoDownloadUpdate: boolean,
|
||||
now: number
|
||||
) => {
|
||||
return hasBuildExpired({
|
||||
buildExpirationTimestamp,
|
||||
autoDownloadUpdate,
|
||||
now,
|
||||
logger: log,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
|
|
@ -89,6 +89,8 @@ export type SocketStatuses = Record<
|
|||
SocketInfo
|
||||
>;
|
||||
|
||||
export type SocketExpirationReason = 'remote' | 'build';
|
||||
|
||||
// This class manages two websocket resources:
|
||||
//
|
||||
// - Authenticated IWebSocketResource which uses supplied WebAPICredentials and
|
||||
|
@ -123,7 +125,7 @@ export class SocketManager extends EventListener {
|
|||
#incomingRequestQueue = new Array<IncomingWebSocketRequest>();
|
||||
#isNavigatorOffline = false;
|
||||
#privIsOnline: boolean | undefined;
|
||||
#isRemotelyExpired = false;
|
||||
#expirationReason: SocketExpirationReason | undefined;
|
||||
#hasStoriesDisabled: boolean;
|
||||
#reconnectController: AbortController | undefined;
|
||||
#envelopeCount = 0;
|
||||
|
@ -145,17 +147,29 @@ export class SocketManager extends EventListener {
|
|||
}
|
||||
|
||||
#markOffline() {
|
||||
if (this.#privIsOnline !== false) {
|
||||
this.#privIsOnline = false;
|
||||
this.emit('offline');
|
||||
// Note: `#privIsOnline` starts as `undefined` so that we emit the first
|
||||
// `offline` event.
|
||||
if (this.#privIsOnline === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#privIsOnline = false;
|
||||
this.emit('offline');
|
||||
}
|
||||
|
||||
#markOnline() {
|
||||
if (this.#privIsOnline === true) {
|
||||
return;
|
||||
}
|
||||
this.#privIsOnline = true;
|
||||
this.emit('online');
|
||||
}
|
||||
|
||||
// Update WebAPICredentials and reconnect authenticated resource if
|
||||
// credentials changed
|
||||
public async authenticate(credentials: WebAPICredentials): Promise<void> {
|
||||
if (this.#isRemotelyExpired) {
|
||||
throw new HTTPError('SocketManager remotely expired', {
|
||||
if (this.#expirationReason != null) {
|
||||
throw new HTTPError(`SocketManager ${this.#expirationReason} expired`, {
|
||||
code: 0,
|
||||
headers: {},
|
||||
stack: new Error().stack,
|
||||
|
@ -240,8 +254,11 @@ export class SocketManager extends EventListener {
|
|||
this.#authenticated = process;
|
||||
|
||||
const reconnect = async (): Promise<void> => {
|
||||
if (this.#isRemotelyExpired) {
|
||||
log.info('SocketManager: remotely expired, not reconnecting');
|
||||
if (this.#expirationReason != null) {
|
||||
log.info(
|
||||
`SocketManager: ${this.#expirationReason} expired, ` +
|
||||
'not reconnecting'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -409,8 +426,11 @@ export class SocketManager extends EventListener {
|
|||
handler: IRequestHandler,
|
||||
timeout?: number
|
||||
): Promise<IWebSocketResource> {
|
||||
if (this.#isRemotelyExpired) {
|
||||
throw new Error('Remotely expired, not connecting provisioning socket');
|
||||
if (this.#expirationReason != null) {
|
||||
throw new Error(
|
||||
`${this.#expirationReason} expired, ` +
|
||||
'not connecting provisioning socket'
|
||||
);
|
||||
}
|
||||
|
||||
return this.#connectResource({
|
||||
|
@ -597,12 +617,15 @@ export class SocketManager extends EventListener {
|
|||
await this.check();
|
||||
}
|
||||
|
||||
public async onRemoteExpiration(): Promise<void> {
|
||||
log.info('SocketManager.onRemoteExpiration');
|
||||
this.#isRemotelyExpired = true;
|
||||
public async onExpiration(reason: SocketExpirationReason): Promise<void> {
|
||||
log.info('SocketManager.onRemoteExpiration', reason);
|
||||
this.#expirationReason = reason;
|
||||
|
||||
// Cancel reconnect attempt if any
|
||||
this.#reconnectController?.abort();
|
||||
|
||||
// Logout
|
||||
await this.logout();
|
||||
}
|
||||
|
||||
public async logout(): Promise<void> {
|
||||
|
@ -636,10 +659,7 @@ export class SocketManager extends EventListener {
|
|||
this.#authenticatedStatus.lastConnectionTransport =
|
||||
newStatus.transportOption;
|
||||
|
||||
if (!this.#privIsOnline) {
|
||||
this.#privIsOnline = true;
|
||||
this.emit('online');
|
||||
}
|
||||
this.#markOnline();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -682,6 +702,14 @@ export class SocketManager extends EventListener {
|
|||
}
|
||||
|
||||
async #getUnauthenticatedResource(): Promise<IWebSocketResource> {
|
||||
if (this.#expirationReason) {
|
||||
throw new HTTPError(`SocketManager ${this.#expirationReason} expired`, {
|
||||
code: 0,
|
||||
headers: {},
|
||||
stack: new Error().stack,
|
||||
});
|
||||
}
|
||||
|
||||
// awaiting on `this.getProxyAgent()` needs to happen here
|
||||
// so that there are no calls to `await` between checking
|
||||
// the value of `this.unauthenticated` and assigning it later in this function
|
||||
|
@ -691,14 +719,6 @@ export class SocketManager extends EventListener {
|
|||
return this.#unauthenticated.getResult();
|
||||
}
|
||||
|
||||
if (this.#isRemotelyExpired) {
|
||||
throw new HTTPError('SocketManager remotely expired', {
|
||||
code: 0,
|
||||
headers: {},
|
||||
stack: new Error().stack,
|
||||
});
|
||||
}
|
||||
|
||||
log.info('SocketManager: connecting unauthenticated socket');
|
||||
|
||||
const transportOption = this.#transportOption();
|
||||
|
|
|
@ -56,7 +56,11 @@ import { getRandomBytes, randomInt } from '../Crypto';
|
|||
import * as linkPreviewFetch from '../linkPreviews/linkPreviewFetch';
|
||||
import { isBadgeImageFileUrlValid } from '../badges/isBadgeImageFileUrlValid';
|
||||
|
||||
import { SocketManager, type SocketStatuses } from './SocketManager';
|
||||
import {
|
||||
SocketManager,
|
||||
type SocketStatuses,
|
||||
type SocketExpirationReason,
|
||||
} from './SocketManager';
|
||||
import type { CDSAuthType, CDSResponseType } from './cds/Types.d';
|
||||
import { CDSI } from './cds/CDSI';
|
||||
import { SignalService as Proto } from '../protobuf';
|
||||
|
@ -87,6 +91,7 @@ import { isProduction } from '../util/version';
|
|||
import type { ServerAlert } from '../util/handleServerAlerts';
|
||||
import { isAbortError } from '../util/isAbortError';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import { drop } from '../util/drop';
|
||||
|
||||
// Note: this will break some code that expects to be able to use err.response when a
|
||||
// web request fails, because it will force it to text. But it is very useful for
|
||||
|
@ -817,6 +822,7 @@ type AjaxOptionsType<Type extends AjaxResponseType, OutputShape = unknown> = (
|
|||
|
||||
export type WebAPIConnectOptionsType = WebAPICredentials & {
|
||||
hasStoriesDisabled: boolean;
|
||||
hasBuildExpired: boolean;
|
||||
};
|
||||
|
||||
export type WebAPIConnectType = {
|
||||
|
@ -1700,7 +1706,7 @@ export type WebAPIType = {
|
|||
isOnline: () => boolean | undefined;
|
||||
onNavigatorOnline: () => Promise<void>;
|
||||
onNavigatorOffline: () => Promise<void>;
|
||||
onRemoteExpiration: () => Promise<void>;
|
||||
onExpiration: (reason: SocketExpirationReason) => Promise<void>;
|
||||
reconnect: () => Promise<void>;
|
||||
};
|
||||
|
||||
|
@ -1880,6 +1886,7 @@ export function initialize({
|
|||
username: initialUsername,
|
||||
password: initialPassword,
|
||||
hasStoriesDisabled,
|
||||
hasBuildExpired,
|
||||
}: WebAPIConnectOptionsType) {
|
||||
let username = initialUsername;
|
||||
let password = initialPassword;
|
||||
|
@ -1933,7 +1940,11 @@ export function initialize({
|
|||
serverAlerts = alerts;
|
||||
});
|
||||
|
||||
void socketManager.authenticate({ username, password });
|
||||
if (hasBuildExpired) {
|
||||
drop(socketManager.onExpiration('build'));
|
||||
}
|
||||
|
||||
drop(socketManager.authenticate({ username, password }));
|
||||
|
||||
const cds = new CDSI(libsignalNet, {
|
||||
logger: log,
|
||||
|
@ -2071,7 +2082,7 @@ export function initialize({
|
|||
isOnline,
|
||||
onNavigatorOffline,
|
||||
onNavigatorOnline,
|
||||
onRemoteExpiration,
|
||||
onExpiration,
|
||||
postBatchIdentityCheck,
|
||||
putEncryptedAttachment,
|
||||
putProfile,
|
||||
|
@ -2283,8 +2294,8 @@ export function initialize({
|
|||
await socketManager.onNavigatorOffline();
|
||||
}
|
||||
|
||||
async function onRemoteExpiration(): Promise<void> {
|
||||
await socketManager.onRemoteExpiration();
|
||||
async function onExpiration(reason: SocketExpirationReason): Promise<void> {
|
||||
await socketManager.onExpiration(reason);
|
||||
}
|
||||
|
||||
async function reconnect(): Promise<void> {
|
||||
|
|
88
ts/util/buildExpiration.ts
Normal file
88
ts/util/buildExpiration.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { Environment, getEnvironment } from '../environment';
|
||||
import type { LoggerType } from '../types/Logging';
|
||||
import { isNotUpdatable } from './version';
|
||||
import { isInPast } from './timestamp';
|
||||
import { DAY } from './durations';
|
||||
|
||||
const NINETY_ONE_DAYS = 91 * DAY;
|
||||
const THIRTY_ONE_DAYS = 31 * DAY;
|
||||
const SIXTY_DAYS = 60 * DAY;
|
||||
|
||||
export type GetBuildExpirationTimestampOptionsType = Readonly<{
|
||||
version: string;
|
||||
packagedBuildExpiration: number;
|
||||
remoteBuildExpiration: number | undefined;
|
||||
autoDownloadUpdate: boolean;
|
||||
logger: LoggerType;
|
||||
}>;
|
||||
|
||||
export function getBuildExpirationTimestamp({
|
||||
version,
|
||||
packagedBuildExpiration,
|
||||
remoteBuildExpiration,
|
||||
autoDownloadUpdate,
|
||||
logger,
|
||||
}: GetBuildExpirationTimestampOptionsType): number {
|
||||
const localBuildExpiration =
|
||||
isNotUpdatable(version) || autoDownloadUpdate
|
||||
? packagedBuildExpiration
|
||||
: packagedBuildExpiration - SIXTY_DAYS;
|
||||
|
||||
// Log the expiration date in this selector because it invalidates only
|
||||
// if one of the arguments changes.
|
||||
let result: number;
|
||||
let type: string;
|
||||
if (remoteBuildExpiration && remoteBuildExpiration < localBuildExpiration) {
|
||||
type = 'remote';
|
||||
result = remoteBuildExpiration;
|
||||
} else {
|
||||
type = 'local';
|
||||
result = localBuildExpiration;
|
||||
}
|
||||
logger.info(`Build expires (${type}): ${new Date(result).toISOString()}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
export type HasBuildExpiredOptionsType = Readonly<{
|
||||
buildExpirationTimestamp: number;
|
||||
autoDownloadUpdate: boolean;
|
||||
now: number;
|
||||
logger: LoggerType;
|
||||
}>;
|
||||
|
||||
export function hasBuildExpired({
|
||||
buildExpirationTimestamp,
|
||||
autoDownloadUpdate,
|
||||
now,
|
||||
logger,
|
||||
}: HasBuildExpiredOptionsType): boolean {
|
||||
if (
|
||||
getEnvironment() !== Environment.PackagedApp &&
|
||||
buildExpirationTimestamp === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isInPast(buildExpirationTimestamp)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const safeExpirationMs = autoDownloadUpdate
|
||||
? NINETY_ONE_DAYS
|
||||
: THIRTY_ONE_DAYS;
|
||||
|
||||
const buildExpirationDuration = buildExpirationTimestamp - now;
|
||||
const tooFarIntoFuture = buildExpirationDuration > safeExpirationMs;
|
||||
|
||||
if (tooFarIntoFuture) {
|
||||
logger.error(
|
||||
'Build expiration is set too far into the future',
|
||||
buildExpirationTimestamp
|
||||
);
|
||||
}
|
||||
|
||||
return tooFarIntoFuture || isInPast(buildExpirationTimestamp);
|
||||
}
|
|
@ -34,3 +34,54 @@ export function safeSetTimeout(
|
|||
|
||||
return setTimeout(callback, delayMs);
|
||||
}
|
||||
|
||||
// Set timeout for a delay that might be longer than MAX_SAFE_TIMEOUT_DELAY. The
|
||||
// callback is guaranteed to execute after desired delay.
|
||||
export class LongTimeout {
|
||||
#callback: VoidFunction;
|
||||
#fireTime: number;
|
||||
#timer: NodeJS.Timeout | undefined;
|
||||
|
||||
constructor(callback: VoidFunction, providedDelayMs: number) {
|
||||
let delayMs = providedDelayMs;
|
||||
|
||||
if (delayMs < 0) {
|
||||
logging.warn('safeSetTimeout: timeout is less than zero');
|
||||
delayMs = 0;
|
||||
}
|
||||
if (Number.isNaN(delayMs)) {
|
||||
throw new Error('NaN delayMs');
|
||||
}
|
||||
if (!Number.isFinite(delayMs)) {
|
||||
throw new Error('Infinite delayMs');
|
||||
}
|
||||
|
||||
this.#callback = callback;
|
||||
this.#fireTime = Date.now() + delayMs;
|
||||
this.#schedule();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (this.#timer != null) {
|
||||
clearTimeout(this.#timer);
|
||||
}
|
||||
this.#timer = undefined;
|
||||
}
|
||||
|
||||
#schedule(): void {
|
||||
const remainingMs = this.#fireTime - Date.now();
|
||||
if (remainingMs <= MAX_SAFE_TIMEOUT_DELAY) {
|
||||
this.#timer = setTimeout(() => this.#fire(), remainingMs);
|
||||
return;
|
||||
}
|
||||
|
||||
this.#timer = setTimeout(() => {
|
||||
this.#schedule();
|
||||
}, MAX_SAFE_TIMEOUT_DELAY);
|
||||
}
|
||||
|
||||
#fire(): void {
|
||||
this.clear();
|
||||
this.#callback();
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue