Simplify online/offline status management

This commit is contained in:
Fedor Indutny 2024-03-18 14:48:00 -07:00 committed by GitHub
parent b359d28771
commit 9aff86f02b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 432 additions and 335 deletions

View file

@ -80,6 +80,7 @@ const noop = () => {};
window.Whisper = window.Whisper || {}; window.Whisper = window.Whisper || {};
window.Whisper.events = { window.Whisper.events = {
on: noop, on: noop,
off: noop,
}; };
window.SignalContext = { window.SignalContext = {

View file

@ -478,8 +478,7 @@ export async function startApp(): Promise<void> {
senderCertificateService.initialize({ senderCertificateService.initialize({
server, server,
navigator, events: window.Whisper.events,
onlineEventTarget: window,
storage: window.storage, storage: window.storage,
}); });
@ -1334,8 +1333,8 @@ export async function startApp(): Promise<void> {
} }
log.warn('background: remote expiration detected, disabling reconnects'); log.warn('background: remote expiration detected, disabling reconnects');
drop(server?.onRemoteExpiration());
remotelyExpired = true; remotelyExpired = true;
onOffline();
}); });
async function runStorageService() { async function runStorageService() {
@ -1417,12 +1416,18 @@ export async function startApp(): Promise<void> {
log.info('Expiration start timestamp cleanup: complete'); log.info('Expiration start timestamp cleanup: complete');
log.info('listening for registration events'); log.info('listening for registration events');
window.Whisper.events.on('registration_done', async () => { window.Whisper.events.on('registration_done', () => {
log.info('handling registration event'); log.info('handling registration event');
strictAssert(server !== undefined, 'WebAPI not ready'); strictAssert(server !== undefined, 'WebAPI not ready');
await server.authenticate(
window.textsecure.storage.user.getWebAPICredentials() // Once this resolves it will trigger `online` event and cause
// `connect()`, but with `firstRun` set to `false`. Thus it is important
// not to await it and let execution fall through.
drop(
server.authenticate(
window.textsecure.storage.user.getWebAPICredentials()
)
); );
// Cancel throttled calls to refreshRemoteConfig since our auth changed. // Cancel throttled calls to refreshRemoteConfig since our auth changed.
@ -1525,52 +1530,20 @@ export async function startApp(): Promise<void> {
return syncRequest; return syncRequest;
}; };
let disconnectTimer: Timers.Timeout | undefined; function onNavigatorOffline() {
let reconnectTimer: Timers.Timeout | undefined; log.info('background: navigator offline');
function onOffline() {
log.info('offline');
window.removeEventListener('offline', onOffline); drop(server?.onNavigatorOffline());
window.addEventListener('online', onOnline);
// We've received logs from Linux where we get an 'offline' event, then 30ms later
// we get an online event. This waits a bit after getting an 'offline' event
// before disconnecting the socket manually.
disconnectTimer = Timers.setTimeout(disconnect, 1000);
if (challengeHandler) {
void challengeHandler.onOffline();
}
} }
function onOnline() { function onNavigatorOnline() {
if (remotelyExpired) { log.info('background: navigator online');
return; drop(server?.onNavigatorOnline());
}
log.info('online');
window.removeEventListener('online', onOnline);
window.addEventListener('offline', onOffline);
if (disconnectTimer && isSocketOnline()) {
log.warn('Already online. Had a blip in online/offline status.');
Timers.clearTimeout(disconnectTimer);
disconnectTimer = undefined;
if (challengeHandler) {
drop(challengeHandler.onOnline());
}
return;
}
if (disconnectTimer) {
Timers.clearTimeout(disconnectTimer);
disconnectTimer = undefined;
}
void connect();
} }
window.addEventListener('online', onNavigatorOnline);
window.addEventListener('offline', onNavigatorOffline);
function isSocketOnline() { function isSocketOnline() {
const socketStatus = window.getSocketStatus(); const socketStatus = window.getSocketStatus();
return ( return (
@ -1579,34 +1552,47 @@ export async function startApp(): Promise<void> {
); );
} }
async function disconnect() { window.Whisper.events.on('online', () => {
log.info('disconnect'); log.info('background: online');
if (!remotelyExpired) {
// Clear timer, since we're only called when the timer is expired drop(connect());
disconnectTimer = undefined;
void AttachmentDownloads.stop();
if (server !== undefined) {
strictAssert(
messageReceiver !== undefined,
'WebAPI should be initialized together with MessageReceiver'
);
await server.onOffline();
await messageReceiver.drain();
} }
} });
window.Whisper.events.on('offline', () => {
log.info('background: offline');
drop(challengeHandler?.onOffline());
drop(AttachmentDownloads.stop());
drop(messageReceiver?.drain());
if (connectCount === 0) {
log.info('background: offline, never connected, showing inbox');
drop(onEmpty()); // this ensures that the loading screen is dismissed
// Switch to inbox view even if contact sync is still running
if (window.reduxStore.getState().app.appView === AppViewType.Installer) {
log.info('background: offline, opening inbox');
window.reduxActions.app.openInbox();
}
}
});
let connectCount = 0; let connectCount = 0;
let connecting = false; let connecting = false;
let remotelyExpired = false; let remotelyExpired = false;
async function connect(firstRun?: boolean) { async function connect(firstRun?: boolean) {
if (connecting) { if (connecting) {
log.warn('connect already running', { connectCount }); log.warn('background: connect already running', {
connectCount,
firstRun,
});
return; return;
} }
if (remotelyExpired) { if (remotelyExpired) {
log.warn('remotely expired, not reconnecting'); log.warn('background: remotely expired, not reconnecting');
return; return;
} }
@ -1618,40 +1604,13 @@ export async function startApp(): Promise<void> {
// Reset the flag and update it below if needed // Reset the flag and update it below if needed
setIsInitialSync(false); setIsInitialSync(false);
log.info('connect', { firstRun, connectCount });
if (reconnectTimer) {
Timers.clearTimeout(reconnectTimer);
reconnectTimer = undefined;
}
// Bootstrap our online/offline detection, only the first time we connect
if (connectCount === 0 && navigator.onLine) {
window.addEventListener('offline', onOffline);
}
if (connectCount === 0 && !navigator.onLine) {
log.warn(
'Starting up offline; will connect when we have network access'
);
window.addEventListener('online', onOnline);
void onEmpty(); // this ensures that the loading screen is dismissed
// Switch to inbox view even if contact sync is still running
if (
window.reduxStore.getState().app.appView === AppViewType.Installer
) {
log.info('firstRun: offline, opening inbox');
window.reduxActions.app.openInbox();
} else {
log.info('firstRun: offline, not opening inbox');
}
return;
}
if (!Registration.everDone()) { if (!Registration.everDone()) {
log.info('background: registration not done, not connecting');
return; return;
} }
log.info('background: connect', { firstRun, connectCount });
// Update our profile key in the conversation if we just got linked. // Update our profile key in the conversation if we just got linked.
const profileKey = await ourProfileKeyService.get(); const profileKey = await ourProfileKeyService.get();
if (firstRun && profileKey) { if (firstRun && profileKey) {
@ -1710,14 +1669,11 @@ export async function startApp(): Promise<void> {
messageReceiver.reset(); messageReceiver.reset();
server.registerRequestHandler(messageReceiver); server.registerRequestHandler(messageReceiver);
// If coming here after `offline` event - connect again. drop(
if (!remotelyExpired) { AttachmentDownloads.start({
await server.onOnline(); logger: log,
} })
);
void AttachmentDownloads.start({
logger: log,
});
if (connectCount === 1) { if (connectCount === 1) {
Stickers.downloadQueuedPacks(); Stickers.downloadQueuedPacks();
@ -2053,7 +2009,8 @@ export async function startApp(): Promise<void> {
} }
log.info('manualConnect: calling connect()'); log.info('manualConnect: calling connect()');
void connect(); enqueueReconnectToWebSocket();
drop(connect());
} }
async function onConfiguration(ev: ConfigurationEvent): Promise<void> { async function onConfiguration(ev: ConfigurationEvent): Promise<void> {

View file

@ -86,7 +86,7 @@ function getUrlsToDownload(): Array<string> {
} }
async function downloadBadgeImageFile(url: string): Promise<string> { async function downloadBadgeImageFile(url: string): Promise<string> {
await waitForOnline(navigator, window, { timeout: 1 * MINUTE }); await waitForOnline({ timeout: 1 * MINUTE });
const { server } = window.textsecure; const { server } = window.textsecure;
if (!server) { if (!server) {

View file

@ -3,22 +3,31 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
function getOnlineStatus(): boolean {
if (window.textsecure) {
return window.textsecure.server?.isOnline() ?? true;
}
// Only for storybook
return navigator.onLine;
}
export function useIsOnline(): boolean { export function useIsOnline(): boolean {
const [isOnline, setIsOnline] = useState(navigator.onLine); const [isOnline, setIsOnline] = useState(getOnlineStatus());
useEffect(() => { useEffect(() => {
const update = () => { const update = () => {
setIsOnline(navigator.onLine); setIsOnline(getOnlineStatus());
}; };
update(); update();
window.addEventListener('offline', update); window.Whisper.events.on('online', update);
window.addEventListener('online', update); window.Whisper.events.on('offline', update);
return () => { return () => {
window.removeEventListener('offline', update); window.Whisper.events.off('online', update);
window.removeEventListener('online', update); window.Whisper.events.off('offline', update);
}; };
}, []); }, []);

View file

@ -24,7 +24,7 @@ export async function commonShouldJobContinue({
} }
try { try {
await waitForOnline(window.navigator, window, { timeout: timeRemaining }); await waitForOnline({ timeout: timeRemaining });
} catch (err: unknown) { } catch (err: unknown) {
log.info("didn't come online in time, giving up"); log.info("didn't come online in time, giving up");
return false; return false;

View file

@ -62,7 +62,7 @@ export class ReportSpamJobQueue extends JobQueue<ReportSpamJobData> {
return undefined; return undefined;
} }
await waitForOnline(window.navigator, window); await waitForOnline();
const { server } = this; const { server } = this;
strictAssert(server !== undefined, 'ReportSpamJobQueue not initialized'); strictAssert(server !== undefined, 'ReportSpamJobQueue not initialized');

View file

@ -12,7 +12,7 @@ export class AreWeASubscriberService {
update( update(
storage: Pick<StorageInterface, 'get' | 'put' | 'onready'>, storage: Pick<StorageInterface, 'get' | 'put' | 'onready'>,
server: Pick<WebAPIType, 'getHasSubscription'> server: Pick<WebAPIType, 'getHasSubscription' | 'isOnline'>
): void { ): void {
this.queue.add(async () => { this.queue.add(async () => {
await new Promise<void>(resolve => storage.onready(resolve)); await new Promise<void>(resolve => storage.onready(resolve));
@ -23,7 +23,7 @@ export class AreWeASubscriberService {
return; return;
} }
await waitForOnline(navigator, window); await waitForOnline({ server });
await storage.put( await storage.put(
'areWeASubscriber', 'areWeASubscriber',

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { import type {
CheckNetworkStatusPayloadType, SetNetworkStatusPayloadType,
NetworkActionType, NetworkActionType,
} from '../state/ducks/network'; } from '../state/ducks/network';
import { getSocketStatus } from '../shims/socketStatus'; import { getSocketStatus } from '../shims/socketStatus';
@ -17,9 +17,16 @@ const OUTAGE_CHECK_INTERVAL = 60 * SECOND;
const OUTAGE_HEALTY_ADDR = '127.0.0.1'; const OUTAGE_HEALTY_ADDR = '127.0.0.1';
const OUTAGE_NO_SERVICE_ADDR = '127.0.0.2'; const OUTAGE_NO_SERVICE_ADDR = '127.0.0.2';
enum OnlineStatus {
Online = 'Online',
MaybeOffline = 'MaybeOffline',
Offline = 'Offline',
}
const OFFLINE_DELAY = 5 * SECOND;
type NetworkActions = { type NetworkActions = {
checkNetworkStatus: (x: CheckNetworkStatusPayloadType) => NetworkActionType; setNetworkStatus: (x: SetNetworkStatusPayloadType) => NetworkActionType;
closeConnectingGracePeriod: () => NetworkActionType;
setOutage: (isOutage: boolean) => NetworkActionType; setOutage: (isOutage: boolean) => NetworkActionType;
}; };
@ -28,11 +35,13 @@ export function initializeNetworkObserver(
): void { ): void {
log.info('Initializing network observer'); log.info('Initializing network observer');
let onlineStatus = OnlineStatus.Online;
const refresh = () => { const refresh = () => {
const socketStatus = getSocketStatus(); const socketStatus = getSocketStatus();
networkActions.checkNetworkStatus({ networkActions.setNetworkStatus({
isOnline: navigator.onLine, isOnline: onlineStatus !== OnlineStatus.Offline,
socketStatus, socketStatus,
}); });
@ -89,12 +98,27 @@ export function initializeNetworkObserver(
networkActions.setOutage(false); networkActions.setOutage(false);
}; };
window.Whisper.events.on('socketStatusChange', refresh); let offlineTimer: NodeJS.Timeout | undefined;
window.Whisper.events.on('socketConnectError', onPotentialOutage);
window.addEventListener('online', refresh); window.Whisper.events.on('socketStatusChange', refresh);
window.addEventListener('offline', refresh); window.Whisper.events.on('online', () => {
window.setTimeout(() => { onlineStatus = OnlineStatus.Online;
networkActions.closeConnectingGracePeriod(); if (offlineTimer) {
}, 5 * SECOND); clearTimeout(offlineTimer);
offlineTimer = undefined;
}
refresh();
});
window.Whisper.events.on('offline', () => {
if (onlineStatus !== OnlineStatus.Online) {
return;
}
onlineStatus = OnlineStatus.MaybeOffline;
offlineTimer = setTimeout(() => {
onlineStatus = OnlineStatus.Offline;
refresh();
onPotentialOutage();
}, OFFLINE_DELAY);
});
} }

View file

@ -34,28 +34,23 @@ export class SenderCertificateService {
Promise<undefined | SerializedCertificateType> Promise<undefined | SerializedCertificateType>
> = new Map(); > = new Map();
private navigator?: { onLine: boolean }; private events?: Pick<typeof window.Whisper.events, 'on' | 'off'>;
private onlineEventTarget?: EventTarget;
private storage?: StorageInterface; private storage?: StorageInterface;
initialize({ initialize({
server, server,
navigator, events,
onlineEventTarget,
storage, storage,
}: { }: {
server: WebAPIType; server: WebAPIType;
navigator: Readonly<{ onLine: boolean }>; events?: Pick<typeof window.Whisper.events, 'on' | 'off'>;
onlineEventTarget: EventTarget;
storage: StorageInterface; storage: StorageInterface;
}): void { }): void {
log.info('Sender certificate service initialized'); log.info('Sender certificate service initialized');
this.server = server; this.server = server;
this.navigator = navigator; this.events = events;
this.onlineEventTarget = onlineEventTarget;
this.storage = storage; this.storage = storage;
} }
@ -150,9 +145,9 @@ export class SenderCertificateService {
private async fetchAndSaveCertificate( private async fetchAndSaveCertificate(
mode: SenderCertificateMode mode: SenderCertificateMode
): Promise<undefined | SerializedCertificateType> { ): Promise<undefined | SerializedCertificateType> {
const { storage, navigator, onlineEventTarget } = this; const { storage, server, events } = this;
assertDev( assertDev(
storage && navigator && onlineEventTarget, storage && server && events,
'Sender certificate service method was called before it was initialized' 'Sender certificate service method was called before it was initialized'
); );
@ -162,7 +157,7 @@ export class SenderCertificateService {
)} certificate` )} certificate`
); );
await waitForOnline(navigator, onlineEventTarget); await waitForOnline({ server, events });
let certificateString: string; let certificateString: string;
try { try {

View file

@ -477,7 +477,12 @@ const doGroupCallPeek = ({
// If we peek right after receiving the message, we may get outdated information. // If we peek right after receiving the message, we may get outdated information.
// This is most noticeable when someone leaves. We add a delay and then make sure // This is most noticeable when someone leaves. We add a delay and then make sure
// to only be peeking once. // to only be peeking once.
await Promise.all([sleep(1000), waitForOnline(navigator, window)]); const { server } = window.textsecure;
if (!server) {
log.error('doGroupCallPeek: no textsecure server');
return;
}
await Promise.all([sleep(1000), waitForOnline()]);
let peekInfo = null; let peekInfo = null;
try { try {

View file

@ -14,30 +14,24 @@ export type NetworkStateType = ReadonlyDeep<{
isOnline: boolean; isOnline: boolean;
isOutage: boolean; isOutage: boolean;
socketStatus: SocketStatus; socketStatus: SocketStatus;
withinConnectingGracePeriod: boolean;
challengeStatus: 'required' | 'pending' | 'idle'; challengeStatus: 'required' | 'pending' | 'idle';
}>; }>;
// Actions // Actions
const CHECK_NETWORK_STATUS = 'network/CHECK_NETWORK_STATUS'; const SET_NETWORK_STATUS = 'network/SET_NETWORK_STATUS';
const CLOSE_CONNECTING_GRACE_PERIOD = 'network/CLOSE_CONNECTING_GRACE_PERIOD';
const RELINK_DEVICE = 'network/RELINK_DEVICE'; const RELINK_DEVICE = 'network/RELINK_DEVICE';
const SET_CHALLENGE_STATUS = 'network/SET_CHALLENGE_STATUS'; const SET_CHALLENGE_STATUS = 'network/SET_CHALLENGE_STATUS';
const SET_OUTAGE = 'network/SET_OUTAGE'; const SET_OUTAGE = 'network/SET_OUTAGE';
export type CheckNetworkStatusPayloadType = ReadonlyDeep<{ export type SetNetworkStatusPayloadType = ReadonlyDeep<{
isOnline: boolean; isOnline: boolean;
socketStatus: SocketStatus; socketStatus: SocketStatus;
}>; }>;
type CheckNetworkStatusAction = ReadonlyDeep<{ type SetNetworkStatusAction = ReadonlyDeep<{
type: 'network/CHECK_NETWORK_STATUS'; type: 'network/SET_NETWORK_STATUS';
payload: CheckNetworkStatusPayloadType; payload: SetNetworkStatusPayloadType;
}>;
type CloseConnectingGracePeriodActionType = ReadonlyDeep<{
type: 'network/CLOSE_CONNECTING_GRACE_PERIOD';
}>; }>;
type RelinkDeviceActionType = ReadonlyDeep<{ type RelinkDeviceActionType = ReadonlyDeep<{
@ -59,8 +53,7 @@ type SetOutageActionType = ReadonlyDeep<{
}>; }>;
export type NetworkActionType = ReadonlyDeep< export type NetworkActionType = ReadonlyDeep<
| CheckNetworkStatusAction | SetNetworkStatusAction
| CloseConnectingGracePeriodActionType
| RelinkDeviceActionType | RelinkDeviceActionType
| SetChallengeStatusActionType | SetChallengeStatusActionType
| SetOutageActionType | SetOutageActionType
@ -68,21 +61,15 @@ export type NetworkActionType = ReadonlyDeep<
// Action Creators // Action Creators
function checkNetworkStatus( function setNetworkStatus(
payload: CheckNetworkStatusPayloadType payload: SetNetworkStatusPayloadType
): CheckNetworkStatusAction { ): SetNetworkStatusAction {
return { return {
type: CHECK_NETWORK_STATUS, type: SET_NETWORK_STATUS,
payload, payload,
}; };
} }
function closeConnectingGracePeriod(): CloseConnectingGracePeriodActionType {
return {
type: CLOSE_CONNECTING_GRACE_PERIOD,
};
}
function relinkDevice(): RelinkDeviceActionType { function relinkDevice(): RelinkDeviceActionType {
trigger('setupAsNewDevice'); trigger('setupAsNewDevice');
@ -108,8 +95,7 @@ function setOutage(isOutage: boolean): SetOutageActionType {
} }
export const actions = { export const actions = {
checkNetworkStatus, setNetworkStatus,
closeConnectingGracePeriod,
relinkDevice, relinkDevice,
setChallengeStatus, setChallengeStatus,
setOutage, setOutage,
@ -123,10 +109,9 @@ export const useNetworkActions = (): BoundActionCreatorsMapObject<
export function getEmptyState(): NetworkStateType { export function getEmptyState(): NetworkStateType {
return { return {
isOnline: navigator.onLine, isOnline: true,
isOutage: false, isOutage: false,
socketStatus: SocketStatus.OPEN, socketStatus: SocketStatus.OPEN,
withinConnectingGracePeriod: true,
challengeStatus: 'idle', challengeStatus: 'idle',
}; };
} }
@ -135,7 +120,7 @@ export function reducer(
state: Readonly<NetworkStateType> = getEmptyState(), state: Readonly<NetworkStateType> = getEmptyState(),
action: Readonly<NetworkActionType> action: Readonly<NetworkActionType>
): NetworkStateType { ): NetworkStateType {
if (action.type === CHECK_NETWORK_STATUS) { if (action.type === SET_NETWORK_STATUS) {
const { isOnline, socketStatus } = action.payload; const { isOnline, socketStatus } = action.payload;
// This action is dispatched frequently. We avoid allocating a new object if nothing // This action is dispatched frequently. We avoid allocating a new object if nothing
@ -146,13 +131,6 @@ export function reducer(
}); });
} }
if (action.type === CLOSE_CONNECTING_GRACE_PERIOD) {
return {
...state,
withinConnectingGracePeriod: false,
};
}
if (action.type === SET_CHALLENGE_STATUS) { if (action.type === SET_CHALLENGE_STATUS) {
return { return {
...state, ...state,

View file

@ -6,7 +6,6 @@ import { createSelector } from 'reselect';
import type { StateType } from '../reducer'; import type { StateType } from '../reducer';
import type { NetworkStateType } from '../ducks/network'; import type { NetworkStateType } from '../ducks/network';
import { isDone } from '../../util/registration'; import { isDone } from '../../util/registration';
import { SocketStatus } from '../../types/SocketStatus';
const getNetwork = (state: StateType): NetworkStateType => state.network; const getNetwork = (state: StateType): NetworkStateType => state.network;
@ -29,21 +28,9 @@ export const hasNetworkDialog = createSelector(
getNetwork, getNetwork,
isDone, isDone,
( (
{ { isOnline, isOutage }: NetworkStateType,
isOnline,
isOutage,
socketStatus,
withinConnectingGracePeriod,
}: NetworkStateType,
isRegistrationDone: boolean isRegistrationDone: boolean
): boolean => ): boolean => isRegistrationDone && (!isOnline || isOutage)
isRegistrationDone &&
(!isOnline ||
isOutage ||
(socketStatus === SocketStatus.CONNECTING &&
!withinConnectingGracePeriod) ||
socketStatus === SocketStatus.CLOSED ||
socketStatus === SocketStatus.CLOSING)
); );
export const getChallengeStatus = createSelector( export const getChallengeStatus = createSelector(

View file

@ -20,11 +20,13 @@ describe('"are we a subscriber?" service', () => {
sandbox = sinon.createSandbox(); sandbox = sinon.createSandbox();
service = new AreWeASubscriberService(); service = new AreWeASubscriberService();
sandbox.stub(navigator, 'onLine').get(() => true);
}); });
it("stores false if there's no local subscriber ID", done => { it("stores false if there's no local subscriber ID", done => {
const fakeServer = { getHasSubscription: sandbox.stub() }; const fakeServer = {
getHasSubscription: sandbox.stub(),
isOnline: () => true,
};
const fakeStorage = { const fakeStorage = {
...fakeStorageDefaults, ...fakeStorageDefaults,
get: () => undefined, get: () => undefined,
@ -39,7 +41,10 @@ describe('"are we a subscriber?" service', () => {
}); });
it("doesn't make a network request if there's no local subscriber ID", done => { it("doesn't make a network request if there's no local subscriber ID", done => {
const fakeServer = { getHasSubscription: sandbox.stub() }; const fakeServer = {
getHasSubscription: sandbox.stub(),
isOnline: () => true,
};
const fakeStorage = { const fakeStorage = {
...fakeStorageDefaults, ...fakeStorageDefaults,
get: () => undefined, get: () => undefined,
@ -53,7 +58,10 @@ describe('"are we a subscriber?" service', () => {
}); });
it('requests the subscriber ID from the server', done => { it('requests the subscriber ID from the server', done => {
const fakeServer = { getHasSubscription: sandbox.stub().resolves(false) }; const fakeServer = {
getHasSubscription: sandbox.stub().resolves(false),
isOnline: () => true,
};
const fakeStorage = { const fakeStorage = {
...fakeStorageDefaults, ...fakeStorageDefaults,
put: sandbox put: sandbox
@ -72,7 +80,10 @@ describe('"are we a subscriber?" service', () => {
}); });
it("stores when we're not a subscriber", done => { it("stores when we're not a subscriber", done => {
const fakeServer = { getHasSubscription: sandbox.stub().resolves(false) }; const fakeServer = {
getHasSubscription: sandbox.stub().resolves(false),
isOnline: () => true,
};
const fakeStorage = { const fakeStorage = {
...fakeStorageDefaults, ...fakeStorageDefaults,
put: sandbox.stub().callsFake((key, value) => { put: sandbox.stub().callsFake((key, value) => {
@ -86,7 +97,10 @@ describe('"are we a subscriber?" service', () => {
}); });
it("stores when we're a subscriber", done => { it("stores when we're a subscriber", done => {
const fakeServer = { getHasSubscription: sandbox.stub().resolves(true) }; const fakeServer = {
getHasSubscription: sandbox.stub().resolves(true),
isOnline: () => true,
};
const fakeStorage = { const fakeStorage = {
...fakeStorageDefaults, ...fakeStorageDefaults,
put: sandbox.stub().callsFake((key, value) => { put: sandbox.stub().callsFake((key, value) => {
@ -103,7 +117,10 @@ describe('"are we a subscriber?" service', () => {
const allDone = explodePromise<void>(); const allDone = explodePromise<void>();
let putCallCount = 0; let putCallCount = 0;
const fakeServer = { getHasSubscription: sandbox.stub().resolves(false) }; const fakeServer = {
getHasSubscription: sandbox.stub().resolves(false),
isOnline: () => true,
};
const fakeStorage = { const fakeStorage = {
...fakeStorageDefaults, ...fakeStorageDefaults,
put: sandbox.stub().callsFake(() => { put: sandbox.stub().callsFake(() => {

View file

@ -25,16 +25,14 @@ describe('SenderCertificateService', () => {
let fakeValidEncodedCertificate: Uint8Array; let fakeValidEncodedCertificate: Uint8Array;
let fakeValidCertificateExpiry: number; let fakeValidCertificateExpiry: number;
let fakeServer: any; let fakeServer: any;
let fakeNavigator: { onLine: boolean }; let fakeEvents: Pick<typeof window.Whisper.events, 'on' | 'off'>;
let fakeWindow: EventTarget;
let fakeStorage: any; let fakeStorage: any;
function initializeTestService(): SenderCertificateService { function initializeTestService(): SenderCertificateService {
const result = new SenderCertificateService(); const result = new SenderCertificateService();
result.initialize({ result.initialize({
server: fakeServer, server: fakeServer,
navigator: fakeNavigator, events: fakeEvents,
onlineEventTarget: fakeWindow,
storage: fakeStorage, storage: fakeStorage,
}); });
return result; return result;
@ -51,18 +49,16 @@ describe('SenderCertificateService', () => {
SenderCertificate.encode(fakeValidCertificate).finish(); SenderCertificate.encode(fakeValidCertificate).finish();
fakeServer = { fakeServer = {
isOnline: () => true,
getSenderCertificate: sinon.stub().resolves({ getSenderCertificate: sinon.stub().resolves({
certificate: Bytes.toBase64(fakeValidEncodedCertificate), certificate: Bytes.toBase64(fakeValidEncodedCertificate),
}), }),
}; };
fakeNavigator = { onLine: true }; fakeEvents = {
on: sinon.stub(),
fakeWindow = { off: sinon.stub(),
addEventListener: sinon.stub(), } as unknown as typeof fakeEvents;
dispatchEvent: sinon.stub(),
removeEventListener: sinon.stub(),
};
fakeStorage = { fakeStorage = {
get: sinon.stub(), get: sinon.stub(),
@ -221,6 +217,7 @@ describe('SenderCertificateService', () => {
let count = 0; let count = 0;
fakeServer = { fakeServer = {
isOnline: () => true,
getSenderCertificate: sinon.spy(async () => { getSenderCertificate: sinon.spy(async () => {
await new Promise(resolve => setTimeout(resolve, 500)); await new Promise(resolve => setTimeout(resolve, 500));

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai'; import { assert } from 'chai';
import { EventEmitter } from 'events';
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import { waitForOnline } from '../../util/waitForOnline'; import { waitForOnline } from '../../util/waitForOnline';
@ -17,28 +18,28 @@ describe('waitForOnline', () => {
sandbox.restore(); sandbox.restore();
}); });
function getFakeWindow(): EventTarget { function getFakeEmitter(): EventEmitter {
const result = new EventTarget(); const result = new EventEmitter();
sinon.stub(result, 'addEventListener'); sinon.stub(result, 'on');
sinon.stub(result, 'removeEventListener'); sinon.stub(result, 'off');
return result; return result;
} }
it("resolves immediately if you're online", async () => { it("resolves immediately if you're online", async () => {
const fakeNavigator = { onLine: true }; const fakeServer = { isOnline: () => true };
const fakeWindow = getFakeWindow(); const fakeEvents = getFakeEmitter();
await waitForOnline(fakeNavigator, fakeWindow); await waitForOnline({ server: fakeServer, events: fakeEvents });
sinon.assert.notCalled(fakeWindow.addEventListener as sinon.SinonStub); sinon.assert.notCalled(fakeEvents.on as sinon.SinonStub);
sinon.assert.notCalled(fakeWindow.removeEventListener as sinon.SinonStub); sinon.assert.notCalled(fakeEvents.off as sinon.SinonStub);
}); });
it("if you're offline, resolves as soon as you're online (and cleans up listeners)", async () => { it("if you're offline, resolves as soon as you're online (and cleans up listeners)", async () => {
const fakeNavigator = { onLine: false }; const fakeServer = { isOnline: () => false };
const fakeWindow = getFakeWindow(); const fakeEvents = getFakeEmitter();
(fakeWindow.addEventListener as sinon.SinonStub) (fakeEvents.on as sinon.SinonStub)
.withArgs('online') .withArgs('online')
.callsFake((_eventName: string, callback: () => void) => { .callsFake((_eventName: string, callback: () => void) => {
setTimeout(callback, 0); setTimeout(callback, 0);
@ -46,7 +47,7 @@ describe('waitForOnline', () => {
let done = false; let done = false;
const promise = (async () => { const promise = (async () => {
await waitForOnline(fakeNavigator, fakeWindow); await waitForOnline({ server: fakeServer, events: fakeEvents });
done = true; done = true;
})(); })();
@ -55,37 +56,41 @@ describe('waitForOnline', () => {
await promise; await promise;
assert.isTrue(done); assert.isTrue(done);
sinon.assert.calledOnce(fakeWindow.addEventListener as sinon.SinonStub); sinon.assert.calledOnce(fakeEvents.on as sinon.SinonStub);
sinon.assert.calledOnce(fakeWindow.removeEventListener as sinon.SinonStub); sinon.assert.calledOnce(fakeEvents.off as sinon.SinonStub);
}); });
it("resolves immediately if you're online when passed a timeout", async () => { it("resolves immediately if you're online when passed a timeout", async () => {
const fakeNavigator = { onLine: true }; const fakeServer = { isOnline: () => true };
const fakeWindow = getFakeWindow(); const fakeEvents = getFakeEmitter();
await waitForOnline(fakeNavigator, fakeWindow, { timeout: 1234 }); await waitForOnline({
server: fakeServer,
events: fakeEvents,
timeout: 1234,
});
sinon.assert.notCalled(fakeWindow.addEventListener as sinon.SinonStub); sinon.assert.notCalled(fakeEvents.on as sinon.SinonStub);
sinon.assert.notCalled(fakeWindow.removeEventListener as sinon.SinonStub); sinon.assert.notCalled(fakeEvents.off as sinon.SinonStub);
}); });
it("resolves immediately if you're online even if passed a timeout of 0", async () => { it("resolves immediately if you're online even if passed a timeout of 0", async () => {
const fakeNavigator = { onLine: true }; const fakeServer = { isOnline: () => true };
const fakeWindow = getFakeWindow(); const fakeEvents = getFakeEmitter();
await waitForOnline(fakeNavigator, fakeWindow, { timeout: 0 }); await waitForOnline({ server: fakeServer, events: fakeEvents, timeout: 0 });
sinon.assert.notCalled(fakeWindow.addEventListener as sinon.SinonStub); sinon.assert.notCalled(fakeEvents.on as sinon.SinonStub);
sinon.assert.notCalled(fakeWindow.removeEventListener as sinon.SinonStub); sinon.assert.notCalled(fakeEvents.off as sinon.SinonStub);
}); });
it("if you're offline, resolves as soon as you're online if it happens before the timeout", async () => { it("if you're offline, resolves as soon as you're online if it happens before the timeout", async () => {
const clock = sandbox.useFakeTimers(); const clock = sandbox.useFakeTimers();
const fakeNavigator = { onLine: false }; const fakeServer = { isOnline: () => false };
const fakeWindow = getFakeWindow(); const fakeEvents = getFakeEmitter();
(fakeWindow.addEventListener as sinon.SinonStub) (fakeEvents.on as sinon.SinonStub)
.withArgs('online') .withArgs('online')
.callsFake((_eventName: string, callback: () => void) => { .callsFake((_eventName: string, callback: () => void) => {
setTimeout(callback, 1000); setTimeout(callback, 1000);
@ -93,7 +98,11 @@ describe('waitForOnline', () => {
let done = false; let done = false;
void (async () => { void (async () => {
await waitForOnline(fakeNavigator, fakeWindow, { timeout: 9999 }); await waitForOnline({
server: fakeServer,
events: fakeEvents,
timeout: 9999,
});
done = true; done = true;
})(); })();
@ -108,16 +117,18 @@ describe('waitForOnline', () => {
it('rejects if too much time has passed, and cleans up listeners', async () => { it('rejects if too much time has passed, and cleans up listeners', async () => {
const clock = sandbox.useFakeTimers(); const clock = sandbox.useFakeTimers();
const fakeNavigator = { onLine: false }; const fakeServer = { isOnline: () => false };
const fakeWindow = getFakeWindow(); const fakeEvents = getFakeEmitter();
(fakeWindow.addEventListener as sinon.SinonStub) (fakeEvents.on as sinon.SinonStub)
.withArgs('online') .withArgs('online')
.callsFake((_eventName: string, callback: () => void) => { .callsFake((_eventName: string, callback: () => void) => {
setTimeout(callback, 9999); setTimeout(callback, 9999);
}); });
const promise = waitForOnline(fakeNavigator, fakeWindow, { const promise = waitForOnline({
server: fakeServer,
events: fakeEvents,
timeout: 100, timeout: 100,
}); });
@ -125,20 +136,24 @@ describe('waitForOnline', () => {
await assert.isRejected(promise); await assert.isRejected(promise);
sinon.assert.calledOnce(fakeWindow.removeEventListener as sinon.SinonStub); sinon.assert.calledOnce(fakeEvents.off as sinon.SinonStub);
}); });
it('rejects if offline and passed a timeout of 0', async () => { it('rejects if offline and passed a timeout of 0', async () => {
const fakeNavigator = { onLine: false }; const fakeServer = { isOnline: () => false };
const fakeWindow = getFakeWindow(); const fakeEvents = getFakeEmitter();
(fakeWindow.addEventListener as sinon.SinonStub) (fakeEvents.on as sinon.SinonStub)
.withArgs('online') .withArgs('online')
.callsFake((_eventName: string, callback: () => void) => { .callsFake((_eventName: string, callback: () => void) => {
setTimeout(callback, 9999); setTimeout(callback, 9999);
}); });
const promise = waitForOnline(fakeNavigator, fakeWindow, { timeout: 0 }); const promise = waitForOnline({
server: fakeServer,
events: fakeEvents,
timeout: 100,
});
await assert.isRejected(promise); await assert.isRejected(promise);
}); });

View file

@ -10,9 +10,14 @@ import EventListener from 'events';
import { AbortableProcess } from '../util/AbortableProcess'; import { AbortableProcess } from '../util/AbortableProcess';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
import { BackOff, FIBONACCI_TIMEOUTS } from '../util/BackOff'; import {
BackOff,
FIBONACCI_TIMEOUTS,
EXTENDED_FIBONACCI_TIMEOUTS,
} from '../util/BackOff';
import * as durations from '../util/durations'; import * as durations from '../util/durations';
import { sleep } from '../util/sleep'; import { sleep } from '../util/sleep';
import { drop } from '../util/drop';
import { createProxyAgent } from '../util/createProxyAgent'; import { createProxyAgent } from '../util/createProxyAgent';
import { SocketStatus } from '../types/SocketStatus'; import { SocketStatus } from '../types/SocketStatus';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
@ -38,6 +43,7 @@ const FIVE_MINUTES = 5 * durations.MINUTE;
const JITTER = 5 * durations.SECOND; const JITTER = 5 * durations.SECOND;
const OFFLINE_KEEPALIVE_TIMEOUT_MS = 5 * durations.SECOND;
export const UNAUTHENTICATED_CHANNEL_NAME = 'unauthenticated'; export const UNAUTHENTICATED_CHANNEL_NAME = 'unauthenticated';
export const AUTHENTICATED_CHANNEL_NAME = 'authenticated'; export const AUTHENTICATED_CHANNEL_NAME = 'authenticated';
@ -86,10 +92,16 @@ export class SocketManager extends EventListener {
private incomingRequestQueue = new Array<IncomingWebSocketRequest>(); private incomingRequestQueue = new Array<IncomingWebSocketRequest>();
private isOffline = false; private isNavigatorOffline = false;
private privIsOnline: boolean | undefined;
private isRemotelyExpired = false;
private hasStoriesDisabled: boolean; private hasStoriesDisabled: boolean;
private reconnectController: AbortController | undefined;
constructor(private readonly options: SocketManagerOptions) { constructor(private readonly options: SocketManagerOptions) {
super(); super();
@ -107,8 +119,8 @@ export class SocketManager extends EventListener {
// 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.isOffline) { if (this.isRemotelyExpired) {
throw new HTTPError('SocketManager offline', { throw new HTTPError('SocketManager remotely expired', {
code: 0, code: 0,
headers: {}, headers: {},
stack: new Error().stack, stack: new Error().stack,
@ -169,6 +181,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) {
log.info('SocketManager: remotely expired, not reconnecting');
return;
}
const timeout = this.backOff.getAndIncrement(); const timeout = this.backOff.getAndIncrement();
log.info( log.info(
@ -176,14 +193,22 @@ export class SocketManager extends EventListener {
`after ${timeout}ms` `after ${timeout}ms`
); );
await sleep(timeout); const reconnectController = new AbortController();
if (this.isOffline) { this.reconnectController = reconnectController;
log.info('SocketManager: cancelled reconnect because we are offline');
try {
await sleep(timeout, reconnectController.signal);
} catch {
log.info('SocketManager: reconnect cancelled');
return; return;
} finally {
if (this.reconnectController === reconnectController) {
this.reconnectController = undefined;
}
} }
if (this.authenticated) { if (this.authenticated) {
log.info('SocketManager: authenticated socket already reconnected'); log.info('SocketManager: authenticated socket already connecting');
return; return;
} }
@ -230,12 +255,13 @@ export class SocketManager extends EventListener {
return; return;
} }
if (code === -1) { if (code === -1 && this.privIsOnline !== false) {
this.emit('connectError'); this.privIsOnline = false;
this.emit('offline');
} }
} }
void reconnect(); drop(reconnect());
return; return;
} }
@ -267,7 +293,7 @@ export class SocketManager extends EventListener {
return; return;
} }
void reconnect(); drop(reconnect());
}); });
} }
@ -287,6 +313,10 @@ export class SocketManager extends EventListener {
public async getProvisioningResource( public async getProvisioningResource(
handler: IRequestHandler handler: IRequestHandler
): Promise<IWebSocketResource> { ): Promise<IWebSocketResource> {
if (this.isRemotelyExpired) {
throw new Error('Remotely expired, not connecting provisioning socket');
}
return this.connectResource({ return this.connectResource({
name: 'provisioning', name: 'provisioning',
path: '/v1/websocket/provisioning/', path: '/v1/websocket/provisioning/',
@ -397,40 +427,6 @@ export class SocketManager extends EventListener {
public async reconnect(): Promise<void> { public async reconnect(): Promise<void> {
log.info('SocketManager.reconnect: starting...'); log.info('SocketManager.reconnect: starting...');
this.onOffline();
await this.onOnline();
log.info('SocketManager.reconnect: complete.');
}
// Force keep-alive checks on WebSocketResources
public async check(): Promise<void> {
if (this.isOffline) {
return;
}
log.info('SocketManager.check');
await Promise.all([
SocketManager.checkResource(this.authenticated),
SocketManager.checkResource(this.unauthenticated),
]);
}
// Puts SocketManager into "online" state and reconnects the authenticated
// IWebSocketResource (if there are valid credentials)
public async onOnline(): Promise<void> {
log.info('SocketManager.onOnline');
this.isOffline = false;
if (this.credentials) {
await this.authenticate(this.credentials);
}
}
// Puts SocketManager into "offline" state and gracefully disconnects both
// unauthenticated and authenticated resources.
public onOffline(): void {
log.info('SocketManager.onOffline');
this.isOffline = true;
const { authenticated, unauthenticated } = this; const { authenticated, unauthenticated } = this;
if (authenticated) { if (authenticated) {
@ -441,6 +437,54 @@ export class SocketManager extends EventListener {
unauthenticated.abort(); unauthenticated.abort();
this.dropUnauthenticated(unauthenticated); this.dropUnauthenticated(unauthenticated);
} }
if (this.credentials) {
this.backOff.reset();
// Cancel old reconnect attempt
this.reconnectController?.abort();
// Start the new attempt
await this.authenticate(this.credentials);
}
log.info('SocketManager.reconnect: complete.');
}
// Force keep-alive checks on WebSocketResources
public async check(): Promise<void> {
log.info('SocketManager.check');
await Promise.all([
this.checkResource(this.authenticated),
this.checkResource(this.unauthenticated),
]);
}
public async onNavigatorOnline(): Promise<void> {
log.info('SocketManager.onNavigatorOnline');
this.isNavigatorOffline = false;
this.backOff.reset(FIBONACCI_TIMEOUTS);
// Reconnect earlier if waiting
if (this.credentials !== undefined) {
this.reconnectController?.abort();
await this.authenticate(this.credentials);
}
}
public async onNavigatorOffline(): Promise<void> {
log.info('SocketManager.onNavigatorOffline');
this.isNavigatorOffline = true;
this.backOff.reset(EXTENDED_FIBONACCI_TIMEOUTS);
await this.check();
}
public async onRemoteExpiration(): Promise<void> {
log.info('SocketManager.onRemoteExpiration');
this.isRemotelyExpired = true;
// Cancel reconnect attempt if any
this.reconnectController?.abort();
} }
public async logout(): Promise<void> { public async logout(): Promise<void> {
@ -453,6 +497,10 @@ export class SocketManager extends EventListener {
this.credentials = undefined; this.credentials = undefined;
} }
public get isOnline(): boolean {
return this.privIsOnline !== false;
}
// //
// Private // Private
// //
@ -464,6 +512,11 @@ export class SocketManager extends EventListener {
this.status = status; this.status = status;
this.emit('statusChange'); this.emit('statusChange');
if (this.status === SocketStatus.OPEN && !this.privIsOnline) {
this.privIsOnline = true;
this.emit('online');
}
} }
private transportOption(): TransportOption { private transportOption(): TransportOption {
@ -522,17 +575,19 @@ export class SocketManager extends EventListener {
} }
private async getUnauthenticatedResource(): Promise<IWebSocketResource> { private async getUnauthenticatedResource(): Promise<IWebSocketResource> {
if (this.isOffline) { if (this.unauthenticated) {
throw new HTTPError('SocketManager offline', { return this.unauthenticated.getResult();
}
if (this.isRemotelyExpired) {
throw new HTTPError('SocketManager remotely expired', {
code: 0, code: 0,
headers: {}, headers: {},
stack: new Error().stack, stack: new Error().stack,
}); });
} }
if (this.unauthenticated) { log.info('SocketManager: connecting unauthenticated socket');
return this.unauthenticated.getResult();
}
const transportOption = this.transportOption(); const transportOption = this.transportOption();
log.info( log.info(
@ -631,7 +686,7 @@ export class SocketManager extends EventListener {
}); });
} }
private static async checkResource( private async checkResource(
process?: AbortableProcess<IWebSocketResource> process?: AbortableProcess<IWebSocketResource>
): Promise<void> { ): Promise<void> {
if (!process) { if (!process) {
@ -639,7 +694,11 @@ export class SocketManager extends EventListener {
} }
const resource = await process.getResult(); const resource = await process.getResult();
resource.forceKeepAlive();
// Force shorter timeout if we think we might be offline
resource.forceKeepAlive(
this.isNavigatorOffline ? OFFLINE_KEEPALIVE_TIMEOUT_MS : undefined
);
} }
private dropAuthenticated( private dropAuthenticated(
@ -770,7 +829,8 @@ export class SocketManager extends EventListener {
callback: (error: HTTPError) => void callback: (error: HTTPError) => void
): this; ): this;
public override on(type: 'statusChange', callback: () => void): this; public override on(type: 'statusChange', callback: () => void): this;
public override on(type: 'connectError', callback: () => void): this; public override on(type: 'online', callback: () => void): this;
public override on(type: 'offline', callback: () => void): this;
public override on( public override on(
type: string | symbol, type: string | symbol,
@ -782,7 +842,8 @@ export class SocketManager extends EventListener {
public override emit(type: 'authError', error: HTTPError): boolean; public override emit(type: 'authError', error: HTTPError): boolean;
public override emit(type: 'statusChange'): boolean; public override emit(type: 'statusChange'): boolean;
public override emit(type: 'connectError'): boolean; public override emit(type: 'online'): boolean;
public override emit(type: 'offline'): boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
public override emit(type: string | symbol, ...args: Array<any>): boolean { public override emit(type: string | symbol, ...args: Array<any>): boolean {

View file

@ -94,17 +94,17 @@ export class UpdateKeysListener {
} }
private runWhenOnline() { private runWhenOnline() {
if (window.navigator.onLine) { if (window.textsecure.server?.isOnline()) {
void this.run(); void this.run();
} else { } else {
log.info( log.info(
'UpdateKeysListener: We are offline; will update keys when we are next online' 'UpdateKeysListener: We are offline; will update keys when we are next online'
); );
const listener = () => { const listener = () => {
window.removeEventListener('online', listener); window.Whisper.events.off('online', listener);
this.setTimeoutForNextRun(); this.setTimeoutForNextRun();
}; };
window.addEventListener('online', listener); window.Whisper.events.on('online', listener);
} }
} }

View file

@ -1154,8 +1154,10 @@ export type WebAPIType = {
unregisterRequestHandler: (handler: IRequestHandler) => void; unregisterRequestHandler: (handler: IRequestHandler) => void;
onHasStoriesDisabledChange: (newValue: boolean) => void; onHasStoriesDisabledChange: (newValue: boolean) => void;
checkSockets: () => void; checkSockets: () => void;
onOnline: () => Promise<void>; isOnline: () => boolean;
onOffline: () => void; onNavigatorOnline: () => Promise<void>;
onNavigatorOffline: () => Promise<void>;
onRemoteExpiration: () => Promise<void>;
reconnect: () => Promise<void>; reconnect: () => Promise<void>;
}; };
@ -1321,12 +1323,16 @@ export function initialize({
window.Whisper.events.trigger('socketStatusChange'); window.Whisper.events.trigger('socketStatusChange');
}); });
socketManager.on('authError', () => { socketManager.on('online', () => {
window.Whisper.events.trigger('unlinkAndDisconnect'); window.Whisper.events.trigger('online');
}); });
socketManager.on('connectError', () => { socketManager.on('offline', () => {
window.Whisper.events.trigger('socketConnectError'); window.Whisper.events.trigger('offline');
});
socketManager.on('authError', () => {
window.Whisper.events.trigger('unlinkAndDisconnect');
}); });
if (useWebSocket) { if (useWebSocket) {
@ -1442,8 +1448,10 @@ export function initialize({
modifyGroup, modifyGroup,
modifyStorageRecords, modifyStorageRecords,
onHasStoriesDisabledChange, onHasStoriesDisabledChange,
onOffline, isOnline,
onOnline, onNavigatorOffline,
onNavigatorOnline,
onRemoteExpiration,
postBatchIdentityCheck, postBatchIdentityCheck,
putEncryptedAttachment, putEncryptedAttachment,
putProfile, putProfile,
@ -1620,12 +1628,20 @@ export function initialize({
void socketManager.check(); void socketManager.check();
} }
async function onOnline(): Promise<void> { function isOnline(): boolean {
await socketManager.onOnline(); return socketManager.isOnline;
} }
function onOffline(): void { async function onNavigatorOnline(): Promise<void> {
socketManager.onOffline(); await socketManager.onNavigatorOnline();
}
async function onNavigatorOffline(): Promise<void> {
await socketManager.onNavigatorOffline();
}
async function onRemoteExpiration(): Promise<void> {
await socketManager.onRemoteExpiration();
} }
async function reconnect(): Promise<void> { async function reconnect(): Promise<void> {

View file

@ -42,6 +42,7 @@ import EventTarget from './EventTarget';
import * as durations from '../util/durations'; import * as durations from '../util/durations';
import { dropNull } from '../util/dropNull'; import { dropNull } from '../util/dropNull';
import { drop } from '../util/drop';
import { isOlderThan } from '../util/timestamp'; import { isOlderThan } from '../util/timestamp';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
@ -52,7 +53,6 @@ import type { IResource } from './WebSocket';
import { isProduction, isStaging } from '../util/version'; import { isProduction, isStaging } from '../util/version';
import { ToastType } from '../types/Toast'; import { ToastType } from '../types/Toast';
import { drop } from '../util/drop';
const THIRTY_SECONDS = 30 * durations.SECOND; const THIRTY_SECONDS = 30 * durations.SECOND;
@ -251,7 +251,7 @@ export interface IWebSocketResource extends IResource {
addEventListener(name: 'close', handler: (ev: CloseEvent) => void): void; addEventListener(name: 'close', handler: (ev: CloseEvent) => void): void;
forceKeepAlive(): void; forceKeepAlive(timeout?: number): void;
shutdown(): void; shutdown(): void;
@ -395,8 +395,8 @@ export class WebSocketResourceWithShadowing implements IWebSocketResource {
this.shadowing.shutdown(); this.shadowing.shutdown();
} }
public forceKeepAlive(): void { public forceKeepAlive(timeout?: number): void {
this.main.forceKeepAlive(); this.main.forceKeepAlive(timeout);
} }
public async sendRequest(options: SendRequestOptions): Promise<Response> { public async sendRequest(options: SendRequestOptions): Promise<Response> {
@ -627,11 +627,11 @@ export default class WebSocketResource
return WebSocketResource.intoResponse(requestResult); return WebSocketResource.intoResponse(requestResult);
} }
public forceKeepAlive(): void { public forceKeepAlive(timeout?: number): void {
if (!this.keepalive) { if (!this.keepalive) {
return; return;
} }
void this.keepalive.send(); drop(this.keepalive.send(timeout));
} }
public close(code = 3000, reason?: string): void { public close(code = 3000, reason?: string): void {
@ -853,7 +853,7 @@ class KeepAlive {
this.clearTimers(); this.clearTimers();
} }
public async send(): Promise<void> { public async send(timeout = KEEPALIVE_TIMEOUT_MS): Promise<void> {
this.clearTimers(); this.clearTimers();
const isStale = isOlderThan(this.lastAliveAt, STALE_THRESHOLD_MS); const isStale = isOlderThan(this.lastAliveAt, STALE_THRESHOLD_MS);
@ -875,7 +875,7 @@ class KeepAlive {
verb: 'GET', verb: 'GET',
path: this.path, path: this.path,
}), }),
KEEPALIVE_TIMEOUT_MS timeout
); );
if (status < 200 || status >= 300) { if (status < 200 || status >= 300) {

View file

@ -596,8 +596,13 @@ async function doDownloadStickerPack(
return; return;
} }
const { server } = window.textsecure;
if (!server) {
throw new Error('server is not available!');
}
// We don't count this as an attempt if we're offline // We don't count this as an attempt if we're offline
const attemptIncrement = navigator.onLine ? 1 : 0; const attemptIncrement = server.isOnline() ? 1 : 0;
const downloadAttempts = const downloadAttempts =
(existing ? existing.downloadAttempts || 0 : 0) + attemptIncrement; (existing ? existing.downloadAttempts || 0 : 0) + attemptIncrement;
if (downloadAttempts > 3) { if (downloadAttempts > 3) {

View file

@ -15,6 +15,17 @@ export const FIBONACCI_TIMEOUTS: ReadonlyArray<number> = [
55 * SECOND, 55 * SECOND,
]; ];
export const EXTENDED_FIBONACCI_TIMEOUTS: ReadonlyArray<number> = [
...FIBONACCI_TIMEOUTS,
89 * SECOND,
144 * SECOND,
233 * SECOND,
377 * SECOND,
610 * SECOND,
987 * SECOND,
1597 * SECOND, // ~26 minutes
];
export type BackOffOptionsType = Readonly<{ export type BackOffOptionsType = Readonly<{
jitter?: number; jitter?: number;
@ -28,7 +39,7 @@ export class BackOff {
private count = 0; private count = 0;
constructor( constructor(
private readonly timeouts: ReadonlyArray<number>, private timeouts: ReadonlyArray<number>,
private readonly options: BackOffOptionsType = {} private readonly options: BackOffOptionsType = {}
) {} ) {}
@ -53,7 +64,10 @@ export class BackOff {
return result; return result;
} }
public reset(): void { public reset(newTimeouts?: ReadonlyArray<number>): void {
if (newTimeouts !== undefined) {
this.timeouts = newTimeouts;
}
this.count = 0; this.count = 0;
} }

View file

@ -3,15 +3,31 @@
import { clearTimeoutIfNecessary } from './clearTimeoutIfNecessary'; import { clearTimeoutIfNecessary } from './clearTimeoutIfNecessary';
export function waitForOnline( export type WaitForOnlineOptionsType = Readonly<{
navigator: Readonly<{ onLine: boolean }>, server?: Readonly<{ isOnline: () => boolean }>;
onlineEventTarget: EventTarget, events?: {
options: Readonly<{ timeout?: number }> = {} on: (event: 'online', fn: () => void) => void;
): Promise<void> { off: (event: 'online', fn: () => void) => void;
const { timeout } = options; };
timeout?: number;
}>;
export function waitForOnline({
server: maybeServer,
events = window.Whisper.events,
timeout,
}: WaitForOnlineOptionsType = {}): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (navigator.onLine) { let server = maybeServer;
if (server === undefined) {
({ server } = window.textsecure);
if (!server) {
reject(new Error('waitForOnline: no textsecure server'));
return;
}
}
if (server.isOnline()) {
resolve(); resolve();
return; return;
} }
@ -24,11 +40,11 @@ export function waitForOnline(
}; };
const cleanup = () => { const cleanup = () => {
onlineEventTarget.removeEventListener('online', listener); events.off('online', listener);
clearTimeoutIfNecessary(timeoutId); clearTimeoutIfNecessary(timeoutId);
}; };
onlineEventTarget.addEventListener('online', listener); events.on('online', listener);
if (timeout !== undefined) { if (timeout !== undefined) {
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {