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.events = {
on: noop,
off: noop,
};
window.SignalContext = {

View file

@ -478,8 +478,7 @@ export async function startApp(): Promise<void> {
senderCertificateService.initialize({
server,
navigator,
onlineEventTarget: window,
events: window.Whisper.events,
storage: window.storage,
});
@ -1334,8 +1333,8 @@ export async function startApp(): Promise<void> {
}
log.warn('background: remote expiration detected, disabling reconnects');
drop(server?.onRemoteExpiration());
remotelyExpired = true;
onOffline();
});
async function runStorageService() {
@ -1417,12 +1416,18 @@ export async function startApp(): Promise<void> {
log.info('Expiration start timestamp cleanup: complete');
log.info('listening for registration events');
window.Whisper.events.on('registration_done', async () => {
window.Whisper.events.on('registration_done', () => {
log.info('handling registration event');
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.
@ -1525,52 +1530,20 @@ export async function startApp(): Promise<void> {
return syncRequest;
};
let disconnectTimer: Timers.Timeout | undefined;
let reconnectTimer: Timers.Timeout | undefined;
function onOffline() {
log.info('offline');
function onNavigatorOffline() {
log.info('background: navigator offline');
window.removeEventListener('offline', onOffline);
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();
}
drop(server?.onNavigatorOffline());
}
function onOnline() {
if (remotelyExpired) {
return;
}
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();
function onNavigatorOnline() {
log.info('background: navigator online');
drop(server?.onNavigatorOnline());
}
window.addEventListener('online', onNavigatorOnline);
window.addEventListener('offline', onNavigatorOffline);
function isSocketOnline() {
const socketStatus = window.getSocketStatus();
return (
@ -1579,34 +1552,47 @@ export async function startApp(): Promise<void> {
);
}
async function disconnect() {
log.info('disconnect');
// Clear timer, since we're only called when the timer is expired
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('online', () => {
log.info('background: online');
if (!remotelyExpired) {
drop(connect());
}
}
});
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 connecting = false;
let remotelyExpired = false;
async function connect(firstRun?: boolean) {
if (connecting) {
log.warn('connect already running', { connectCount });
log.warn('background: connect already running', {
connectCount,
firstRun,
});
return;
}
if (remotelyExpired) {
log.warn('remotely expired, not reconnecting');
log.warn('background: remotely expired, not reconnecting');
return;
}
@ -1618,40 +1604,13 @@ export async function startApp(): Promise<void> {
// Reset the flag and update it below if needed
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()) {
log.info('background: registration not done, not connecting');
return;
}
log.info('background: connect', { firstRun, connectCount });
// Update our profile key in the conversation if we just got linked.
const profileKey = await ourProfileKeyService.get();
if (firstRun && profileKey) {
@ -1710,14 +1669,11 @@ export async function startApp(): Promise<void> {
messageReceiver.reset();
server.registerRequestHandler(messageReceiver);
// If coming here after `offline` event - connect again.
if (!remotelyExpired) {
await server.onOnline();
}
void AttachmentDownloads.start({
logger: log,
});
drop(
AttachmentDownloads.start({
logger: log,
})
);
if (connectCount === 1) {
Stickers.downloadQueuedPacks();
@ -2053,7 +2009,8 @@ export async function startApp(): Promise<void> {
}
log.info('manualConnect: calling connect()');
void connect();
enqueueReconnectToWebSocket();
drop(connect());
}
async function onConfiguration(ev: ConfigurationEvent): Promise<void> {

View file

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

View file

@ -3,22 +3,31 @@
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 {
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [isOnline, setIsOnline] = useState(getOnlineStatus());
useEffect(() => {
const update = () => {
setIsOnline(navigator.onLine);
setIsOnline(getOnlineStatus());
};
update();
window.addEventListener('offline', update);
window.addEventListener('online', update);
window.Whisper.events.on('online', update);
window.Whisper.events.on('offline', update);
return () => {
window.removeEventListener('offline', update);
window.removeEventListener('online', update);
window.Whisper.events.off('online', update);
window.Whisper.events.off('offline', update);
};
}, []);

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type {
CheckNetworkStatusPayloadType,
SetNetworkStatusPayloadType,
NetworkActionType,
} from '../state/ducks/network';
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_NO_SERVICE_ADDR = '127.0.0.2';
enum OnlineStatus {
Online = 'Online',
MaybeOffline = 'MaybeOffline',
Offline = 'Offline',
}
const OFFLINE_DELAY = 5 * SECOND;
type NetworkActions = {
checkNetworkStatus: (x: CheckNetworkStatusPayloadType) => NetworkActionType;
closeConnectingGracePeriod: () => NetworkActionType;
setNetworkStatus: (x: SetNetworkStatusPayloadType) => NetworkActionType;
setOutage: (isOutage: boolean) => NetworkActionType;
};
@ -28,11 +35,13 @@ export function initializeNetworkObserver(
): void {
log.info('Initializing network observer');
let onlineStatus = OnlineStatus.Online;
const refresh = () => {
const socketStatus = getSocketStatus();
networkActions.checkNetworkStatus({
isOnline: navigator.onLine,
networkActions.setNetworkStatus({
isOnline: onlineStatus !== OnlineStatus.Offline,
socketStatus,
});
@ -89,12 +98,27 @@ export function initializeNetworkObserver(
networkActions.setOutage(false);
};
window.Whisper.events.on('socketStatusChange', refresh);
window.Whisper.events.on('socketConnectError', onPotentialOutage);
let offlineTimer: NodeJS.Timeout | undefined;
window.addEventListener('online', refresh);
window.addEventListener('offline', refresh);
window.setTimeout(() => {
networkActions.closeConnectingGracePeriod();
}, 5 * SECOND);
window.Whisper.events.on('socketStatusChange', refresh);
window.Whisper.events.on('online', () => {
onlineStatus = OnlineStatus.Online;
if (offlineTimer) {
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>
> = new Map();
private navigator?: { onLine: boolean };
private onlineEventTarget?: EventTarget;
private events?: Pick<typeof window.Whisper.events, 'on' | 'off'>;
private storage?: StorageInterface;
initialize({
server,
navigator,
onlineEventTarget,
events,
storage,
}: {
server: WebAPIType;
navigator: Readonly<{ onLine: boolean }>;
onlineEventTarget: EventTarget;
events?: Pick<typeof window.Whisper.events, 'on' | 'off'>;
storage: StorageInterface;
}): void {
log.info('Sender certificate service initialized');
this.server = server;
this.navigator = navigator;
this.onlineEventTarget = onlineEventTarget;
this.events = events;
this.storage = storage;
}
@ -150,9 +145,9 @@ export class SenderCertificateService {
private async fetchAndSaveCertificate(
mode: SenderCertificateMode
): Promise<undefined | SerializedCertificateType> {
const { storage, navigator, onlineEventTarget } = this;
const { storage, server, events } = this;
assertDev(
storage && navigator && onlineEventTarget,
storage && server && events,
'Sender certificate service method was called before it was initialized'
);
@ -162,7 +157,7 @@ export class SenderCertificateService {
)} certificate`
);
await waitForOnline(navigator, onlineEventTarget);
await waitForOnline({ server, events });
let certificateString: string;
try {

View file

@ -477,7 +477,12 @@ const doGroupCallPeek = ({
// 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
// 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;
try {

View file

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

View file

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

View file

@ -20,11 +20,13 @@ describe('"are we a subscriber?" service', () => {
sandbox = sinon.createSandbox();
service = new AreWeASubscriberService();
sandbox.stub(navigator, 'onLine').get(() => true);
});
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 = {
...fakeStorageDefaults,
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 => {
const fakeServer = { getHasSubscription: sandbox.stub() };
const fakeServer = {
getHasSubscription: sandbox.stub(),
isOnline: () => true,
};
const fakeStorage = {
...fakeStorageDefaults,
get: () => undefined,
@ -53,7 +58,10 @@ describe('"are we a subscriber?" service', () => {
});
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 = {
...fakeStorageDefaults,
put: sandbox
@ -72,7 +80,10 @@ describe('"are we a subscriber?" service', () => {
});
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 = {
...fakeStorageDefaults,
put: sandbox.stub().callsFake((key, value) => {
@ -86,7 +97,10 @@ describe('"are we a subscriber?" service', () => {
});
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 = {
...fakeStorageDefaults,
put: sandbox.stub().callsFake((key, value) => {
@ -103,7 +117,10 @@ describe('"are we a subscriber?" service', () => {
const allDone = explodePromise<void>();
let putCallCount = 0;
const fakeServer = { getHasSubscription: sandbox.stub().resolves(false) };
const fakeServer = {
getHasSubscription: sandbox.stub().resolves(false),
isOnline: () => true,
};
const fakeStorage = {
...fakeStorageDefaults,
put: sandbox.stub().callsFake(() => {

View file

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

View file

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

View file

@ -10,9 +10,14 @@ import EventListener from 'events';
import { AbortableProcess } from '../util/AbortableProcess';
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 { sleep } from '../util/sleep';
import { drop } from '../util/drop';
import { createProxyAgent } from '../util/createProxyAgent';
import { SocketStatus } from '../types/SocketStatus';
import * as Errors from '../types/errors';
@ -38,6 +43,7 @@ const FIVE_MINUTES = 5 * durations.MINUTE;
const JITTER = 5 * durations.SECOND;
const OFFLINE_KEEPALIVE_TIMEOUT_MS = 5 * durations.SECOND;
export const UNAUTHENTICATED_CHANNEL_NAME = 'unauthenticated';
export const AUTHENTICATED_CHANNEL_NAME = 'authenticated';
@ -86,10 +92,16 @@ export class SocketManager extends EventListener {
private incomingRequestQueue = new Array<IncomingWebSocketRequest>();
private isOffline = false;
private isNavigatorOffline = false;
private privIsOnline: boolean | undefined;
private isRemotelyExpired = false;
private hasStoriesDisabled: boolean;
private reconnectController: AbortController | undefined;
constructor(private readonly options: SocketManagerOptions) {
super();
@ -107,8 +119,8 @@ export class SocketManager extends EventListener {
// Update WebAPICredentials and reconnect authenticated resource if
// credentials changed
public async authenticate(credentials: WebAPICredentials): Promise<void> {
if (this.isOffline) {
throw new HTTPError('SocketManager offline', {
if (this.isRemotelyExpired) {
throw new HTTPError('SocketManager remotely expired', {
code: 0,
headers: {},
stack: new Error().stack,
@ -169,6 +181,11 @@ export class SocketManager extends EventListener {
this.authenticated = process;
const reconnect = async (): Promise<void> => {
if (this.isRemotelyExpired) {
log.info('SocketManager: remotely expired, not reconnecting');
return;
}
const timeout = this.backOff.getAndIncrement();
log.info(
@ -176,14 +193,22 @@ export class SocketManager extends EventListener {
`after ${timeout}ms`
);
await sleep(timeout);
if (this.isOffline) {
log.info('SocketManager: cancelled reconnect because we are offline');
const reconnectController = new AbortController();
this.reconnectController = reconnectController;
try {
await sleep(timeout, reconnectController.signal);
} catch {
log.info('SocketManager: reconnect cancelled');
return;
} finally {
if (this.reconnectController === reconnectController) {
this.reconnectController = undefined;
}
}
if (this.authenticated) {
log.info('SocketManager: authenticated socket already reconnected');
log.info('SocketManager: authenticated socket already connecting');
return;
}
@ -230,12 +255,13 @@ export class SocketManager extends EventListener {
return;
}
if (code === -1) {
this.emit('connectError');
if (code === -1 && this.privIsOnline !== false) {
this.privIsOnline = false;
this.emit('offline');
}
}
void reconnect();
drop(reconnect());
return;
}
@ -267,7 +293,7 @@ export class SocketManager extends EventListener {
return;
}
void reconnect();
drop(reconnect());
});
}
@ -287,6 +313,10 @@ export class SocketManager extends EventListener {
public async getProvisioningResource(
handler: IRequestHandler
): Promise<IWebSocketResource> {
if (this.isRemotelyExpired) {
throw new Error('Remotely expired, not connecting provisioning socket');
}
return this.connectResource({
name: 'provisioning',
path: '/v1/websocket/provisioning/',
@ -397,40 +427,6 @@ export class SocketManager extends EventListener {
public async reconnect(): Promise<void> {
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;
if (authenticated) {
@ -441,6 +437,54 @@ export class SocketManager extends EventListener {
unauthenticated.abort();
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> {
@ -453,6 +497,10 @@ export class SocketManager extends EventListener {
this.credentials = undefined;
}
public get isOnline(): boolean {
return this.privIsOnline !== false;
}
//
// Private
//
@ -464,6 +512,11 @@ export class SocketManager extends EventListener {
this.status = status;
this.emit('statusChange');
if (this.status === SocketStatus.OPEN && !this.privIsOnline) {
this.privIsOnline = true;
this.emit('online');
}
}
private transportOption(): TransportOption {
@ -522,17 +575,19 @@ export class SocketManager extends EventListener {
}
private async getUnauthenticatedResource(): Promise<IWebSocketResource> {
if (this.isOffline) {
throw new HTTPError('SocketManager offline', {
if (this.unauthenticated) {
return this.unauthenticated.getResult();
}
if (this.isRemotelyExpired) {
throw new HTTPError('SocketManager remotely expired', {
code: 0,
headers: {},
stack: new Error().stack,
});
}
if (this.unauthenticated) {
return this.unauthenticated.getResult();
}
log.info('SocketManager: connecting unauthenticated socket');
const transportOption = this.transportOption();
log.info(
@ -631,7 +686,7 @@ export class SocketManager extends EventListener {
});
}
private static async checkResource(
private async checkResource(
process?: AbortableProcess<IWebSocketResource>
): Promise<void> {
if (!process) {
@ -639,7 +694,11 @@ export class SocketManager extends EventListener {
}
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(
@ -770,7 +829,8 @@ export class SocketManager extends EventListener {
callback: (error: HTTPError) => 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(
type: string | symbol,
@ -782,7 +842,8 @@ export class SocketManager extends EventListener {
public override emit(type: 'authError', error: HTTPError): 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
public override emit(type: string | symbol, ...args: Array<any>): boolean {

View file

@ -94,17 +94,17 @@ export class UpdateKeysListener {
}
private runWhenOnline() {
if (window.navigator.onLine) {
if (window.textsecure.server?.isOnline()) {
void this.run();
} else {
log.info(
'UpdateKeysListener: We are offline; will update keys when we are next online'
);
const listener = () => {
window.removeEventListener('online', listener);
window.Whisper.events.off('online', listener);
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;
onHasStoriesDisabledChange: (newValue: boolean) => void;
checkSockets: () => void;
onOnline: () => Promise<void>;
onOffline: () => void;
isOnline: () => boolean;
onNavigatorOnline: () => Promise<void>;
onNavigatorOffline: () => Promise<void>;
onRemoteExpiration: () => Promise<void>;
reconnect: () => Promise<void>;
};
@ -1321,12 +1323,16 @@ export function initialize({
window.Whisper.events.trigger('socketStatusChange');
});
socketManager.on('authError', () => {
window.Whisper.events.trigger('unlinkAndDisconnect');
socketManager.on('online', () => {
window.Whisper.events.trigger('online');
});
socketManager.on('connectError', () => {
window.Whisper.events.trigger('socketConnectError');
socketManager.on('offline', () => {
window.Whisper.events.trigger('offline');
});
socketManager.on('authError', () => {
window.Whisper.events.trigger('unlinkAndDisconnect');
});
if (useWebSocket) {
@ -1442,8 +1448,10 @@ export function initialize({
modifyGroup,
modifyStorageRecords,
onHasStoriesDisabledChange,
onOffline,
onOnline,
isOnline,
onNavigatorOffline,
onNavigatorOnline,
onRemoteExpiration,
postBatchIdentityCheck,
putEncryptedAttachment,
putProfile,
@ -1620,12 +1628,20 @@ export function initialize({
void socketManager.check();
}
async function onOnline(): Promise<void> {
await socketManager.onOnline();
function isOnline(): boolean {
return socketManager.isOnline;
}
function onOffline(): void {
socketManager.onOffline();
async function onNavigatorOnline(): Promise<void> {
await socketManager.onNavigatorOnline();
}
async function onNavigatorOffline(): Promise<void> {
await socketManager.onNavigatorOffline();
}
async function onRemoteExpiration(): Promise<void> {
await socketManager.onRemoteExpiration();
}
async function reconnect(): Promise<void> {

View file

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

View file

@ -596,8 +596,13 @@ async function doDownloadStickerPack(
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
const attemptIncrement = navigator.onLine ? 1 : 0;
const attemptIncrement = server.isOnline() ? 1 : 0;
const downloadAttempts =
(existing ? existing.downloadAttempts || 0 : 0) + attemptIncrement;
if (downloadAttempts > 3) {

View file

@ -15,6 +15,17 @@ export const FIBONACCI_TIMEOUTS: ReadonlyArray<number> = [
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<{
jitter?: number;
@ -28,7 +39,7 @@ export class BackOff {
private count = 0;
constructor(
private readonly timeouts: ReadonlyArray<number>,
private timeouts: ReadonlyArray<number>,
private readonly options: BackOffOptionsType = {}
) {}
@ -53,7 +64,10 @@ export class BackOff {
return result;
}
public reset(): void {
public reset(newTimeouts?: ReadonlyArray<number>): void {
if (newTimeouts !== undefined) {
this.timeouts = newTimeouts;
}
this.count = 0;
}

View file

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