Faster WebSocket reconnects

This commit is contained in:
Fedor Indutny 2021-06-09 15:28:54 -07:00 committed by GitHub
parent 3cac4a19e1
commit 17e6ec468e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 940 additions and 677 deletions

View File

@ -168,15 +168,15 @@
this.interval = setInterval(() => { this.interval = setInterval(() => {
const status = window.getSocketStatus(); const status = window.getSocketStatus();
switch (status) { switch (status) {
case WebSocket.CONNECTING: case 'CONNECTING':
break; break;
case WebSocket.OPEN: case 'OPEN':
clearInterval(this.interval); clearInterval(this.interval);
// if we've connected, we can wait for real empty event // if we've connected, we can wait for real empty event
this.interval = null; this.interval = null;
break; break;
case WebSocket.CLOSING: case 'CLOSING':
case WebSocket.CLOSED: case 'CLOSED':
clearInterval(this.interval); clearInterval(this.interval);
this.interval = null; this.interval = null;
// if we failed to connect, we pretend we got an empty event // if we failed to connect, we pretend we got an empty event
@ -184,7 +184,7 @@
break; break;
default: default:
window.log.warn( window.log.warn(
'startConnectionListener: Found unexpected socket status; calling onEmpty() manually.' `startConnectionListener: Found unexpected socket status ${status}; calling onEmpty() manually.`
); );
this.onEmpty(); this.onEmpty();
break; break;

View File

@ -14,7 +14,12 @@ const fakeAPI = {
getAvatar: fakeCall, getAvatar: fakeCall,
getDevices: fakeCall, getDevices: fakeCall,
// getKeysForIdentifier : fakeCall, // getKeysForIdentifier : fakeCall,
getMessageSocket: () => new window.MockSocket('ws://localhost:8081/'), getMessageSocket: async () => ({
on() {},
removeListener() {},
close() {},
sendBytes() {},
}),
getMyKeys: fakeCall, getMyKeys: fakeCall,
getProfile: fakeCall, getProfile: fakeCall,
getProvisioningSocket: fakeCall, getProvisioningSocket: fakeCall,

View File

@ -37,7 +37,6 @@
<script type="text/javascript" src="crypto_test.js"></script> <script type="text/javascript" src="crypto_test.js"></script>
<script type="text/javascript" src="contacts_parser_test.js"></script> <script type="text/javascript" src="contacts_parser_test.js"></script>
<script type="text/javascript" src="generate_keys_test.js"></script> <script type="text/javascript" src="generate_keys_test.js"></script>
<script type="text/javascript" src="websocket-resources_test.js"></script>
<script type="text/javascript" src="task_with_timeout_test.js"></script> <script type="text/javascript" src="task_with_timeout_test.js"></script>
<script type="text/javascript" src="account_manager_test.js"></script> <script type="text/javascript" src="account_manager_test.js"></script>
<script type="text/javascript" src="message_receiver_test.js"></script> <script type="text/javascript" src="message_receiver_test.js"></script>

View File

@ -1,237 +0,0 @@
// Copyright 2015-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
describe('WebSocket-Resource', () => {
describe('requests and responses', () => {
it('receives requests and sends responses', done => {
// mock socket
const requestId = '1';
const socket = {
send(data) {
const message = window.textsecure.protobuf.WebSocketMessage.decode(
data
);
assert.strictEqual(
message.type,
window.textsecure.protobuf.WebSocketMessage.Type.RESPONSE
);
assert.strictEqual(message.response.message, 'OK');
assert.strictEqual(message.response.status, 200);
assert.strictEqual(message.response.id.toString(), requestId);
done();
},
addEventListener() {},
};
// actual test
this.resource = new window.textsecure.WebSocketResource(socket, {
handleRequest(request) {
assert.strictEqual(request.verb, 'PUT');
assert.strictEqual(request.path, '/some/path');
assertEqualArrayBuffers(
request.body.toArrayBuffer(),
window.Signal.Crypto.typedArrayToArrayBuffer(
new Uint8Array([1, 2, 3])
)
);
request.respond(200, 'OK');
},
});
// mock socket request
socket.onmessage({
data: new Blob([
new window.textsecure.protobuf.WebSocketMessage({
type: window.textsecure.protobuf.WebSocketMessage.Type.REQUEST,
request: {
id: requestId,
verb: 'PUT',
path: '/some/path',
body: window.Signal.Crypto.typedArrayToArrayBuffer(
new Uint8Array([1, 2, 3])
),
},
})
.encode()
.toArrayBuffer(),
]),
});
});
it('sends requests and receives responses', done => {
// mock socket and request handler
let requestId;
const socket = {
send(data) {
const message = window.textsecure.protobuf.WebSocketMessage.decode(
data
);
assert.strictEqual(
message.type,
window.textsecure.protobuf.WebSocketMessage.Type.REQUEST
);
assert.strictEqual(message.request.verb, 'PUT');
assert.strictEqual(message.request.path, '/some/path');
assertEqualArrayBuffers(
message.request.body.toArrayBuffer(),
window.Signal.Crypto.typedArrayToArrayBuffer(
new Uint8Array([1, 2, 3])
)
);
requestId = message.request.id;
},
addEventListener() {},
};
// actual test
const resource = new window.textsecure.WebSocketResource(socket);
resource.sendRequest({
verb: 'PUT',
path: '/some/path',
body: window.Signal.Crypto.typedArrayToArrayBuffer(
new Uint8Array([1, 2, 3])
),
error: done,
success(message, status) {
assert.strictEqual(message, 'OK');
assert.strictEqual(status, 200);
done();
},
});
// mock socket response
socket.onmessage({
data: new Blob([
new window.textsecure.protobuf.WebSocketMessage({
type: window.textsecure.protobuf.WebSocketMessage.Type.RESPONSE,
response: { id: requestId, message: 'OK', status: 200 },
})
.encode()
.toArrayBuffer(),
]),
});
});
});
describe('close', () => {
before(() => {
window.WebSocket = MockSocket;
});
after(() => {
window.WebSocket = WebSocket;
});
it('closes the connection', done => {
const mockServer = new MockServer('ws://localhost:8081');
mockServer.on('connection', server => {
server.on('close', done);
});
const resource = new window.textsecure.WebSocketResource(
new WebSocket('ws://localhost:8081')
);
resource.close();
});
});
describe.skip('with a keepalive config', function thisNeeded() {
before(() => {
window.WebSocket = MockSocket;
});
after(() => {
window.WebSocket = WebSocket;
});
this.timeout(60000);
it('sends keepalives once a minute', done => {
const mockServer = new MockServer('ws://localhost:8081');
mockServer.on('connection', server => {
server.on('message', data => {
const message = window.textsecure.protobuf.WebSocketMessage.decode(
data
);
assert.strictEqual(
message.type,
window.textsecure.protobuf.WebSocketMessage.Type.REQUEST
);
assert.strictEqual(message.request.verb, 'GET');
assert.strictEqual(message.request.path, '/v1/keepalive');
server.close();
done();
});
});
this.resource = new window.textsecure.WebSocketResource(
new WebSocket('ws://loc1alhost:8081'),
{
keepalive: { path: '/v1/keepalive' },
}
);
});
it('uses / as a default path', done => {
const mockServer = new MockServer('ws://localhost:8081');
mockServer.on('connection', server => {
server.on('message', data => {
const message = window.textsecure.protobuf.WebSocketMessage.decode(
data
);
assert.strictEqual(
message.type,
window.textsecure.protobuf.WebSocketMessage.Type.REQUEST
);
assert.strictEqual(message.request.verb, 'GET');
assert.strictEqual(message.request.path, '/');
server.close();
done();
});
});
this.resource = new window.textsecure.WebSocketResource(
new WebSocket('ws://localhost:8081'),
{
keepalive: true,
}
);
});
it('optionally disconnects if no response', function thisNeeded1(done) {
this.timeout(65000);
const mockServer = new MockServer('ws://localhost:8081');
const socket = new WebSocket('ws://localhost:8081');
mockServer.on('connection', server => {
server.on('close', done);
});
this.resource = new window.textsecure.WebSocketResource(socket, {
keepalive: true,
});
});
it('allows resetting the keepalive timer', function thisNeeded2(done) {
this.timeout(65000);
const mockServer = new MockServer('ws://localhost:8081');
const socket = new WebSocket('ws://localhost:8081');
const startTime = Date.now();
mockServer.on('connection', server => {
server.on('message', data => {
const message = window.textsecure.protobuf.WebSocketMessage.decode(
data
);
assert.strictEqual(
message.type,
window.textsecure.protobuf.WebSocketMessage.Type.REQUEST
);
assert.strictEqual(message.request.verb, 'GET');
assert.strictEqual(message.request.path, '/');
assert(
Date.now() > startTime + 60000,
'keepalive time should be longer than a minute'
);
server.close();
done();
});
});
const resource = new window.textsecure.WebSocketResource(socket, {
keepalive: true,
});
setTimeout(() => {
resource.resetKeepAliveTimer();
}, 5000);
});
});
});

View File

@ -123,6 +123,7 @@ const {
} = require('./ts/types/Settings'); } = require('./ts/types/Settings');
const { Environment } = require('./ts/environment'); const { Environment } = require('./ts/environment');
const { ChallengeMainHandler } = require('./ts/main/challengeMain'); const { ChallengeMainHandler } = require('./ts/main/challengeMain');
const { PowerChannel } = require('./ts/main/powerChannel');
const { maybeParseUrl, setUrlSearchParams } = require('./ts/util/url'); const { maybeParseUrl, setUrlSearchParams } = require('./ts/util/url');
const sql = new MainSQL(); const sql = new MainSQL();
@ -1265,6 +1266,14 @@ app.on('ready', async () => {
cleanupOrphanedAttachments, cleanupOrphanedAttachments,
}); });
sqlChannels.initialize(sql); sqlChannels.initialize(sql);
PowerChannel.initialize({
send(event) {
if (!mainWindow) {
return;
}
mainWindow.webContents.send(event);
},
});
// Run window preloading in parallel with database initialization. // Run window preloading in parallel with database initialization.
await createWindow(); await createWindow();

View File

@ -179,6 +179,15 @@ try {
ipc.on('challenge:response', (_event, response) => { ipc.on('challenge:response', (_event, response) => {
Whisper.events.trigger('challengeResponse', response); Whisper.events.trigger('challengeResponse', response);
}); });
ipc.on('power-channel:suspend', () => {
Whisper.events.trigger('powerMonitorSuspend');
});
ipc.on('power-channel:resume', () => {
Whisper.events.trigger('powerMonitorResume');
});
window.sendChallengeRequest = request => window.sendChallengeRequest = request =>
ipc.send('challenge:request', request); ipc.send('challenge:request', request);

View File

@ -11,6 +11,7 @@ import { DataMessageClass } from './textsecure.d';
import { MessageAttributesType } from './model-types.d'; import { MessageAttributesType } from './model-types.d';
import { WhatIsThis } from './window.d'; import { WhatIsThis } from './window.d';
import { getTitleBarVisibility, TitleBarVisibility } from './types/Settings'; import { getTitleBarVisibility, TitleBarVisibility } from './types/Settings';
import { SocketStatus } from './types/SocketStatus';
import { DEFAULT_CONVERSATION_COLOR } from './types/Colors'; import { DEFAULT_CONVERSATION_COLOR } from './types/Colors';
import { ChallengeHandler } from './challenge'; import { ChallengeHandler } from './challenge';
import { isWindowDragElement } from './util/isWindowDragElement'; import { isWindowDragElement } from './util/isWindowDragElement';
@ -38,6 +39,7 @@ import { connectToServerWithStoredCredentials } from './util/connectToServerWith
import * as universalExpireTimer from './util/universalExpireTimer'; import * as universalExpireTimer from './util/universalExpireTimer';
import { isDirectConversation, isGroupV2 } from './util/whatTypeOfConversation'; import { isDirectConversation, isGroupV2 } from './util/whatTypeOfConversation';
import { getSendOptions } from './util/getSendOptions'; import { getSendOptions } from './util/getSendOptions';
import { BackOff } from './util/BackOff';
const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000; const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000;
@ -96,6 +98,15 @@ export async function startApp(): Promise<void> {
resolveOnAppView = resolve; resolveOnAppView = resolve;
}); });
// Fibonacci timeouts
const reconnectBackOff = new BackOff([
5 * 1000,
10 * 1000,
15 * 1000,
25 * 1000,
40 * 1000,
]);
window.textsecure.protobuf.onLoad(() => { window.textsecure.protobuf.onLoad(() => {
window.storage.onready(() => { window.storage.onready(() => {
senderCertificateService.initialize({ senderCertificateService.initialize({
@ -302,15 +313,15 @@ export async function startApp(): Promise<void> {
}); });
let messageReceiver: WhatIsThis; let messageReceiver: WhatIsThis;
let preMessageReceiverStatus: WhatIsThis; let preMessageReceiverStatus: SocketStatus | undefined;
window.getSocketStatus = () => { window.getSocketStatus = () => {
if (messageReceiver) { if (messageReceiver) {
return messageReceiver.getStatus(); return messageReceiver.getStatus();
} }
if (window._.isNumber(preMessageReceiverStatus)) { if (preMessageReceiverStatus) {
return preMessageReceiverStatus; return preMessageReceiverStatus;
} }
return WebSocket.CLOSED; return SocketStatus.CLOSED;
}; };
window.Whisper.events = window._.clone(window.Backbone.Events); window.Whisper.events = window._.clone(window.Backbone.Events);
let accountManager: typeof window.textsecure.AccountManager; let accountManager: typeof window.textsecure.AccountManager;
@ -1549,6 +1560,19 @@ export async function startApp(): Promise<void> {
} }
}); });
window.Whisper.events.on('powerMonitorSuspend', () => {
window.log.info('powerMonitor: suspend');
});
window.Whisper.events.on('powerMonitorResume', () => {
window.log.info('powerMonitor: resume');
if (!messageReceiver) {
return;
}
messageReceiver.checkSocket();
});
const reconnectToWebSocketQueue = new LatestQueue(); const reconnectToWebSocketQueue = new LatestQueue();
const enqueueReconnectToWebSocket = () => { const enqueueReconnectToWebSocket = () => {
@ -1884,7 +1908,8 @@ export async function startApp(): Promise<void> {
function isSocketOnline() { function isSocketOnline() {
const socketStatus = window.getSocketStatus(); const socketStatus = window.getSocketStatus();
return ( return (
socketStatus === WebSocket.CONNECTING || socketStatus === WebSocket.OPEN socketStatus === SocketStatus.CONNECTING ||
socketStatus === SocketStatus.OPEN
); );
} }
@ -1937,7 +1962,7 @@ export async function startApp(): Promise<void> {
return; return;
} }
preMessageReceiverStatus = WebSocket.CONNECTING; preMessageReceiverStatus = SocketStatus.CONNECTING;
if (messageReceiver) { if (messageReceiver) {
await messageReceiver.stopProcessing(); await messageReceiver.stopProcessing();
@ -2020,7 +2045,7 @@ export async function startApp(): Promise<void> {
window.Signal.Services.initializeGroupCredentialFetcher(); window.Signal.Services.initializeGroupCredentialFetcher();
preMessageReceiverStatus = null; preMessageReceiverStatus = undefined;
// eslint-disable-next-line no-inner-declarations // eslint-disable-next-line no-inner-declarations
function addQueuedEventListener(name: string, handler: WhatIsThis) { function addQueuedEventListener(name: string, handler: WhatIsThis) {
@ -2258,6 +2283,8 @@ export async function startApp(): Promise<void> {
// Intentionally not awaiting // Intentionally not awaiting
challengeHandler.onOnline(); challengeHandler.onOnline();
reconnectBackOff.reset();
} finally { } finally {
connecting = false; connecting = false;
} }
@ -3380,8 +3407,10 @@ export async function startApp(): Promise<void> {
) { ) {
// Failed to connect to server // Failed to connect to server
if (navigator.onLine) { if (navigator.onLine) {
window.log.info('retrying in 1 minute'); const timeout = reconnectBackOff.getAndIncrement();
reconnectTimer = setTimeout(connect, 60000);
window.log.info(`retrying in ${timeout}ms`);
reconnectTimer = setTimeout(connect, timeout);
window.Whisper.events.trigger('reconnectTimer'); window.Whisper.events.trigger('reconnectTimer');

View File

@ -7,6 +7,7 @@ import { boolean, select } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { NetworkStatus } from './NetworkStatus'; import { NetworkStatus } from './NetworkStatus';
import { SocketStatus } from '../types/SocketStatus';
import { setup as setupI18n } from '../../js/modules/i18n'; import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json'; import enMessages from '../../_locales/en/messages.json';
@ -16,7 +17,7 @@ const defaultProps = {
hasNetworkDialog: true, hasNetworkDialog: true,
i18n, i18n,
isOnline: true, isOnline: true,
socketStatus: 0, socketStatus: SocketStatus.CONNECTING,
manualReconnect: action('manual-reconnect'), manualReconnect: action('manual-reconnect'),
withinConnectingGracePeriod: false, withinConnectingGracePeriod: false,
challengeStatus: 'idle' as const, challengeStatus: 'idle' as const,
@ -26,19 +27,19 @@ const permutations = [
{ {
title: 'Connecting', title: 'Connecting',
props: { props: {
socketStatus: 0, socketStatus: SocketStatus.CONNECTING,
}, },
}, },
{ {
title: 'Closing (online)', title: 'Closing (online)',
props: { props: {
socketStatus: 2, socketStatus: SocketStatus.CLOSING,
}, },
}, },
{ {
title: 'Closed (online)', title: 'Closed (online)',
props: { props: {
socketStatus: 3, socketStatus: SocketStatus.CLOSED,
}, },
}, },
{ {
@ -56,12 +57,12 @@ storiesOf('Components/NetworkStatus', module)
const socketStatus = select( const socketStatus = select(
'socketStatus', 'socketStatus',
{ {
CONNECTING: 0, CONNECTING: SocketStatus.CONNECTING,
OPEN: 1, OPEN: SocketStatus.OPEN,
CLOSING: 2, CLOSING: SocketStatus.CLOSING,
CLOSED: 3, CLOSED: SocketStatus.CLOSED,
}, },
0 SocketStatus.CONNECTING
); );
return ( return (

View File

@ -4,6 +4,7 @@
import React from 'react'; import React from 'react';
import { LocalizerType } from '../types/Util'; import { LocalizerType } from '../types/Util';
import { SocketStatus } from '../types/SocketStatus';
import { NetworkStateType } from '../state/ducks/network'; import { NetworkStateType } from '../state/ducks/network';
const FIVE_SECONDS = 5 * 1000; const FIVE_SECONDS = 5 * 1000;
@ -100,12 +101,12 @@ export const NetworkStatus = ({
let renderActionableButton; let renderActionableButton;
switch (socketStatus) { switch (socketStatus) {
case WebSocket.CONNECTING: case SocketStatus.CONNECTING:
subtext = i18n('connectingHangOn'); subtext = i18n('connectingHangOn');
title = i18n('connecting'); title = i18n('connecting');
break; break;
case WebSocket.CLOSED: case SocketStatus.CLOSED:
case WebSocket.CLOSING: case SocketStatus.CLOSING:
default: default:
renderActionableButton = manualReconnectButton; renderActionableButton = manualReconnectButton;
title = i18n('disconnected'); title = i18n('disconnected');

26
ts/main/powerChannel.ts Normal file
View File

@ -0,0 +1,26 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { powerMonitor } from 'electron';
export type InitializeOptions = {
send(event: string): void;
};
export class PowerChannel {
private static isInitialized = false;
static initialize({ send }: InitializeOptions): void {
if (PowerChannel.isInitialized) {
throw new Error('PowerChannel already initialized');
}
PowerChannel.isInitialized = true;
powerMonitor.on('suspend', () => {
send('power-channel:suspend');
});
powerMonitor.on('resume', () => {
send('power-channel:resume');
});
}
}

View File

@ -11,6 +11,7 @@ import {
} from '../util/zkgroup'; } from '../util/zkgroup';
import { GroupCredentialType } from '../textsecure/WebAPI'; import { GroupCredentialType } from '../textsecure/WebAPI';
import { BackOff } from '../util/BackOff';
import { sleep } from '../util/sleep'; import { sleep } from '../util/sleep';
export const GROUP_CREDENTIALS_KEY = 'groupCredentials'; export const GROUP_CREDENTIALS_KEY = 'groupCredentials';
@ -50,33 +51,28 @@ export async function initializeGroupCredentialFetcher(): Promise<void> {
await runWithRetry(maybeFetchNewCredentials, { scheduleAnother: 4 * HOUR }); await runWithRetry(maybeFetchNewCredentials, { scheduleAnother: 4 * HOUR });
} }
type BackoffType = { const BACKOFF_TIMEOUTS = [
[key: number]: number | undefined; SECOND,
max: number; 5 * SECOND,
}; 30 * SECOND,
const BACKOFF: BackoffType = { 2 * MINUTE,
0: SECOND, 5 * MINUTE,
1: 5 * SECOND, ];
2: 30 * SECOND,
3: 2 * MINUTE,
max: 5 * MINUTE,
};
export async function runWithRetry( export async function runWithRetry(
fn: () => Promise<void>, fn: () => Promise<void>,
options: { scheduleAnother?: number } = {} options: { scheduleAnother?: number } = {}
): Promise<void> { ): Promise<void> {
let count = 0; const backOff = new BackOff(BACKOFF_TIMEOUTS);
// eslint-disable-next-line no-constant-condition // eslint-disable-next-line no-constant-condition
while (true) { while (true) {
try { try {
count += 1;
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await fn(); await fn();
return; return;
} catch (error) { } catch (error) {
const wait = BACKOFF[count] || BACKOFF.max; const wait = backOff.getAndIncrement();
window.log.info( window.log.info(
`runWithRetry: ${fn.name} failed. Waiting ${wait}ms for retry. Error: ${error.stack}` `runWithRetry: ${fn.name} failed. Waiting ${wait}ms for retry. Error: ${error.stack}`
); );

View File

@ -30,6 +30,7 @@ import {
toGroupV2Record, toGroupV2Record,
} from './storageRecordOps'; } from './storageRecordOps';
import { ConversationModel } from '../models/conversations'; import { ConversationModel } from '../models/conversations';
import { BackOff } from '../util/BackOff';
import { storageJobQueue } from '../util/JobQueue'; import { storageJobQueue } from '../util/JobQueue';
import { sleep } from '../util/sleep'; import { sleep } from '../util/sleep';
import { isMoreRecentThan } from '../util/timestamp'; import { isMoreRecentThan } from '../util/timestamp';
@ -45,8 +46,6 @@ const {
updateConversation, updateConversation,
} = dataInterface; } = dataInterface;
let consecutiveStops = 0;
let consecutiveConflicts = 0;
const uploadBucket: Array<number> = []; const uploadBucket: Array<number> = [];
const validRecordTypes = new Set([ const validRecordTypes = new Set([
@ -57,24 +56,18 @@ const validRecordTypes = new Set([
4, // ACCOUNT 4, // ACCOUNT
]); ]);
type BackoffType = {
[key: number]: number | undefined;
max: number;
};
const SECOND = 1000; const SECOND = 1000;
const MINUTE = 60 * SECOND; const MINUTE = 60 * SECOND;
const BACKOFF: BackoffType = {
0: SECOND,
1: 5 * SECOND,
2: 30 * SECOND,
3: 2 * MINUTE,
max: 5 * MINUTE,
};
function backOff(count: number) { const backOff = new BackOff([
const ms = BACKOFF[count] || BACKOFF.max; SECOND,
return sleep(ms); 5 * SECOND,
} 30 * SECOND,
2 * MINUTE,
5 * MINUTE,
]);
const conflictBackOff = new BackOff([SECOND, 5 * SECOND, 30 * SECOND]);
function redactStorageID(storageID: string): string { function redactStorageID(storageID: string): string {
return storageID.substring(0, 3); return storageID.substring(0, 3);
@ -494,16 +487,15 @@ async function uploadManifest(
); );
if (err.code === 409) { if (err.code === 409) {
if (consecutiveConflicts > 3) { if (conflictBackOff.isFull()) {
window.log.error( window.log.error(
'storageService.uploadManifest: Exceeded maximum consecutive conflicts' 'storageService.uploadManifest: Exceeded maximum consecutive conflicts'
); );
return; return;
} }
consecutiveConflicts += 1;
window.log.info( window.log.info(
`storageService.uploadManifest: Conflict found with v${version}, running sync job times(${consecutiveConflicts})` `storageService.uploadManifest: Conflict found with v${version}, running sync job times(${conflictBackOff.getIndex()})`
); );
throw err; throw err;
@ -517,8 +509,8 @@ async function uploadManifest(
version version
); );
window.storage.put('manifestVersion', version); window.storage.put('manifestVersion', version);
consecutiveConflicts = 0; conflictBackOff.reset();
consecutiveStops = 0; backOff.reset();
await window.textsecure.messaging.sendFetchManifestSyncMessage(); await window.textsecure.messaging.sendFetchManifestSyncMessage();
} }
@ -527,21 +519,21 @@ async function stopStorageServiceSync() {
await window.storage.remove('storageKey'); await window.storage.remove('storageKey');
if (consecutiveStops < 5) { if (backOff.isFull()) {
await backOff(consecutiveStops);
window.log.info( window.log.info(
'storageService.stopStorageServiceSync: requesting new keys' 'storageService.stopStorageServiceSync: too many consecutive stops'
); );
consecutiveStops += 1; return;
setTimeout(() => {
if (!window.textsecure.messaging) {
throw new Error(
'storageService.stopStorageServiceSync: We are offline!'
);
}
window.textsecure.messaging.sendRequestKeySyncMessage();
});
} }
await sleep(backOff.getAndIncrement());
window.log.info('storageService.stopStorageServiceSync: requesting new keys');
setTimeout(() => {
if (!window.textsecure.messaging) {
throw new Error('storageService.stopStorageServiceSync: We are offline!');
}
window.textsecure.messaging.sendRequestKeySyncMessage();
});
} }
async function createNewManifest() { async function createNewManifest() {
@ -976,7 +968,7 @@ async function processRemoteRecords(
return conflictCount; return conflictCount;
} }
consecutiveConflicts = 0; conflictBackOff.reset();
} catch (err) { } catch (err) {
window.log.error( window.log.error(
'storageService.processRemoteRecords: failed!', 'storageService.processRemoteRecords: failed!',
@ -1082,7 +1074,7 @@ async function upload(fromSync = false): Promise<void> {
window.log.info( window.log.info(
'storageService.upload: no storageKey, requesting new keys' 'storageService.upload: no storageKey, requesting new keys'
); );
consecutiveStops = 0; backOff.reset();
await window.textsecure.messaging.sendRequestKeySyncMessage(); await window.textsecure.messaging.sendRequestKeySyncMessage();
return; return;
} }
@ -1108,7 +1100,7 @@ async function upload(fromSync = false): Promise<void> {
await uploadManifest(version, generatedManifest); await uploadManifest(version, generatedManifest);
} catch (err) { } catch (err) {
if (err.code === 409) { if (err.code === 409) {
await backOff(consecutiveConflicts); await sleep(conflictBackOff.getAndIncrement());
window.log.info('storageService.upload: pushing sync on the queue'); window.log.info('storageService.upload: pushing sync on the queue');
// The sync job will check for conflicts and as part of that conflict // The sync job will check for conflicts and as part of that conflict
// check if an item needs sync and doesn't match with the remote record // check if an item needs sync and doesn't match with the remote record

View File

@ -1,7 +1,9 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
export function getSocketStatus(): number { import { SocketStatus } from '../types/SocketStatus';
export function getSocketStatus(): SocketStatus {
const { getSocketStatus: getMessageReceiverStatus } = window; const { getSocketStatus: getMessageReceiverStatus } = window;
return getMessageReceiverStatus(); return getMessageReceiverStatus();

View File

@ -98,7 +98,7 @@ export const actions = {
export function getEmptyState(): NetworkStateType { export function getEmptyState(): NetworkStateType {
return { return {
isOnline: navigator.onLine, isOnline: navigator.onLine,
socketStatus: WebSocket.OPEN, socketStatus: SocketStatus.OPEN,
withinConnectingGracePeriod: true, withinConnectingGracePeriod: true,
challengeStatus: 'idle', challengeStatus: 'idle',
}; };

View File

@ -6,6 +6,7 @@ import { createSelector } from 'reselect';
import { StateType } from '../reducer'; import { StateType } from '../reducer';
import { NetworkStateType } from '../ducks/network'; import { 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;
@ -18,9 +19,10 @@ export const hasNetworkDialog = createSelector(
): boolean => ): boolean =>
isRegistrationDone && isRegistrationDone &&
(!isOnline || (!isOnline ||
(socketStatus === WebSocket.CONNECTING && !withinConnectingGracePeriod) || (socketStatus === SocketStatus.CONNECTING &&
socketStatus === WebSocket.CLOSED || !withinConnectingGracePeriod) ||
socketStatus === WebSocket.CLOSING) socketStatus === SocketStatus.CLOSED ||
socketStatus === SocketStatus.CLOSING)
); );
export const isChallengePending = createSelector( export const isChallengePending = createSelector(

View File

@ -0,0 +1,45 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { BackOff } from '../../util/BackOff';
describe('BackOff', () => {
it('should return increasing timeouts', () => {
const b = new BackOff([1, 2, 3]);
assert.strictEqual(b.getIndex(), 0);
assert.strictEqual(b.isFull(), false);
assert.strictEqual(b.get(), 1);
assert.strictEqual(b.getAndIncrement(), 1);
assert.strictEqual(b.get(), 2);
assert.strictEqual(b.getIndex(), 1);
assert.strictEqual(b.isFull(), false);
assert.strictEqual(b.getAndIncrement(), 2);
assert.strictEqual(b.getIndex(), 2);
assert.strictEqual(b.isFull(), true);
assert.strictEqual(b.getAndIncrement(), 3);
assert.strictEqual(b.getIndex(), 2);
assert.strictEqual(b.isFull(), true);
assert.strictEqual(b.getAndIncrement(), 3);
assert.strictEqual(b.getIndex(), 2);
assert.strictEqual(b.isFull(), true);
});
it('should reset', () => {
const b = new BackOff([1, 2, 3]);
assert.strictEqual(b.getAndIncrement(), 1);
assert.strictEqual(b.getAndIncrement(), 2);
b.reset();
assert.strictEqual(b.getAndIncrement(), 1);
assert.strictEqual(b.getAndIncrement(), 2);
});
});

View File

@ -0,0 +1,264 @@
// Copyright 2015-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable
class-methods-use-this,
no-new,
@typescript-eslint/no-empty-function,
@typescript-eslint/no-explicit-any
*/
import { assert } from 'chai';
import * as sinon from 'sinon';
import EventEmitter from 'events';
import { connection as WebSocket } from 'websocket';
import WebSocketResource from '../textsecure/WebsocketResources';
describe('WebSocket-Resource', () => {
class FakeSocket extends EventEmitter {
public sendBytes(_: Uint8Array) {}
public close() {}
}
describe('requests and responses', () => {
it('receives requests and sends responses', done => {
// mock socket
const requestId = '1';
const socket = new FakeSocket();
sinon.stub(socket, 'sendBytes').callsFake((data: Uint8Array) => {
const message = window.textsecure.protobuf.WebSocketMessage.decode(
data
);
assert.strictEqual(
message.type,
window.textsecure.protobuf.WebSocketMessage.Type.RESPONSE
);
assert.strictEqual(message.response?.message, 'OK');
assert.strictEqual(message.response?.status, 200);
assert.strictEqual(message.response?.id.toString(), requestId);
done();
});
// actual test
new WebSocketResource(socket as WebSocket, {
handleRequest(request: any) {
assert.strictEqual(request.verb, 'PUT');
assert.strictEqual(request.path, '/some/path');
assert.ok(
window.Signal.Crypto.constantTimeEqual(
request.body.toArrayBuffer(),
window.Signal.Crypto.typedArrayToArrayBuffer(
new Uint8Array([1, 2, 3])
)
)
);
request.respond(200, 'OK');
},
});
// mock socket request
socket.emit('message', {
type: 'binary',
binaryData: new Uint8Array(
new window.textsecure.protobuf.WebSocketMessage({
type: window.textsecure.protobuf.WebSocketMessage.Type.REQUEST,
request: {
id: requestId,
verb: 'PUT',
path: '/some/path',
body: window.Signal.Crypto.typedArrayToArrayBuffer(
new Uint8Array([1, 2, 3])
),
},
})
.encode()
.toArrayBuffer()
),
});
});
it('sends requests and receives responses', done => {
// mock socket and request handler
let requestId: Long | undefined;
const socket = new FakeSocket();
sinon.stub(socket, 'sendBytes').callsFake((data: Uint8Array) => {
const message = window.textsecure.protobuf.WebSocketMessage.decode(
data
);
assert.strictEqual(
message.type,
window.textsecure.protobuf.WebSocketMessage.Type.REQUEST
);
assert.strictEqual(message.request?.verb, 'PUT');
assert.strictEqual(message.request?.path, '/some/path');
assert.ok(
window.Signal.Crypto.constantTimeEqual(
message.request?.body.toArrayBuffer(),
window.Signal.Crypto.typedArrayToArrayBuffer(
new Uint8Array([1, 2, 3])
)
)
);
requestId = message.request?.id;
});
// actual test
const resource = new WebSocketResource(socket as WebSocket);
resource.sendRequest({
verb: 'PUT',
path: '/some/path',
body: window.Signal.Crypto.typedArrayToArrayBuffer(
new Uint8Array([1, 2, 3])
),
error: done,
success(message: string, status: number) {
assert.strictEqual(message, 'OK');
assert.strictEqual(status, 200);
done();
},
});
// mock socket response
socket.emit('message', {
type: 'binary',
binaryData: new Uint8Array(
new window.textsecure.protobuf.WebSocketMessage({
type: window.textsecure.protobuf.WebSocketMessage.Type.RESPONSE,
response: { id: requestId, message: 'OK', status: 200 },
})
.encode()
.toArrayBuffer()
),
});
});
});
describe('close', () => {
it('closes the connection', done => {
const socket = new FakeSocket();
sinon.stub(socket, 'close').callsFake(() => done());
const resource = new WebSocketResource(socket as WebSocket);
resource.close();
});
});
describe('with a keepalive config', () => {
const NOW = Date.now();
beforeEach(function beforeEach() {
this.sandbox = sinon.createSandbox();
this.clock = this.sandbox.useFakeTimers({
now: NOW,
});
});
afterEach(function afterEach() {
this.sandbox.restore();
});
it('sends keepalives once a minute', function test(done) {
const socket = new FakeSocket();
sinon.stub(socket, 'sendBytes').callsFake(data => {
const message = window.textsecure.protobuf.WebSocketMessage.decode(
data
);
assert.strictEqual(
message.type,
window.textsecure.protobuf.WebSocketMessage.Type.REQUEST
);
assert.strictEqual(message.request?.verb, 'GET');
assert.strictEqual(message.request?.path, '/v1/keepalive');
done();
});
new WebSocketResource(socket as WebSocket, {
keepalive: { path: '/v1/keepalive' },
});
this.clock.next();
});
it('uses / as a default path', function test(done) {
const socket = new FakeSocket();
sinon.stub(socket, 'sendBytes').callsFake(data => {
const message = window.textsecure.protobuf.WebSocketMessage.decode(
data
);
assert.strictEqual(
message.type,
window.textsecure.protobuf.WebSocketMessage.Type.REQUEST
);
assert.strictEqual(message.request?.verb, 'GET');
assert.strictEqual(message.request?.path, '/');
done();
});
new WebSocketResource(socket as WebSocket, {
keepalive: true,
});
this.clock.next();
});
it('optionally disconnects if no response', function thisNeeded1(done) {
const socket = new FakeSocket();
sinon.stub(socket, 'close').callsFake(() => done());
new WebSocketResource(socket as WebSocket, {
keepalive: true,
});
// One to trigger send
this.clock.next();
// Another to trigger send timeout
this.clock.next();
});
it('allows resetting the keepalive timer', function thisNeeded2(done) {
const startTime = Date.now();
const socket = new FakeSocket();
sinon.stub(socket, 'sendBytes').callsFake(data => {
const message = window.textsecure.protobuf.WebSocketMessage.decode(
data
);
assert.strictEqual(
message.type,
window.textsecure.protobuf.WebSocketMessage.Type.REQUEST
);
assert.strictEqual(message.request?.verb, 'GET');
assert.strictEqual(message.request?.path, '/');
assert.strictEqual(
Date.now(),
startTime + 60000,
'keepalive time should be one minute'
);
done();
});
const resource = new WebSocketResource(socket as WebSocket, {
keepalive: true,
});
setTimeout(() => {
resource.keepalive?.reset();
}, 5000);
// Trigger setTimeout above
this.clock.next();
// Trigger sendBytes
this.clock.next();
});
});
});

View File

@ -199,109 +199,111 @@ export default class AccountManager extends EventTarget {
const queueTask = this.queueTask.bind(this); const queueTask = this.queueTask.bind(this);
const provisioningCipher = new ProvisioningCipher(); const provisioningCipher = new ProvisioningCipher();
let gotProvisionEnvelope = false; let gotProvisionEnvelope = false;
return provisioningCipher.getPublicKey().then( const pubKey = await provisioningCipher.getPublicKey();
async (pubKey: ArrayBuffer) =>
new Promise((resolve, reject) => { const socket = await getSocket();
const socket = getSocket();
socket.onclose = event => { window.log.info('provisioning socket open');
window.log.info('provisioning socket closed. Code:', event.code);
if (!gotProvisionEnvelope) { return new Promise((resolve, reject) => {
reject(new Error('websocket closed')); socket.on('close', (code, reason) => {
window.log.info(
`provisioning socket closed. Code: ${code} Reason: ${reason}`
);
if (!gotProvisionEnvelope) {
reject(new Error('websocket closed'));
}
});
const wsr = new WebSocketResource(socket, {
keepalive: { path: '/v1/keepalive/provisioning' },
handleRequest(request: IncomingWebSocketRequest) {
if (
request.path === '/v1/address' &&
request.verb === 'PUT' &&
request.body
) {
const proto = window.textsecure.protobuf.ProvisioningUuid.decode(
request.body
);
const { uuid } = proto;
if (!uuid) {
throw new Error('registerSecondDevice: expected a UUID');
} }
}; const url = getProvisioningUrl(uuid, pubKey);
socket.onopen = () => {
window.log.info('provisioning socket open');
};
const wsr = new WebSocketResource(socket, {
keepalive: { path: '/v1/keepalive/provisioning' },
handleRequest(request: IncomingWebSocketRequest) {
if (
request.path === '/v1/address' &&
request.verb === 'PUT' &&
request.body
) {
const proto = window.textsecure.protobuf.ProvisioningUuid.decode(
request.body
);
const { uuid } = proto;
if (!uuid) {
throw new Error('registerSecondDevice: expected a UUID');
}
const url = getProvisioningUrl(uuid, pubKey);
if (window.CI) { if (window.CI) {
window.CI.setProvisioningURL(url); window.CI.setProvisioningURL(url);
} }
setProvisioningUrl(url); setProvisioningUrl(url);
request.respond(200, 'OK'); request.respond(200, 'OK');
} else if ( } else if (
request.path === '/v1/message' && request.path === '/v1/message' &&
request.verb === 'PUT' && request.verb === 'PUT' &&
request.body request.body
) { ) {
const envelope = window.textsecure.protobuf.ProvisionEnvelope.decode( const envelope = window.textsecure.protobuf.ProvisionEnvelope.decode(
request.body, request.body,
'binary' 'binary'
); );
request.respond(200, 'OK'); request.respond(200, 'OK');
gotProvisionEnvelope = true; gotProvisionEnvelope = true;
wsr.close(); wsr.close();
resolve( resolve(
provisioningCipher provisioningCipher
.decrypt(envelope) .decrypt(envelope)
.then(async provisionMessage => .then(async provisionMessage =>
queueTask(async () => queueTask(async () =>
confirmNumber(provisionMessage.number).then( confirmNumber(provisionMessage.number).then(
async deviceName => { async deviceName => {
if ( if (
typeof deviceName !== 'string' || typeof deviceName !== 'string' ||
deviceName.length === 0 deviceName.length === 0
) { ) {
throw new Error( throw new Error(
'AccountManager.registerSecondDevice: Invalid device name' 'AccountManager.registerSecondDevice: Invalid device name'
); );
} }
if ( if (
!provisionMessage.number || !provisionMessage.number ||
!provisionMessage.provisioningCode || !provisionMessage.provisioningCode ||
!provisionMessage.identityKeyPair !provisionMessage.identityKeyPair
) { ) {
throw new Error( throw new Error(
'AccountManager.registerSecondDevice: Provision message was missing key data' 'AccountManager.registerSecondDevice: Provision message was missing key data'
); );
} }
return createAccount( return createAccount(
provisionMessage.number, provisionMessage.number,
provisionMessage.provisioningCode, provisionMessage.provisioningCode,
provisionMessage.identityKeyPair, provisionMessage.identityKeyPair,
provisionMessage.profileKey, provisionMessage.profileKey,
deviceName, deviceName,
provisionMessage.userAgent, provisionMessage.userAgent,
provisionMessage.readReceipts, provisionMessage.readReceipts,
{ uuid: provisionMessage.uuid } { uuid: provisionMessage.uuid }
)
.then(clearSessionsAndPreKeys)
.then(generateKeys)
.then(async (keys: GeneratedKeysType) =>
registerKeys(keys).then(async () =>
confirmKeys(keys)
)
)
.then(registrationDone);
}
) )
) .then(clearSessionsAndPreKeys)
.then(generateKeys)
.then(async (keys: GeneratedKeysType) =>
registerKeys(keys).then(async () =>
confirmKeys(keys)
)
)
.then(registrationDone);
}
) )
); )
} else { )
window.log.error('Unknown websocket message', request.path); );
} } else {
}, window.log.error('Unknown websocket message', request.path);
}); }
}) },
); });
});
} }
async refreshPreKeys() { async refreshPreKeys() {

View File

@ -10,9 +10,10 @@
/* eslint-disable max-classes-per-file */ /* eslint-disable max-classes-per-file */
/* eslint-disable no-restricted-syntax */ /* eslint-disable no-restricted-syntax */
import { isNumber, map, omit, noop } from 'lodash'; import { isNumber, map, omit } from 'lodash';
import PQueue from 'p-queue'; import PQueue from 'p-queue';
import { v4 as getGuid } from 'uuid'; import { v4 as getGuid } from 'uuid';
import { connection as WebSocket } from 'websocket';
import { z } from 'zod'; import { z } from 'zod';
import { import {
@ -41,6 +42,7 @@ import {
SignedPreKeys, SignedPreKeys,
} from '../LibSignalStores'; } from '../LibSignalStores';
import { BatcherType, createBatcher } from '../util/batcher'; import { BatcherType, createBatcher } from '../util/batcher';
import { sleep } from '../util/sleep';
import { parseIntOrThrow } from '../util/parseIntOrThrow'; import { parseIntOrThrow } from '../util/parseIntOrThrow';
import { Zone } from '../util/Zone'; import { Zone } from '../util/Zone';
import EventTarget from './EventTarget'; import EventTarget from './EventTarget';
@ -53,6 +55,7 @@ import Crypto from './Crypto';
import { deriveMasterKeyFromGroupV1, typedArrayToArrayBuffer } from '../Crypto'; import { deriveMasterKeyFromGroupV1, typedArrayToArrayBuffer } from '../Crypto';
import { ContactBuffer, GroupBuffer } from './ContactsParser'; import { ContactBuffer, GroupBuffer } from './ContactsParser';
import { isByteBufferEmpty } from '../util/isByteBufferEmpty'; import { isByteBufferEmpty } from '../util/isByteBufferEmpty';
import { SocketStatus } from '../types/SocketStatus';
import { import {
AttachmentPointerClass, AttachmentPointerClass,
@ -68,13 +71,12 @@ import {
} from '../textsecure.d'; } from '../textsecure.d';
import { ByteBufferClass } from '../window.d'; import { ByteBufferClass } from '../window.d';
import { WebSocket } from './WebSocket';
import { deriveGroupFields, MASTER_KEY_LENGTH } from '../groups'; import { deriveGroupFields, MASTER_KEY_LENGTH } from '../groups';
const GROUPV1_ID_LENGTH = 16; const GROUPV1_ID_LENGTH = 16;
const GROUPV2_ID_LENGTH = 32; const GROUPV2_ID_LENGTH = 32;
const RETRY_TIMEOUT = 2 * 60 * 1000; const RETRY_TIMEOUT = 2 * 60 * 1000;
const RECONNECT_DELAY = 1 * 1000;
const decryptionErrorTypeSchema = z const decryptionErrorTypeSchema = z
.object({ .object({
@ -169,7 +171,9 @@ enum TaskType {
} }
class MessageReceiverInner extends EventTarget { class MessageReceiverInner extends EventTarget {
_onClose?: (ev: any) => Promise<void>; _onClose?: (code: number, reason: string) => Promise<void>;
_onError?: (error: Error) => Promise<void>;
appQueue: PQueue; appQueue: PQueue;
@ -185,7 +189,7 @@ class MessageReceiverInner extends EventTarget {
deviceId?: number; deviceId?: number;
hasConnected?: boolean; hasConnected = false;
incomingQueue: PQueue; incomingQueue: PQueue;
@ -209,6 +213,8 @@ class MessageReceiverInner extends EventTarget {
socket?: WebSocket; socket?: WebSocket;
socketStatus = SocketStatus.CLOSED;
stoppingProcessing?: boolean; stoppingProcessing?: boolean;
username: string; username: string;
@ -304,7 +310,7 @@ class MessageReceiverInner extends EventTarget {
static arrayBufferToStringBase64 = (arrayBuffer: ArrayBuffer): string => static arrayBufferToStringBase64 = (arrayBuffer: ArrayBuffer): string =>
window.dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('base64'); window.dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('base64');
connect() { async connect(): Promise<void> {
if (this.calledClose) { if (this.calledClose) {
return; return;
} }
@ -322,17 +328,43 @@ class MessageReceiverInner extends EventTarget {
this.hasConnected = true; this.hasConnected = true;
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) { if (this.socket && this.socket.connected) {
this.socket.close(); this.socket.close();
this.socket = undefined;
if (this.wsr) { if (this.wsr) {
this.wsr.close(); this.wsr.close();
this.wsr = undefined;
} }
} }
this.socketStatus = SocketStatus.CONNECTING;
// initialize the socket and start listening for messages // initialize the socket and start listening for messages
this.socket = this.server.getMessageSocket(); try {
this.socket.onclose = this.onclose.bind(this); this.socket = await this.server.getMessageSocket();
this.socket.onerror = this.onerror.bind(this); } catch (error) {
this.socket.onopen = this.onopen.bind(this); this.socketStatus = SocketStatus.CLOSED;
const event = new Event('error');
event.error = error;
await this.dispatchAndWait(event);
return;
}
this.socketStatus = SocketStatus.OPEN;
window.log.info('websocket open');
window.logMessageReceiverConnect();
if (!this._onClose) {
this._onClose = this.onclose.bind(this);
}
if (!this._onError) {
this._onError = this.onerror.bind(this);
}
this.socket.on('close', this._onClose);
this.socket.on('error', this._onError);
this.wsr = new WebSocketResource(this.socket, { this.wsr = new WebSocketResource(this.socket, {
handleRequest: this.handleRequest.bind(this), handleRequest: this.handleRequest.bind(this),
keepalive: { keepalive: {
@ -342,7 +374,6 @@ class MessageReceiverInner extends EventTarget {
}); });
// Because sometimes the socket doesn't properly emit its close event // Because sometimes the socket doesn't properly emit its close event
this._onClose = this.onclose.bind(this);
if (this._onClose) { if (this._onClose) {
this.wsr.addEventListener('close', this._onClose); this.wsr.addEventListener('close', this._onClose);
} }
@ -362,9 +393,12 @@ class MessageReceiverInner extends EventTarget {
shutdown() { shutdown() {
if (this.socket) { if (this.socket) {
this.socket.onclose = noop; if (this._onClose) {
this.socket.onerror = noop; this.socket.removeListener('close', this._onClose);
this.socket.onopen = noop; }
if (this._onError) {
this.socket.removeListener('error', this._onError);
}
this.socket = undefined; this.socket = undefined;
} }
@ -380,6 +414,7 @@ class MessageReceiverInner extends EventTarget {
async close() { async close() {
window.log.info('MessageReceiver.close()'); window.log.info('MessageReceiver.close()');
this.calledClose = true; this.calledClose = true;
this.socketStatus = SocketStatus.CLOSING;
// Our WebSocketResource instance will close the socket and emit a 'close' event // Our WebSocketResource instance will close the socket and emit a 'close' event
// if the socket doesn't emit one quickly enough. // if the socket doesn't emit one quickly enough.
@ -392,13 +427,8 @@ class MessageReceiverInner extends EventTarget {
return this.drain(); return this.drain();
} }
onopen() { async onerror(error: Error): Promise<void> {
window.log.info('websocket open'); window.log.error('websocket error', error);
window.logMessageReceiverConnect();
}
onerror() {
window.log.error('websocket error');
} }
async dispatchAndWait(event: Event) { async dispatchAndWait(event: Event) {
@ -407,35 +437,41 @@ class MessageReceiverInner extends EventTarget {
return Promise.resolve(); return Promise.resolve();
} }
async onclose(ev: any) { async onclose(code: number, reason: string): Promise<void> {
window.log.info( window.log.info(
'websocket closed', 'websocket closed',
ev.code, code,
ev.reason || '', reason || '',
'calledClose:', 'calledClose:',
this.calledClose this.calledClose
); );
this.socketStatus = SocketStatus.CLOSED;
this.shutdown(); this.shutdown();
if (this.calledClose) { if (this.calledClose) {
return Promise.resolve(); return;
} }
if (ev.code === 3000) { if (code === 3000) {
return Promise.resolve(); return;
} }
if (ev.code === 3001) { if (code === 3001) {
this.onEmpty(); this.onEmpty();
} }
// possible 403 or network issue. Make an request to confirm
return this.server await sleep(RECONNECT_DELAY);
.getDevices()
.then(this.connect.bind(this)) // No HTTP error? Reconnect // Try to reconnect (if there is an error - we'll get an
.catch(async e => { // `error` event from `connect()` and hit the retry backoff logic in
const event = new Event('error'); // `ts/background.ts`)
event.error = e; await this.connect();
return this.dispatchAndWait(event); }
});
checkSocket(): void {
if (this.wsr) {
this.wsr.forceKeepAlive();
}
} }
handleRequest(request: IncomingWebSocketRequest) { handleRequest(request: IncomingWebSocketRequest) {
@ -1076,14 +1112,8 @@ class MessageReceiverInner extends EventTarget {
throw new Error('Received message with no content and no legacyMessage'); throw new Error('Received message with no content and no legacyMessage');
} }
getStatus() { getStatus(): SocketStatus {
if (this.socket) { return this.socketStatus;
return this.socket.readyState;
}
if (this.hasConnected) {
return WebSocket.CLOSED;
}
return -1;
} }
async onDeliveryReceipt(envelope: EnvelopeClass): Promise<void> { async onDeliveryReceipt(envelope: EnvelopeClass): Promise<void> {
@ -2693,6 +2723,7 @@ export default class MessageReceiver {
this.hasEmptied = inner.hasEmptied.bind(inner); this.hasEmptied = inner.hasEmptied.bind(inner);
this.removeEventListener = inner.removeEventListener.bind(inner); this.removeEventListener = inner.removeEventListener.bind(inner);
this.stopProcessing = inner.stopProcessing.bind(inner); this.stopProcessing = inner.stopProcessing.bind(inner);
this.checkSocket = inner.checkSocket.bind(inner);
this.unregisterBatchers = inner.unregisterBatchers.bind(inner); this.unregisterBatchers = inner.unregisterBatchers.bind(inner);
inner.connect(); inner.connect();
@ -2707,7 +2738,7 @@ export default class MessageReceiver {
attachment: AttachmentPointerClass attachment: AttachmentPointerClass
) => Promise<DownloadAttachmentType>; ) => Promise<DownloadAttachmentType>;
getStatus: () => number; getStatus: () => SocketStatus;
hasEmptied: () => boolean; hasEmptied: () => boolean;
@ -2717,6 +2748,8 @@ export default class MessageReceiver {
unregisterBatchers: () => void; unregisterBatchers: () => void;
checkSocket: () => void;
getProcessedCount: () => number; getProcessedCount: () => number;
static stringToArrayBuffer = MessageReceiverInner.stringToArrayBuffer; static stringToArrayBuffer = MessageReceiverInner.stringToArrayBuffer;

View File

@ -11,7 +11,7 @@
import fetch, { Response } from 'node-fetch'; import fetch, { Response } from 'node-fetch';
import ProxyAgent from 'proxy-agent'; import ProxyAgent from 'proxy-agent';
import { Agent } from 'https'; import { Agent, RequestOptions } from 'https';
import pProps from 'p-props'; import pProps from 'p-props';
import { import {
compact, compact,
@ -25,9 +25,11 @@ import { pki } from 'node-forge';
import is from '@sindresorhus/is'; import is from '@sindresorhus/is';
import PQueue from 'p-queue'; import PQueue from 'p-queue';
import { v4 as getGuid } from 'uuid'; import { v4 as getGuid } from 'uuid';
import { client as WebSocketClient, connection as WebSocket } from 'websocket';
import { z } from 'zod'; import { z } from 'zod';
import { Long } from '../window.d'; import { Long } from '../window.d';
import { assert } from '../util/assert';
import { getUserAgent } from '../util/getUserAgent'; import { getUserAgent } from '../util/getUserAgent';
import { toWebSafeBase64 } from '../util/webSafeBase64'; import { toWebSafeBase64 } from '../util/webSafeBase64';
import { isPackIdValid, redactPackId } from '../../js/modules/stickers'; import { isPackIdValid, redactPackId } from '../../js/modules/stickers';
@ -59,7 +61,6 @@ import {
StorageServiceCredentials, StorageServiceCredentials,
} from '../textsecure.d'; } from '../textsecure.d';
import { WebSocket } from './WebSocket';
import MessageSender from './SendMessage'; import MessageSender from './SendMessage';
// Note: this will break some code that expects to be able to use err.response when a // Note: this will break some code that expects to be able to use err.response when a
@ -261,31 +262,85 @@ function _validateResponse(response: any, schema: any) {
return true; return true;
} }
function _createSocket( export type ConnectSocketOptions = Readonly<{
certificateAuthority: string;
proxyUrl?: string;
version: string;
timeout?: number;
}>;
const TEN_SECONDS = 1000 * 10;
async function _connectSocket(
url: string, url: string,
{ {
certificateAuthority, certificateAuthority,
proxyUrl, proxyUrl,
version, version,
}: { certificateAuthority: string; proxyUrl?: string; version: string } timeout = TEN_SECONDS,
) { }: ConnectSocketOptions
let requestOptions; ): Promise<WebSocket> {
let tlsOptions: RequestOptions = {
ca: certificateAuthority,
};
if (proxyUrl) { if (proxyUrl) {
requestOptions = { tlsOptions = {
ca: certificateAuthority, ...tlsOptions,
agent: new ProxyAgent(proxyUrl), agent: new ProxyAgent(proxyUrl),
}; };
} else {
requestOptions = {
ca: certificateAuthority,
};
} }
const headers = { const headers = {
'User-Agent': getUserAgent(version), 'User-Agent': getUserAgent(version),
}; };
return new WebSocket(url, undefined, undefined, headers, requestOptions, { const client = new WebSocketClient({
tlsOptions,
maxReceivedFrameSize: 0x210000, maxReceivedFrameSize: 0x210000,
}); });
client.connect(url, undefined, undefined, headers);
const { stack } = new Error();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('Connection timed out'));
client.abort();
}, timeout);
client.on('connect', socket => {
clearTimeout(timer);
resolve(socket);
});
client.on('httpResponse', async response => {
clearTimeout(timer);
const statusCode = response.statusCode || -1;
await _handleStatusCode(statusCode);
const error = makeHTTPError(
'promiseAjax: invalid websocket response',
statusCode || -1,
{}, // headers
undefined,
stack
);
const translatedError = _translateError(error);
assert(
translatedError,
'`httpResponse` event cannot be emitted with 200 status code'
);
reject(translatedError);
});
client.on('connectFailed', error => {
clearTimeout(timer);
reject(error);
});
});
} }
const FIVE_MINUTES = 1000 * 60 * 5; const FIVE_MINUTES = 1000 * 60 * 5;
@ -403,6 +458,56 @@ function getHostname(url: string): string {
return urlObject.hostname; return urlObject.hostname;
} }
async function _handleStatusCode(
status: number,
unauthenticated = false
): Promise<void> {
if (status === 499) {
window.log.error('Got 499 from Signal Server. Build is expired.');
await window.storage.put('remoteBuildExpiration', Date.now());
window.reduxActions.expiration.hydrateExpirationStatus(true);
}
if (!unauthenticated && status === 401) {
window.log.error('Got 401 from Signal Server. We might be unlinked.');
window.Whisper.events.trigger('mightBeUnlinked');
}
}
function _translateError(error: Error): Error | undefined {
const { code } = error;
if (code === 200) {
// Happens sometimes when we get no response. Might be nice to get 204 instead.
return undefined;
}
let message: string;
switch (code) {
case -1:
message =
'Failed to connect to the server, please check your network connection.';
break;
case 413:
message = 'Rate limit exceeded, please try again later.';
break;
case 403:
message = 'Invalid code, please try again.';
break;
case 417:
message = 'Number already registered.';
break;
case 401:
message =
'Invalid authentication, most likely someone re-registered and invalidated our registration.';
break;
case 404:
message = 'Number is not registered.';
break;
default:
message = 'The server rejected our query, please file a bug report.';
}
error.message = `${message} (original: ${error.message})`;
return error;
}
async function _promiseAjax( async function _promiseAjax(
providedUrl: string | null, providedUrl: string | null,
options: PromiseAjaxOptionsType options: PromiseAjaxOptionsType
@ -487,25 +592,11 @@ async function _promiseAjax(
fetch(url, fetchOptions) fetch(url, fetchOptions)
.then(async response => { .then(async response => {
if (options.serverUrl) { if (
if ( options.serverUrl &&
response.status === 499 && getHostname(options.serverUrl) === getHostname(url)
getHostname(options.serverUrl) === getHostname(url) ) {
) { await _handleStatusCode(response.status, unauthenticated);
window.log.error('Got 499 from Signal Server. Build is expired.');
await window.storage.put('remoteBuildExpiration', Date.now());
window.reduxActions.expiration.hydrateExpirationStatus(true);
}
if (
!unauthenticated &&
response.status === 401 &&
getHostname(options.serverUrl) === getHostname(url)
) {
window.log.error(
'Got 401 from Signal Server. We might be unlinked.'
);
window.Whisper.events.trigger('mightBeUnlinked');
}
} }
let resultPromise; let resultPromise;
@ -863,7 +954,7 @@ export type WebAPIType = {
deviceId?: number, deviceId?: number,
options?: { accessKey?: string } options?: { accessKey?: string }
) => Promise<ServerKeysType>; ) => Promise<ServerKeysType>;
getMessageSocket: () => WebSocket; getMessageSocket: () => Promise<WebSocket>;
getMyKeys: () => Promise<number>; getMyKeys: () => Promise<number>;
getProfile: ( getProfile: (
identifier: string, identifier: string,
@ -880,7 +971,7 @@ export type WebAPIType = {
profileKeyCredentialRequest?: string; profileKeyCredentialRequest?: string;
} }
) => Promise<any>; ) => Promise<any>;
getProvisioningSocket: () => WebSocket; getProvisioningSocket: () => Promise<WebSocket>;
getSenderCertificate: ( getSenderCertificate: (
withUuid?: boolean withUuid?: boolean
) => Promise<{ certificate: string }>; ) => Promise<{ certificate: string }>;
@ -1153,39 +1244,10 @@ export function initialize({
unauthenticated: param.unauthenticated, unauthenticated: param.unauthenticated,
accessKey: param.accessKey, accessKey: param.accessKey,
}).catch((e: Error) => { }).catch((e: Error) => {
const { code } = e; const translatedError = _translateError(e);
if (code === 200) { if (translatedError) {
// Happens sometimes when we get no response. Might be nice to get 204 instead. throw translatedError;
return null;
} }
let message: string;
switch (code) {
case -1:
message =
'Failed to connect to the server, please check your network connection.';
break;
case 413:
message = 'Rate limit exceeded, please try again later.';
break;
case 403:
message = 'Invalid code, please try again.';
break;
case 417:
message = 'Number already registered.';
break;
case 401:
message =
'Invalid authentication, most likely someone re-registered and invalidated our registration.';
break;
case 404:
message = 'Number is not registered.';
break;
default:
message =
'The server rejected our query, please file a bug report.';
}
e.message = `${message} (original: ${e.message})`;
throw e;
}); });
} }
@ -2318,7 +2380,7 @@ export function initialize({
}; };
} }
function getMessageSocket() { function getMessageSocket(): Promise<WebSocket> {
window.log.info('opening message socket', url); window.log.info('opening message socket', url);
const fixedScheme = url const fixedScheme = url
.replace('https://', 'wss://') .replace('https://', 'wss://')
@ -2327,20 +2389,20 @@ export function initialize({
const pass = encodeURIComponent(password); const pass = encodeURIComponent(password);
const clientVersion = encodeURIComponent(version); const clientVersion = encodeURIComponent(version);
return _createSocket( return _connectSocket(
`${fixedScheme}/v1/websocket/?login=${login}&password=${pass}&agent=OWD&version=${clientVersion}`, `${fixedScheme}/v1/websocket/?login=${login}&password=${pass}&agent=OWD&version=${clientVersion}`,
{ certificateAuthority, proxyUrl, version } { certificateAuthority, proxyUrl, version }
); );
} }
function getProvisioningSocket() { function getProvisioningSocket(): Promise<WebSocket> {
window.log.info('opening provisioning socket', url); window.log.info('opening provisioning socket', url);
const fixedScheme = url const fixedScheme = url
.replace('https://', 'wss://') .replace('https://', 'wss://')
.replace('http://', 'ws://'); .replace('http://', 'ws://');
const clientVersion = encodeURIComponent(version); const clientVersion = encodeURIComponent(version);
return _createSocket( return _connectSocket(
`${fixedScheme}/v1/websocket/provisioning/?agent=OWD&version=${clientVersion}`, `${fixedScheme}/v1/websocket/provisioning/?agent=OWD&version=${clientVersion}`,
{ certificateAuthority, proxyUrl, version } { certificateAuthority, proxyUrl, version }
); );

View File

@ -1,22 +0,0 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { w3cwebsocket } from 'websocket';
type ModifiedEventSource = Omit<EventSource, 'onerror'>;
declare class ModifiedWebSocket
extends w3cwebsocket
implements ModifiedEventSource {
withCredentials: boolean;
addEventListener: EventSource['addEventListener'];
removeEventListener: EventSource['removeEventListener'];
dispatchEvent: EventSource['dispatchEvent'];
}
export type WebSocket = ModifiedWebSocket;
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const WebSocket = w3cwebsocket as typeof ModifiedWebSocket;

View File

@ -27,12 +27,12 @@
* *
*/ */
import { connection as WebSocket, IMessage } from 'websocket';
import { ByteBufferClass } from '../window.d'; import { ByteBufferClass } from '../window.d';
import EventTarget from './EventTarget'; import EventTarget from './EventTarget';
import { WebSocket } from './WebSocket';
class Request { class Request {
verb: string; verb: string;
@ -92,14 +92,13 @@ export class IncomingWebSocketRequest {
this.headers = request.headers; this.headers = request.headers;
this.respond = (status, message) => { this.respond = (status, message) => {
socket.send( const ab = new window.textsecure.protobuf.WebSocketMessage({
new window.textsecure.protobuf.WebSocketMessage({ type: window.textsecure.protobuf.WebSocketMessage.Type.RESPONSE,
type: window.textsecure.protobuf.WebSocketMessage.Type.RESPONSE, response: { id: request.id, message, status },
response: { id: request.id, message, status }, })
}) .encode()
.encode() .toArrayBuffer();
.toArrayBuffer() socket.sendBytes(Buffer.from(ab));
);
}; };
} }
} }
@ -111,20 +110,19 @@ class OutgoingWebSocketRequest {
constructor(options: any, socket: WebSocket) { constructor(options: any, socket: WebSocket) {
const request = new Request(options); const request = new Request(options);
outgoing[request.id] = request; outgoing[request.id] = request;
socket.send( const ab = new window.textsecure.protobuf.WebSocketMessage({
new window.textsecure.protobuf.WebSocketMessage({ type: window.textsecure.protobuf.WebSocketMessage.Type.REQUEST,
type: window.textsecure.protobuf.WebSocketMessage.Type.REQUEST, request: {
request: { verb: request.verb,
verb: request.verb, path: request.path,
path: request.path, body: request.body,
body: request.body, headers: request.headers,
headers: request.headers, id: request.id,
id: request.id, },
}, })
}) .encode()
.encode() .toArrayBuffer();
.toArrayBuffer() socket.sendBytes(Buffer.from(ab));
);
} }
} }
@ -149,66 +147,58 @@ export default class WebSocketResource extends EventTarget {
this.sendRequest = options => new OutgoingWebSocketRequest(options, socket); this.sendRequest = options => new OutgoingWebSocketRequest(options, socket);
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
socket.onmessage = socketMessage => { const onMessage = ({ type, binaryData }: IMessage): void => {
const blob = socketMessage.data; if (type !== 'binary' || !binaryData) {
const handleArrayBuffer = (buffer: ArrayBuffer) => { throw new Error(`Unsupported websocket message type: ${type}`);
const message = window.textsecure.protobuf.WebSocketMessage.decode( }
buffer
const message = window.textsecure.protobuf.WebSocketMessage.decode(
binaryData
);
if (
message.type ===
window.textsecure.protobuf.WebSocketMessage.Type.REQUEST &&
message.request
) {
handleRequest(
new IncomingWebSocketRequest({
verb: message.request.verb,
path: message.request.path,
body: message.request.body,
headers: message.request.headers,
id: message.request.id,
socket,
})
); );
if ( } else if (
message.type === message.type ===
window.textsecure.protobuf.WebSocketMessage.Type.REQUEST && window.textsecure.protobuf.WebSocketMessage.Type.RESPONSE &&
message.request message.response
) { ) {
handleRequest( const { response } = message;
new IncomingWebSocketRequest({ const request = outgoing[response.id];
verb: message.request.verb, if (request) {
path: message.request.path, request.response = response;
body: message.request.body, let callback = request.error;
headers: message.request.headers, if (
id: message.request.id, response.status &&
socket, response.status >= 200 &&
}) response.status < 300
); ) {
} else if ( callback = request.success;
message.type ===
window.textsecure.protobuf.WebSocketMessage.Type.RESPONSE &&
message.response
) {
const { response } = message;
const request = outgoing[response.id];
if (request) {
request.response = response;
let callback = request.error;
if (
response.status &&
response.status >= 200 &&
response.status < 300
) {
callback = request.success;
}
if (typeof callback === 'function') {
callback(response.message, response.status, request);
}
} else {
throw new Error(
`Received response for unknown request ${message.response.id}`
);
} }
}
};
if (blob instanceof ArrayBuffer) { if (typeof callback === 'function') {
handleArrayBuffer(blob); callback(response.message, response.status, request);
} else { }
const reader = new FileReader(); } else {
reader.onload = () => { throw new Error(
handleArrayBuffer(reader.result as ArrayBuffer); `Received response for unknown request ${message.response.id}`
}; );
reader.readAsArrayBuffer(blob as any); }
} }
}; };
socket.on('message', onMessage);
if (opts.keepalive) { if (opts.keepalive) {
this.keepalive = new KeepAlive(this, { this.keepalive = new KeepAlive(this, {
@ -217,15 +207,13 @@ export default class WebSocketResource extends EventTarget {
}); });
const resetKeepAliveTimer = this.keepalive.reset.bind(this.keepalive); const resetKeepAliveTimer = this.keepalive.reset.bind(this.keepalive);
socket.addEventListener('open', resetKeepAliveTimer); this.keepalive.reset();
socket.addEventListener('message', resetKeepAliveTimer);
socket.addEventListener( socket.on('message', resetKeepAliveTimer);
'close', socket.on('close', this.keepalive.stop.bind(this.keepalive));
this.keepalive.stop.bind(this.keepalive)
);
} }
socket.addEventListener('close', () => { socket.on('close', () => {
this.closed = true; this.closed = true;
}); });
@ -242,7 +230,7 @@ export default class WebSocketResource extends EventTarget {
socket.close(code, reason); socket.close(code, reason);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
socket.onmessage = undefined; socket.removeListener('message', onMessage);
// On linux the socket can wait a long time to emit its close event if we've // On linux the socket can wait a long time to emit its close event if we've
// lost the internet connection. On the order of minutes. This speeds that // lost the internet connection. On the order of minutes. This speeds that
@ -261,6 +249,13 @@ export default class WebSocketResource extends EventTarget {
}, 5000); }, 5000);
}; };
} }
public forceKeepAlive(): void {
if (!this.keepalive) {
return;
}
this.keepalive.send();
}
} }
type KeepAliveOptionsType = { type KeepAliveOptionsType = {
@ -269,15 +264,15 @@ type KeepAliveOptionsType = {
}; };
class KeepAlive { class KeepAlive {
keepAliveTimer: any; private keepAliveTimer: NodeJS.Timeout | undefined;
disconnectTimer: any; private disconnectTimer: NodeJS.Timeout | undefined;
path: string; private path: string;
disconnect: boolean; private disconnect: boolean;
wsr: WebSocketResource; private wsr: WebSocketResource;
constructor( constructor(
websocketResource: WebSocketResource, websocketResource: WebSocketResource,
@ -292,30 +287,46 @@ class KeepAlive {
} }
} }
stop() { public stop(): void {
clearTimeout(this.keepAliveTimer); this.clearTimers();
clearTimeout(this.disconnectTimer);
} }
reset() { public send(): void {
clearTimeout(this.keepAliveTimer); this.clearTimers();
clearTimeout(this.disconnectTimer);
this.keepAliveTimer = setTimeout(() => { if (this.disconnect) {
if (this.disconnect) { // automatically disconnect if server doesn't ack
// automatically disconnect if server doesn't ack this.disconnectTimer = setTimeout(() => {
this.disconnectTimer = setTimeout(() => { this.clearTimers();
clearTimeout(this.keepAliveTimer);
this.wsr.close(3001, 'No response to keepalive request'); this.wsr.close(3001, 'No response to keepalive request');
}, 10000); }, 10000);
} else { } else {
this.reset(); this.reset();
} }
window.log.info('Sending a keepalive message');
this.wsr.sendRequest({ window.log.info('WebSocketResources: Sending a keepalive message');
verb: 'GET', this.wsr.sendRequest({
path: this.path, verb: 'GET',
success: this.reset.bind(this), path: this.path,
}); success: this.reset.bind(this),
}, 55000); });
}
public reset(): void {
this.clearTimers();
this.keepAliveTimer = setTimeout(() => this.send(), 55000);
}
private clearTimers(): void {
if (this.keepAliveTimer) {
clearTimeout(this.keepAliveTimer);
this.keepAliveTimer = undefined;
}
if (this.disconnectTimer) {
clearTimeout(this.disconnectTimer);
this.disconnectTimer = undefined;
}
} }
} }

View File

@ -4,8 +4,8 @@
// Maps to values found here: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState // Maps to values found here: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState
// which are returned by libtextsecure's MessageReceiver // which are returned by libtextsecure's MessageReceiver
export enum SocketStatus { export enum SocketStatus {
CONNECTING, CONNECTING = 'CONNECTING',
OPEN, OPEN = 'OPEN',
CLOSING, CLOSING = 'CLOSING',
CLOSED, CLOSED = 'CLOSED',
} }

33
ts/util/BackOff.ts Normal file
View File

@ -0,0 +1,33 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export class BackOff {
private count = 0;
constructor(private readonly timeouts: ReadonlyArray<number>) {}
public get(): number {
return this.timeouts[this.count];
}
public getAndIncrement(): number {
const result = this.get();
if (!this.isFull()) {
this.count += 1;
}
return result;
}
public reset(): void {
this.count = 0;
}
public isFull(): boolean {
return this.count === this.timeouts.length - 1;
}
public getIndex(): number {
return this.count;
}
}

3
ts/window.d.ts vendored
View File

@ -108,6 +108,7 @@ import { ElectronLocaleType } from './util/mapToSupportLocale';
import { SignalProtocolStore } from './SignalProtocolStore'; import { SignalProtocolStore } from './SignalProtocolStore';
import { StartupQueue } from './util/StartupQueue'; import { StartupQueue } from './util/StartupQueue';
import * as synchronousCrypto from './util/synchronousCrypto'; import * as synchronousCrypto from './util/synchronousCrypto';
import { SocketStatus } from './types/SocketStatus';
import SyncRequest from './textsecure/SyncRequest'; import SyncRequest from './textsecure/SyncRequest';
import { ConversationColorType, CustomColorType } from './types/Colors'; import { ConversationColorType, CustomColorType } from './types/Colors';
@ -190,7 +191,7 @@ declare global {
getNodeVersion: () => string; getNodeVersion: () => string;
getServerPublicParams: () => string; getServerPublicParams: () => string;
getSfuUrl: () => string; getSfuUrl: () => string;
getSocketStatus: () => number; getSocketStatus: () => SocketStatus;
getSyncRequest: (timeoutMillis?: number) => SyncRequest; getSyncRequest: (timeoutMillis?: number) => SyncRequest;
getTitle: () => string; getTitle: () => string;
waitForEmptyEventQueue: () => Promise<void>; waitForEmptyEventQueue: () => Promise<void>;