Refactor build expiration checks

This commit is contained in:
Fedor Indutny 2025-06-10 12:17:07 -07:00 committed by GitHub
commit 9a4972d59e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 323 additions and 83 deletions

View file

@ -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;
}

View 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);
}
}

View file

@ -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,
});
}
);

View file

@ -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();

View file

@ -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> {

View 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);
}

View file

@ -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();
}
}