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 { checkFirstEnvelope } from './util/checkFirstEnvelope';
import { BLOCKED_UUIDS_ID } from './textsecure/storage/Blocked'; import { BLOCKED_UUIDS_ID } from './textsecure/storage/Blocked';
import { ReleaseNotesFetcher } from './services/releaseNotesFetcher'; import { ReleaseNotesFetcher } from './services/releaseNotesFetcher';
import { BuildExpirationService } from './services/buildExpiration';
import { import {
maybeQueueDeviceNameFetch, maybeQueueDeviceNameFetch,
onDeviceNameChangeSync, onDeviceNameChangeSync,
@ -522,11 +523,18 @@ export async function startApp(): Promise<void> {
window.Whisper.events.on('firstEnvelope', checkFirstEnvelope); window.Whisper.events.on('firstEnvelope', checkFirstEnvelope);
const buildExpirationService = new BuildExpirationService();
server = window.WebAPI.connect({ server = window.WebAPI.connect({
...window.textsecure.storage.user.getWebAPICredentials(), ...window.textsecure.storage.user.getWebAPICredentials(),
hasBuildExpired: buildExpirationService.hasBuildExpired(),
hasStoriesDisabled: window.storage.get('hasStoriesDisabled', false), hasStoriesDisabled: window.storage.get('hasStoriesDisabled', false),
}); });
buildExpirationService.on('expired', () => {
drop(server?.onExpiration('build'));
});
window.textsecure.server = server; window.textsecure.server = server;
window.textsecure.messaging = new window.textsecure.MessageSender(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'); log.error('background: remote expiration detected, disabling reconnects');
drop(window.storage.put('remoteBuildExpiration', Date.now())); drop(window.storage.put('remoteBuildExpiration', Date.now()));
drop(server?.onRemoteExpiration()); drop(server?.onExpiration('remote'));
remotelyExpired = true; remotelyExpired = true;
}); });
@ -1717,6 +1725,7 @@ export async function startApp(): Promise<void> {
if (remotelyExpired) { if (remotelyExpired) {
log.info('afterAuthSocketConnect: remotely expired'); log.info('afterAuthSocketConnect: remotely expired');
drop(onEmpty({ isFromMessageReceiver: false })); // this ensures that the inbox loading progress bar is dismissed
return; 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 { 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 * as log from '../../logging/log';
import type { StateType } from '../reducer'; import type { StateType } from '../reducer';
import type { ExpirationStateType } from '../ducks/expiration'; import type { ExpirationStateType } from '../ducks/expiration';
import { getRemoteBuildExpiration, getAutoDownloadUpdate } from './items'; import { getRemoteBuildExpiration, getAutoDownloadUpdate } from './items';
import { isNotUpdatable } from '../../util/version'; import {
getBuildExpirationTimestamp,
const NINETY_ONE_DAYS = 91 * DAY; hasBuildExpired,
const THIRTY_ONE_DAYS = 31 * DAY; } from '../../util/buildExpiration';
const SIXTY_DAYS = 60 * DAY;
export const getExpiration = (state: StateType): ExpirationStateType => export const getExpiration = (state: StateType): ExpirationStateType =>
state.expiration; state.expiration;
@ -29,28 +25,17 @@ export const getExpirationTimestamp = createSelector(
getRemoteBuildExpiration, getRemoteBuildExpiration,
getAutoDownloadUpdate, getAutoDownloadUpdate,
( (
buildExpiration: number, packagedBuildExpiration: number,
remoteBuildExpiration: number | undefined, remoteBuildExpiration: number | undefined,
autoDownloadUpdate: boolean autoDownloadUpdate: boolean
): number => { ): number => {
const localBuildExpiration = return getBuildExpirationTimestamp({
isNotUpdatable(window.getVersion()) || autoDownloadUpdate version: window.getVersion(),
? buildExpiration packagedBuildExpiration,
: buildExpiration - SIXTY_DAYS; remoteBuildExpiration,
autoDownloadUpdate,
// Log the expiration date in this selector because it invalidates only logger: log,
// 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;
} }
); );
@ -62,29 +47,16 @@ export const hasExpired = createSelector(
getExpirationTimestamp, getExpirationTimestamp,
getAutoDownloadUpdate, getAutoDownloadUpdate,
(_: StateType, { now = Date.now() }: HasExpiredOptionsType = {}) => now, (_: StateType, { now = Date.now() }: HasExpiredOptionsType = {}) => now,
(buildExpiration: number, autoDownloadUpdate: boolean, now: number) => { (
if (getEnvironment() !== Environment.PackagedApp && buildExpiration === 0) { buildExpirationTimestamp: number,
return false; autoDownloadUpdate: boolean,
} now: number
) => {
if (isInPast(buildExpiration)) { return hasBuildExpired({
return true; buildExpirationTimestamp,
} autoDownloadUpdate,
now,
const safeExpirationMs = autoDownloadUpdate logger: log,
? 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);
} }
); );

View file

@ -89,6 +89,8 @@ export type SocketStatuses = Record<
SocketInfo SocketInfo
>; >;
export type SocketExpirationReason = 'remote' | 'build';
// This class manages two websocket resources: // This class manages two websocket resources:
// //
// - Authenticated IWebSocketResource which uses supplied WebAPICredentials and // - Authenticated IWebSocketResource which uses supplied WebAPICredentials and
@ -123,7 +125,7 @@ export class SocketManager extends EventListener {
#incomingRequestQueue = new Array<IncomingWebSocketRequest>(); #incomingRequestQueue = new Array<IncomingWebSocketRequest>();
#isNavigatorOffline = false; #isNavigatorOffline = false;
#privIsOnline: boolean | undefined; #privIsOnline: boolean | undefined;
#isRemotelyExpired = false; #expirationReason: SocketExpirationReason | undefined;
#hasStoriesDisabled: boolean; #hasStoriesDisabled: boolean;
#reconnectController: AbortController | undefined; #reconnectController: AbortController | undefined;
#envelopeCount = 0; #envelopeCount = 0;
@ -145,17 +147,29 @@ export class SocketManager extends EventListener {
} }
#markOffline() { #markOffline() {
if (this.#privIsOnline !== false) { // Note: `#privIsOnline` starts as `undefined` so that we emit the first
this.#privIsOnline = false; // `offline` event.
this.emit('offline'); 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 // Update WebAPICredentials and reconnect authenticated resource if
// credentials changed // credentials changed
public async authenticate(credentials: WebAPICredentials): Promise<void> { public async authenticate(credentials: WebAPICredentials): Promise<void> {
if (this.#isRemotelyExpired) { if (this.#expirationReason != null) {
throw new HTTPError('SocketManager remotely expired', { throw new HTTPError(`SocketManager ${this.#expirationReason} expired`, {
code: 0, code: 0,
headers: {}, headers: {},
stack: new Error().stack, stack: new Error().stack,
@ -240,8 +254,11 @@ export class SocketManager extends EventListener {
this.#authenticated = process; this.#authenticated = process;
const reconnect = async (): Promise<void> => { const reconnect = async (): Promise<void> => {
if (this.#isRemotelyExpired) { if (this.#expirationReason != null) {
log.info('SocketManager: remotely expired, not reconnecting'); log.info(
`SocketManager: ${this.#expirationReason} expired, ` +
'not reconnecting'
);
return; return;
} }
@ -409,8 +426,11 @@ export class SocketManager extends EventListener {
handler: IRequestHandler, handler: IRequestHandler,
timeout?: number timeout?: number
): Promise<IWebSocketResource> { ): Promise<IWebSocketResource> {
if (this.#isRemotelyExpired) { if (this.#expirationReason != null) {
throw new Error('Remotely expired, not connecting provisioning socket'); throw new Error(
`${this.#expirationReason} expired, ` +
'not connecting provisioning socket'
);
} }
return this.#connectResource({ return this.#connectResource({
@ -597,12 +617,15 @@ export class SocketManager extends EventListener {
await this.check(); await this.check();
} }
public async onRemoteExpiration(): Promise<void> { public async onExpiration(reason: SocketExpirationReason): Promise<void> {
log.info('SocketManager.onRemoteExpiration'); log.info('SocketManager.onRemoteExpiration', reason);
this.#isRemotelyExpired = true; this.#expirationReason = reason;
// Cancel reconnect attempt if any // Cancel reconnect attempt if any
this.#reconnectController?.abort(); this.#reconnectController?.abort();
// Logout
await this.logout();
} }
public async logout(): Promise<void> { public async logout(): Promise<void> {
@ -636,10 +659,7 @@ export class SocketManager extends EventListener {
this.#authenticatedStatus.lastConnectionTransport = this.#authenticatedStatus.lastConnectionTransport =
newStatus.transportOption; newStatus.transportOption;
if (!this.#privIsOnline) { this.#markOnline();
this.#privIsOnline = true;
this.emit('online');
}
} }
} }
@ -682,6 +702,14 @@ export class SocketManager extends EventListener {
} }
async #getUnauthenticatedResource(): Promise<IWebSocketResource> { 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 // awaiting on `this.getProxyAgent()` needs to happen here
// so that there are no calls to `await` between checking // so that there are no calls to `await` between checking
// the value of `this.unauthenticated` and assigning it later in this function // 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(); 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'); log.info('SocketManager: connecting unauthenticated socket');
const transportOption = this.#transportOption(); const transportOption = this.#transportOption();

View file

@ -56,7 +56,11 @@ import { getRandomBytes, randomInt } from '../Crypto';
import * as linkPreviewFetch from '../linkPreviews/linkPreviewFetch'; import * as linkPreviewFetch from '../linkPreviews/linkPreviewFetch';
import { isBadgeImageFileUrlValid } from '../badges/isBadgeImageFileUrlValid'; 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 type { CDSAuthType, CDSResponseType } from './cds/Types.d';
import { CDSI } from './cds/CDSI'; import { CDSI } from './cds/CDSI';
import { SignalService as Proto } from '../protobuf'; import { SignalService as Proto } from '../protobuf';
@ -87,6 +91,7 @@ import { isProduction } from '../util/version';
import type { ServerAlert } from '../util/handleServerAlerts'; import type { ServerAlert } from '../util/handleServerAlerts';
import { isAbortError } from '../util/isAbortError'; import { isAbortError } from '../util/isAbortError';
import { missingCaseError } from '../util/missingCaseError'; 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 // 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 // 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 & { export type WebAPIConnectOptionsType = WebAPICredentials & {
hasStoriesDisabled: boolean; hasStoriesDisabled: boolean;
hasBuildExpired: boolean;
}; };
export type WebAPIConnectType = { export type WebAPIConnectType = {
@ -1700,7 +1706,7 @@ export type WebAPIType = {
isOnline: () => boolean | undefined; isOnline: () => boolean | undefined;
onNavigatorOnline: () => Promise<void>; onNavigatorOnline: () => Promise<void>;
onNavigatorOffline: () => Promise<void>; onNavigatorOffline: () => Promise<void>;
onRemoteExpiration: () => Promise<void>; onExpiration: (reason: SocketExpirationReason) => Promise<void>;
reconnect: () => Promise<void>; reconnect: () => Promise<void>;
}; };
@ -1880,6 +1886,7 @@ export function initialize({
username: initialUsername, username: initialUsername,
password: initialPassword, password: initialPassword,
hasStoriesDisabled, hasStoriesDisabled,
hasBuildExpired,
}: WebAPIConnectOptionsType) { }: WebAPIConnectOptionsType) {
let username = initialUsername; let username = initialUsername;
let password = initialPassword; let password = initialPassword;
@ -1933,7 +1940,11 @@ export function initialize({
serverAlerts = alerts; serverAlerts = alerts;
}); });
void socketManager.authenticate({ username, password }); if (hasBuildExpired) {
drop(socketManager.onExpiration('build'));
}
drop(socketManager.authenticate({ username, password }));
const cds = new CDSI(libsignalNet, { const cds = new CDSI(libsignalNet, {
logger: log, logger: log,
@ -2071,7 +2082,7 @@ export function initialize({
isOnline, isOnline,
onNavigatorOffline, onNavigatorOffline,
onNavigatorOnline, onNavigatorOnline,
onRemoteExpiration, onExpiration,
postBatchIdentityCheck, postBatchIdentityCheck,
putEncryptedAttachment, putEncryptedAttachment,
putProfile, putProfile,
@ -2283,8 +2294,8 @@ export function initialize({
await socketManager.onNavigatorOffline(); await socketManager.onNavigatorOffline();
} }
async function onRemoteExpiration(): Promise<void> { async function onExpiration(reason: SocketExpirationReason): Promise<void> {
await socketManager.onRemoteExpiration(); await socketManager.onExpiration(reason);
} }
async function reconnect(): Promise<void> { 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); 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();
}
}