Faster WebSocket reconnects
This commit is contained in:
		
					parent
					
						
							
								3cac4a19e1
							
						
					
				
			
			
				commit
				
					
						17e6ec468e
					
				
			
		
					 25 changed files with 940 additions and 677 deletions
				
			
		|  | @ -168,15 +168,15 @@ | |||
|       this.interval = setInterval(() => { | ||||
|         const status = window.getSocketStatus(); | ||||
|         switch (status) { | ||||
|           case WebSocket.CONNECTING: | ||||
|           case 'CONNECTING': | ||||
|             break; | ||||
|           case WebSocket.OPEN: | ||||
|           case 'OPEN': | ||||
|             clearInterval(this.interval); | ||||
|             // if we've connected, we can wait for real empty event
 | ||||
|             this.interval = null; | ||||
|             break; | ||||
|           case WebSocket.CLOSING: | ||||
|           case WebSocket.CLOSED: | ||||
|           case 'CLOSING': | ||||
|           case 'CLOSED': | ||||
|             clearInterval(this.interval); | ||||
|             this.interval = null; | ||||
|             // if we failed to connect, we pretend we got an empty event
 | ||||
|  | @ -184,7 +184,7 @@ | |||
|             break; | ||||
|           default: | ||||
|             window.log.warn( | ||||
|               'startConnectionListener: Found unexpected socket status; calling onEmpty() manually.' | ||||
|               `startConnectionListener: Found unexpected socket status ${status}; calling onEmpty() manually.` | ||||
|             ); | ||||
|             this.onEmpty(); | ||||
|             break; | ||||
|  |  | |||
|  | @ -14,7 +14,12 @@ const fakeAPI = { | |||
|   getAvatar: fakeCall, | ||||
|   getDevices: fakeCall, | ||||
|   // getKeysForIdentifier : fakeCall,
 | ||||
|   getMessageSocket: () => new window.MockSocket('ws://localhost:8081/'), | ||||
|   getMessageSocket: async () => ({ | ||||
|     on() {}, | ||||
|     removeListener() {}, | ||||
|     close() {}, | ||||
|     sendBytes() {}, | ||||
|   }), | ||||
|   getMyKeys: fakeCall, | ||||
|   getProfile: fakeCall, | ||||
|   getProvisioningSocket: fakeCall, | ||||
|  |  | |||
|  | @ -37,7 +37,6 @@ | |||
|   <script type="text/javascript" src="crypto_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="websocket-resources_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="message_receiver_test.js"></script> | ||||
|  |  | |||
|  | @ -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); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										9
									
								
								main.js
									
										
									
									
									
								
							
							
						
						
									
										9
									
								
								main.js
									
										
									
									
									
								
							|  | @ -123,6 +123,7 @@ const { | |||
| } = require('./ts/types/Settings'); | ||||
| const { Environment } = require('./ts/environment'); | ||||
| const { ChallengeMainHandler } = require('./ts/main/challengeMain'); | ||||
| const { PowerChannel } = require('./ts/main/powerChannel'); | ||||
| const { maybeParseUrl, setUrlSearchParams } = require('./ts/util/url'); | ||||
| 
 | ||||
| const sql = new MainSQL(); | ||||
|  | @ -1265,6 +1266,14 @@ app.on('ready', async () => { | |||
|     cleanupOrphanedAttachments, | ||||
|   }); | ||||
|   sqlChannels.initialize(sql); | ||||
|   PowerChannel.initialize({ | ||||
|     send(event) { | ||||
|       if (!mainWindow) { | ||||
|         return; | ||||
|       } | ||||
|       mainWindow.webContents.send(event); | ||||
|     }, | ||||
|   }); | ||||
| 
 | ||||
|   // Run window preloading in parallel with database initialization.
 | ||||
|   await createWindow(); | ||||
|  |  | |||
|  | @ -179,6 +179,15 @@ try { | |||
|   ipc.on('challenge:response', (_event, 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 => | ||||
|     ipc.send('challenge:request', request); | ||||
| 
 | ||||
|  |  | |||
|  | @ -11,6 +11,7 @@ import { DataMessageClass } from './textsecure.d'; | |||
| import { MessageAttributesType } from './model-types.d'; | ||||
| import { WhatIsThis } from './window.d'; | ||||
| import { getTitleBarVisibility, TitleBarVisibility } from './types/Settings'; | ||||
| import { SocketStatus } from './types/SocketStatus'; | ||||
| import { DEFAULT_CONVERSATION_COLOR } from './types/Colors'; | ||||
| import { ChallengeHandler } from './challenge'; | ||||
| import { isWindowDragElement } from './util/isWindowDragElement'; | ||||
|  | @ -38,6 +39,7 @@ import { connectToServerWithStoredCredentials } from './util/connectToServerWith | |||
| import * as universalExpireTimer from './util/universalExpireTimer'; | ||||
| import { isDirectConversation, isGroupV2 } from './util/whatTypeOfConversation'; | ||||
| import { getSendOptions } from './util/getSendOptions'; | ||||
| import { BackOff } from './util/BackOff'; | ||||
| 
 | ||||
| const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000; | ||||
| 
 | ||||
|  | @ -96,6 +98,15 @@ export async function startApp(): Promise<void> { | |||
|     resolveOnAppView = resolve; | ||||
|   }); | ||||
| 
 | ||||
|   // Fibonacci timeouts
 | ||||
|   const reconnectBackOff = new BackOff([ | ||||
|     5 * 1000, | ||||
|     10 * 1000, | ||||
|     15 * 1000, | ||||
|     25 * 1000, | ||||
|     40 * 1000, | ||||
|   ]); | ||||
| 
 | ||||
|   window.textsecure.protobuf.onLoad(() => { | ||||
|     window.storage.onready(() => { | ||||
|       senderCertificateService.initialize({ | ||||
|  | @ -302,15 +313,15 @@ export async function startApp(): Promise<void> { | |||
|   }); | ||||
| 
 | ||||
|   let messageReceiver: WhatIsThis; | ||||
|   let preMessageReceiverStatus: WhatIsThis; | ||||
|   let preMessageReceiverStatus: SocketStatus | undefined; | ||||
|   window.getSocketStatus = () => { | ||||
|     if (messageReceiver) { | ||||
|       return messageReceiver.getStatus(); | ||||
|     } | ||||
|     if (window._.isNumber(preMessageReceiverStatus)) { | ||||
|     if (preMessageReceiverStatus) { | ||||
|       return preMessageReceiverStatus; | ||||
|     } | ||||
|     return WebSocket.CLOSED; | ||||
|     return SocketStatus.CLOSED; | ||||
|   }; | ||||
|   window.Whisper.events = window._.clone(window.Backbone.Events); | ||||
|   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 enqueueReconnectToWebSocket = () => { | ||||
|  | @ -1884,7 +1908,8 @@ export async function startApp(): Promise<void> { | |||
|   function isSocketOnline() { | ||||
|     const socketStatus = window.getSocketStatus(); | ||||
|     return ( | ||||
|       socketStatus === WebSocket.CONNECTING || socketStatus === WebSocket.OPEN | ||||
|       socketStatus === SocketStatus.CONNECTING || | ||||
|       socketStatus === SocketStatus.OPEN | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|  | @ -1937,7 +1962,7 @@ export async function startApp(): Promise<void> { | |||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       preMessageReceiverStatus = WebSocket.CONNECTING; | ||||
|       preMessageReceiverStatus = SocketStatus.CONNECTING; | ||||
| 
 | ||||
|       if (messageReceiver) { | ||||
|         await messageReceiver.stopProcessing(); | ||||
|  | @ -2020,7 +2045,7 @@ export async function startApp(): Promise<void> { | |||
| 
 | ||||
|       window.Signal.Services.initializeGroupCredentialFetcher(); | ||||
| 
 | ||||
|       preMessageReceiverStatus = null; | ||||
|       preMessageReceiverStatus = undefined; | ||||
| 
 | ||||
|       // eslint-disable-next-line no-inner-declarations
 | ||||
|       function addQueuedEventListener(name: string, handler: WhatIsThis) { | ||||
|  | @ -2258,6 +2283,8 @@ export async function startApp(): Promise<void> { | |||
| 
 | ||||
|       // Intentionally not awaiting
 | ||||
|       challengeHandler.onOnline(); | ||||
| 
 | ||||
|       reconnectBackOff.reset(); | ||||
|     } finally { | ||||
|       connecting = false; | ||||
|     } | ||||
|  | @ -3380,8 +3407,10 @@ export async function startApp(): Promise<void> { | |||
|     ) { | ||||
|       // Failed to connect to server
 | ||||
|       if (navigator.onLine) { | ||||
|         window.log.info('retrying in 1 minute'); | ||||
|         reconnectTimer = setTimeout(connect, 60000); | ||||
|         const timeout = reconnectBackOff.getAndIncrement(); | ||||
| 
 | ||||
|         window.log.info(`retrying in ${timeout}ms`); | ||||
|         reconnectTimer = setTimeout(connect, timeout); | ||||
| 
 | ||||
|         window.Whisper.events.trigger('reconnectTimer'); | ||||
| 
 | ||||
|  |  | |||
|  | @ -7,6 +7,7 @@ import { boolean, select } from '@storybook/addon-knobs'; | |||
| import { action } from '@storybook/addon-actions'; | ||||
| 
 | ||||
| import { NetworkStatus } from './NetworkStatus'; | ||||
| import { SocketStatus } from '../types/SocketStatus'; | ||||
| import { setup as setupI18n } from '../../js/modules/i18n'; | ||||
| import enMessages from '../../_locales/en/messages.json'; | ||||
| 
 | ||||
|  | @ -16,7 +17,7 @@ const defaultProps = { | |||
|   hasNetworkDialog: true, | ||||
|   i18n, | ||||
|   isOnline: true, | ||||
|   socketStatus: 0, | ||||
|   socketStatus: SocketStatus.CONNECTING, | ||||
|   manualReconnect: action('manual-reconnect'), | ||||
|   withinConnectingGracePeriod: false, | ||||
|   challengeStatus: 'idle' as const, | ||||
|  | @ -26,19 +27,19 @@ const permutations = [ | |||
|   { | ||||
|     title: 'Connecting', | ||||
|     props: { | ||||
|       socketStatus: 0, | ||||
|       socketStatus: SocketStatus.CONNECTING, | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     title: 'Closing (online)', | ||||
|     props: { | ||||
|       socketStatus: 2, | ||||
|       socketStatus: SocketStatus.CLOSING, | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     title: 'Closed (online)', | ||||
|     props: { | ||||
|       socketStatus: 3, | ||||
|       socketStatus: SocketStatus.CLOSED, | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|  | @ -56,12 +57,12 @@ storiesOf('Components/NetworkStatus', module) | |||
|     const socketStatus = select( | ||||
|       'socketStatus', | ||||
|       { | ||||
|         CONNECTING: 0, | ||||
|         OPEN: 1, | ||||
|         CLOSING: 2, | ||||
|         CLOSED: 3, | ||||
|         CONNECTING: SocketStatus.CONNECTING, | ||||
|         OPEN: SocketStatus.OPEN, | ||||
|         CLOSING: SocketStatus.CLOSING, | ||||
|         CLOSED: SocketStatus.CLOSED, | ||||
|       }, | ||||
|       0 | ||||
|       SocketStatus.CONNECTING | ||||
|     ); | ||||
| 
 | ||||
|     return ( | ||||
|  |  | |||
|  | @ -4,6 +4,7 @@ | |||
| import React from 'react'; | ||||
| 
 | ||||
| import { LocalizerType } from '../types/Util'; | ||||
| import { SocketStatus } from '../types/SocketStatus'; | ||||
| import { NetworkStateType } from '../state/ducks/network'; | ||||
| 
 | ||||
| const FIVE_SECONDS = 5 * 1000; | ||||
|  | @ -100,12 +101,12 @@ export const NetworkStatus = ({ | |||
|   let renderActionableButton; | ||||
| 
 | ||||
|   switch (socketStatus) { | ||||
|     case WebSocket.CONNECTING: | ||||
|     case SocketStatus.CONNECTING: | ||||
|       subtext = i18n('connectingHangOn'); | ||||
|       title = i18n('connecting'); | ||||
|       break; | ||||
|     case WebSocket.CLOSED: | ||||
|     case WebSocket.CLOSING: | ||||
|     case SocketStatus.CLOSED: | ||||
|     case SocketStatus.CLOSING: | ||||
|     default: | ||||
|       renderActionableButton = manualReconnectButton; | ||||
|       title = i18n('disconnected'); | ||||
|  |  | |||
							
								
								
									
										26
									
								
								ts/main/powerChannel.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								ts/main/powerChannel.ts
									
										
									
									
									
										Normal 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'); | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|  | @ -11,6 +11,7 @@ import { | |||
| } from '../util/zkgroup'; | ||||
| 
 | ||||
| import { GroupCredentialType } from '../textsecure/WebAPI'; | ||||
| import { BackOff } from '../util/BackOff'; | ||||
| import { sleep } from '../util/sleep'; | ||||
| 
 | ||||
| export const GROUP_CREDENTIALS_KEY = 'groupCredentials'; | ||||
|  | @ -50,33 +51,28 @@ export async function initializeGroupCredentialFetcher(): Promise<void> { | |||
|   await runWithRetry(maybeFetchNewCredentials, { scheduleAnother: 4 * HOUR }); | ||||
| } | ||||
| 
 | ||||
| type BackoffType = { | ||||
|   [key: number]: number | undefined; | ||||
|   max: number; | ||||
| }; | ||||
| const BACKOFF: BackoffType = { | ||||
|   0: SECOND, | ||||
|   1: 5 * SECOND, | ||||
|   2: 30 * SECOND, | ||||
|   3: 2 * MINUTE, | ||||
|   max: 5 * MINUTE, | ||||
| }; | ||||
| const BACKOFF_TIMEOUTS = [ | ||||
|   SECOND, | ||||
|   5 * SECOND, | ||||
|   30 * SECOND, | ||||
|   2 * MINUTE, | ||||
|   5 * MINUTE, | ||||
| ]; | ||||
| 
 | ||||
| export async function runWithRetry( | ||||
|   fn: () => Promise<void>, | ||||
|   options: { scheduleAnother?: number } = {} | ||||
| ): Promise<void> { | ||||
|   let count = 0; | ||||
|   const backOff = new BackOff(BACKOFF_TIMEOUTS); | ||||
| 
 | ||||
|   // eslint-disable-next-line no-constant-condition
 | ||||
|   while (true) { | ||||
|     try { | ||||
|       count += 1; | ||||
|       // eslint-disable-next-line no-await-in-loop
 | ||||
|       await fn(); | ||||
|       return; | ||||
|     } catch (error) { | ||||
|       const wait = BACKOFF[count] || BACKOFF.max; | ||||
|       const wait = backOff.getAndIncrement(); | ||||
|       window.log.info( | ||||
|         `runWithRetry: ${fn.name} failed. Waiting ${wait}ms for retry. Error: ${error.stack}` | ||||
|       ); | ||||
|  |  | |||
|  | @ -30,6 +30,7 @@ import { | |||
|   toGroupV2Record, | ||||
| } from './storageRecordOps'; | ||||
| import { ConversationModel } from '../models/conversations'; | ||||
| import { BackOff } from '../util/BackOff'; | ||||
| import { storageJobQueue } from '../util/JobQueue'; | ||||
| import { sleep } from '../util/sleep'; | ||||
| import { isMoreRecentThan } from '../util/timestamp'; | ||||
|  | @ -45,8 +46,6 @@ const { | |||
|   updateConversation, | ||||
| } = dataInterface; | ||||
| 
 | ||||
| let consecutiveStops = 0; | ||||
| let consecutiveConflicts = 0; | ||||
| const uploadBucket: Array<number> = []; | ||||
| 
 | ||||
| const validRecordTypes = new Set([ | ||||
|  | @ -57,24 +56,18 @@ const validRecordTypes = new Set([ | |||
|   4, // ACCOUNT
 | ||||
| ]); | ||||
| 
 | ||||
| type BackoffType = { | ||||
|   [key: number]: number | undefined; | ||||
|   max: number; | ||||
| }; | ||||
| const SECOND = 1000; | ||||
| 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 ms = BACKOFF[count] || BACKOFF.max; | ||||
|   return sleep(ms); | ||||
| } | ||||
| const backOff = new BackOff([ | ||||
|   SECOND, | ||||
|   5 * SECOND, | ||||
|   30 * SECOND, | ||||
|   2 * MINUTE, | ||||
|   5 * MINUTE, | ||||
| ]); | ||||
| 
 | ||||
| const conflictBackOff = new BackOff([SECOND, 5 * SECOND, 30 * SECOND]); | ||||
| 
 | ||||
| function redactStorageID(storageID: string): string { | ||||
|   return storageID.substring(0, 3); | ||||
|  | @ -494,16 +487,15 @@ async function uploadManifest( | |||
|     ); | ||||
| 
 | ||||
|     if (err.code === 409) { | ||||
|       if (consecutiveConflicts > 3) { | ||||
|       if (conflictBackOff.isFull()) { | ||||
|         window.log.error( | ||||
|           'storageService.uploadManifest: Exceeded maximum consecutive conflicts' | ||||
|         ); | ||||
|         return; | ||||
|       } | ||||
|       consecutiveConflicts += 1; | ||||
| 
 | ||||
|       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; | ||||
|  | @ -517,8 +509,8 @@ async function uploadManifest( | |||
|     version | ||||
|   ); | ||||
|   window.storage.put('manifestVersion', version); | ||||
|   consecutiveConflicts = 0; | ||||
|   consecutiveStops = 0; | ||||
|   conflictBackOff.reset(); | ||||
|   backOff.reset(); | ||||
|   await window.textsecure.messaging.sendFetchManifestSyncMessage(); | ||||
| } | ||||
| 
 | ||||
|  | @ -527,21 +519,21 @@ async function stopStorageServiceSync() { | |||
| 
 | ||||
|   await window.storage.remove('storageKey'); | ||||
| 
 | ||||
|   if (consecutiveStops < 5) { | ||||
|     await backOff(consecutiveStops); | ||||
|   if (backOff.isFull()) { | ||||
|     window.log.info( | ||||
|       'storageService.stopStorageServiceSync: requesting new keys' | ||||
|       'storageService.stopStorageServiceSync: too many consecutive stops' | ||||
|     ); | ||||
|     consecutiveStops += 1; | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   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!' | ||||
|         ); | ||||
|       throw new Error('storageService.stopStorageServiceSync: We are offline!'); | ||||
|     } | ||||
|     window.textsecure.messaging.sendRequestKeySyncMessage(); | ||||
|   }); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| async function createNewManifest() { | ||||
|  | @ -976,7 +968,7 @@ async function processRemoteRecords( | |||
|       return conflictCount; | ||||
|     } | ||||
| 
 | ||||
|     consecutiveConflicts = 0; | ||||
|     conflictBackOff.reset(); | ||||
|   } catch (err) { | ||||
|     window.log.error( | ||||
|       'storageService.processRemoteRecords: failed!', | ||||
|  | @ -1082,7 +1074,7 @@ async function upload(fromSync = false): Promise<void> { | |||
|     window.log.info( | ||||
|       'storageService.upload: no storageKey, requesting new keys' | ||||
|     ); | ||||
|     consecutiveStops = 0; | ||||
|     backOff.reset(); | ||||
|     await window.textsecure.messaging.sendRequestKeySyncMessage(); | ||||
|     return; | ||||
|   } | ||||
|  | @ -1108,7 +1100,7 @@ async function upload(fromSync = false): Promise<void> { | |||
|     await uploadManifest(version, generatedManifest); | ||||
|   } catch (err) { | ||||
|     if (err.code === 409) { | ||||
|       await backOff(consecutiveConflicts); | ||||
|       await sleep(conflictBackOff.getAndIncrement()); | ||||
|       window.log.info('storageService.upload: pushing sync on the queue'); | ||||
|       // 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
 | ||||
|  |  | |||
|  | @ -1,7 +1,9 @@ | |||
| // Copyright 2020 Signal Messenger, LLC
 | ||||
| // Copyright 2020-2021 Signal Messenger, LLC
 | ||||
| // SPDX-License-Identifier: AGPL-3.0-only
 | ||||
| 
 | ||||
| export function getSocketStatus(): number { | ||||
| import { SocketStatus } from '../types/SocketStatus'; | ||||
| 
 | ||||
| export function getSocketStatus(): SocketStatus { | ||||
|   const { getSocketStatus: getMessageReceiverStatus } = window; | ||||
| 
 | ||||
|   return getMessageReceiverStatus(); | ||||
|  |  | |||
|  | @ -98,7 +98,7 @@ export const actions = { | |||
| export function getEmptyState(): NetworkStateType { | ||||
|   return { | ||||
|     isOnline: navigator.onLine, | ||||
|     socketStatus: WebSocket.OPEN, | ||||
|     socketStatus: SocketStatus.OPEN, | ||||
|     withinConnectingGracePeriod: true, | ||||
|     challengeStatus: 'idle', | ||||
|   }; | ||||
|  |  | |||
|  | @ -6,6 +6,7 @@ import { createSelector } from 'reselect'; | |||
| import { StateType } from '../reducer'; | ||||
| import { NetworkStateType } from '../ducks/network'; | ||||
| import { isDone } from '../../util/registration'; | ||||
| import { SocketStatus } from '../../types/SocketStatus'; | ||||
| 
 | ||||
| const getNetwork = (state: StateType): NetworkStateType => state.network; | ||||
| 
 | ||||
|  | @ -18,9 +19,10 @@ export const hasNetworkDialog = createSelector( | |||
|   ): boolean => | ||||
|     isRegistrationDone && | ||||
|     (!isOnline || | ||||
|       (socketStatus === WebSocket.CONNECTING && !withinConnectingGracePeriod) || | ||||
|       socketStatus === WebSocket.CLOSED || | ||||
|       socketStatus === WebSocket.CLOSING) | ||||
|       (socketStatus === SocketStatus.CONNECTING && | ||||
|         !withinConnectingGracePeriod) || | ||||
|       socketStatus === SocketStatus.CLOSED || | ||||
|       socketStatus === SocketStatus.CLOSING) | ||||
| ); | ||||
| 
 | ||||
| export const isChallengePending = createSelector( | ||||
|  |  | |||
							
								
								
									
										45
									
								
								ts/test-both/util/BackOff_test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								ts/test-both/util/BackOff_test.ts
									
										
									
									
									
										Normal 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); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										264
									
								
								ts/test-electron/WebsocketResources_test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										264
									
								
								ts/test-electron/WebsocketResources_test.ts
									
										
									
									
									
										Normal 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(); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  | @ -199,19 +199,22 @@ export default class AccountManager extends EventTarget { | |||
|     const queueTask = this.queueTask.bind(this); | ||||
|     const provisioningCipher = new ProvisioningCipher(); | ||||
|     let gotProvisionEnvelope = false; | ||||
|     return provisioningCipher.getPublicKey().then( | ||||
|       async (pubKey: ArrayBuffer) => | ||||
|         new Promise((resolve, reject) => { | ||||
|           const socket = getSocket(); | ||||
|           socket.onclose = event => { | ||||
|             window.log.info('provisioning socket closed. Code:', event.code); | ||||
|     const pubKey = await provisioningCipher.getPublicKey(); | ||||
| 
 | ||||
|     const socket = await getSocket(); | ||||
| 
 | ||||
|     window.log.info('provisioning socket open'); | ||||
| 
 | ||||
|     return new Promise((resolve, reject) => { | ||||
|       socket.on('close', (code, reason) => { | ||||
|         window.log.info( | ||||
|           `provisioning socket closed. Code: ${code} Reason: ${reason}` | ||||
|         ); | ||||
|         if (!gotProvisionEnvelope) { | ||||
|           reject(new Error('websocket closed')); | ||||
|         } | ||||
|           }; | ||||
|           socket.onopen = () => { | ||||
|             window.log.info('provisioning socket open'); | ||||
|           }; | ||||
|       }); | ||||
| 
 | ||||
|       const wsr = new WebSocketResource(socket, { | ||||
|         keepalive: { path: '/v1/keepalive/provisioning' }, | ||||
|         handleRequest(request: IncomingWebSocketRequest) { | ||||
|  | @ -300,8 +303,7 @@ export default class AccountManager extends EventTarget { | |||
|           } | ||||
|         }, | ||||
|       }); | ||||
|         }) | ||||
|     ); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   async refreshPreKeys() { | ||||
|  |  | |||
|  | @ -10,9 +10,10 @@ | |||
| /* eslint-disable max-classes-per-file */ | ||||
| /* eslint-disable no-restricted-syntax */ | ||||
| 
 | ||||
| import { isNumber, map, omit, noop } from 'lodash'; | ||||
| import { isNumber, map, omit } from 'lodash'; | ||||
| import PQueue from 'p-queue'; | ||||
| import { v4 as getGuid } from 'uuid'; | ||||
| import { connection as WebSocket } from 'websocket'; | ||||
| import { z } from 'zod'; | ||||
| 
 | ||||
| import { | ||||
|  | @ -41,6 +42,7 @@ import { | |||
|   SignedPreKeys, | ||||
| } from '../LibSignalStores'; | ||||
| import { BatcherType, createBatcher } from '../util/batcher'; | ||||
| import { sleep } from '../util/sleep'; | ||||
| import { parseIntOrThrow } from '../util/parseIntOrThrow'; | ||||
| import { Zone } from '../util/Zone'; | ||||
| import EventTarget from './EventTarget'; | ||||
|  | @ -53,6 +55,7 @@ import Crypto from './Crypto'; | |||
| import { deriveMasterKeyFromGroupV1, typedArrayToArrayBuffer } from '../Crypto'; | ||||
| import { ContactBuffer, GroupBuffer } from './ContactsParser'; | ||||
| import { isByteBufferEmpty } from '../util/isByteBufferEmpty'; | ||||
| import { SocketStatus } from '../types/SocketStatus'; | ||||
| 
 | ||||
| import { | ||||
|   AttachmentPointerClass, | ||||
|  | @ -68,13 +71,12 @@ import { | |||
| } from '../textsecure.d'; | ||||
| import { ByteBufferClass } from '../window.d'; | ||||
| 
 | ||||
| import { WebSocket } from './WebSocket'; | ||||
| 
 | ||||
| import { deriveGroupFields, MASTER_KEY_LENGTH } from '../groups'; | ||||
| 
 | ||||
| const GROUPV1_ID_LENGTH = 16; | ||||
| const GROUPV2_ID_LENGTH = 32; | ||||
| const RETRY_TIMEOUT = 2 * 60 * 1000; | ||||
| const RECONNECT_DELAY = 1 * 1000; | ||||
| 
 | ||||
| const decryptionErrorTypeSchema = z | ||||
|   .object({ | ||||
|  | @ -169,7 +171,9 @@ enum TaskType { | |||
| } | ||||
| 
 | ||||
| class MessageReceiverInner extends EventTarget { | ||||
|   _onClose?: (ev: any) => Promise<void>; | ||||
|   _onClose?: (code: number, reason: string) => Promise<void>; | ||||
| 
 | ||||
|   _onError?: (error: Error) => Promise<void>; | ||||
| 
 | ||||
|   appQueue: PQueue; | ||||
| 
 | ||||
|  | @ -185,7 +189,7 @@ class MessageReceiverInner extends EventTarget { | |||
| 
 | ||||
|   deviceId?: number; | ||||
| 
 | ||||
|   hasConnected?: boolean; | ||||
|   hasConnected = false; | ||||
| 
 | ||||
|   incomingQueue: PQueue; | ||||
| 
 | ||||
|  | @ -209,6 +213,8 @@ class MessageReceiverInner extends EventTarget { | |||
| 
 | ||||
|   socket?: WebSocket; | ||||
| 
 | ||||
|   socketStatus = SocketStatus.CLOSED; | ||||
| 
 | ||||
|   stoppingProcessing?: boolean; | ||||
| 
 | ||||
|   username: string; | ||||
|  | @ -304,7 +310,7 @@ class MessageReceiverInner extends EventTarget { | |||
|   static arrayBufferToStringBase64 = (arrayBuffer: ArrayBuffer): string => | ||||
|     window.dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('base64'); | ||||
| 
 | ||||
|   connect() { | ||||
|   async connect(): Promise<void> { | ||||
|     if (this.calledClose) { | ||||
|       return; | ||||
|     } | ||||
|  | @ -322,17 +328,43 @@ class MessageReceiverInner extends EventTarget { | |||
| 
 | ||||
|     this.hasConnected = true; | ||||
| 
 | ||||
|     if (this.socket && this.socket.readyState !== WebSocket.CLOSED) { | ||||
|     if (this.socket && this.socket.connected) { | ||||
|       this.socket.close(); | ||||
|       this.socket = undefined; | ||||
|       if (this.wsr) { | ||||
|         this.wsr.close(); | ||||
|         this.wsr = undefined; | ||||
|       } | ||||
|     } | ||||
|     this.socketStatus = SocketStatus.CONNECTING; | ||||
| 
 | ||||
|     // initialize the socket and start listening for messages
 | ||||
|     this.socket = this.server.getMessageSocket(); | ||||
|     this.socket.onclose = this.onclose.bind(this); | ||||
|     this.socket.onerror = this.onerror.bind(this); | ||||
|     this.socket.onopen = this.onopen.bind(this); | ||||
|     try { | ||||
|       this.socket = await this.server.getMessageSocket(); | ||||
|     } catch (error) { | ||||
|       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, { | ||||
|       handleRequest: this.handleRequest.bind(this), | ||||
|       keepalive: { | ||||
|  | @ -342,7 +374,6 @@ class MessageReceiverInner extends EventTarget { | |||
|     }); | ||||
| 
 | ||||
|     // Because sometimes the socket doesn't properly emit its close event
 | ||||
|     this._onClose = this.onclose.bind(this); | ||||
|     if (this._onClose) { | ||||
|       this.wsr.addEventListener('close', this._onClose); | ||||
|     } | ||||
|  | @ -362,9 +393,12 @@ class MessageReceiverInner extends EventTarget { | |||
| 
 | ||||
|   shutdown() { | ||||
|     if (this.socket) { | ||||
|       this.socket.onclose = noop; | ||||
|       this.socket.onerror = noop; | ||||
|       this.socket.onopen = noop; | ||||
|       if (this._onClose) { | ||||
|         this.socket.removeListener('close', this._onClose); | ||||
|       } | ||||
|       if (this._onError) { | ||||
|         this.socket.removeListener('error', this._onError); | ||||
|       } | ||||
| 
 | ||||
|       this.socket = undefined; | ||||
|     } | ||||
|  | @ -380,6 +414,7 @@ class MessageReceiverInner extends EventTarget { | |||
|   async close() { | ||||
|     window.log.info('MessageReceiver.close()'); | ||||
|     this.calledClose = true; | ||||
|     this.socketStatus = SocketStatus.CLOSING; | ||||
| 
 | ||||
|     // Our WebSocketResource instance will close the socket and emit a 'close' event
 | ||||
|     //   if the socket doesn't emit one quickly enough.
 | ||||
|  | @ -392,13 +427,8 @@ class MessageReceiverInner extends EventTarget { | |||
|     return this.drain(); | ||||
|   } | ||||
| 
 | ||||
|   onopen() { | ||||
|     window.log.info('websocket open'); | ||||
|     window.logMessageReceiverConnect(); | ||||
|   } | ||||
| 
 | ||||
|   onerror() { | ||||
|     window.log.error('websocket error'); | ||||
|   async onerror(error: Error): Promise<void> { | ||||
|     window.log.error('websocket error', error); | ||||
|   } | ||||
| 
 | ||||
|   async dispatchAndWait(event: Event) { | ||||
|  | @ -407,35 +437,41 @@ class MessageReceiverInner extends EventTarget { | |||
|     return Promise.resolve(); | ||||
|   } | ||||
| 
 | ||||
|   async onclose(ev: any) { | ||||
|   async onclose(code: number, reason: string): Promise<void> { | ||||
|     window.log.info( | ||||
|       'websocket closed', | ||||
|       ev.code, | ||||
|       ev.reason || '', | ||||
|       code, | ||||
|       reason || '', | ||||
|       'calledClose:', | ||||
|       this.calledClose | ||||
|     ); | ||||
| 
 | ||||
|     this.socketStatus = SocketStatus.CLOSED; | ||||
| 
 | ||||
|     this.shutdown(); | ||||
| 
 | ||||
|     if (this.calledClose) { | ||||
|       return Promise.resolve(); | ||||
|       return; | ||||
|     } | ||||
|     if (ev.code === 3000) { | ||||
|       return Promise.resolve(); | ||||
|     if (code === 3000) { | ||||
|       return; | ||||
|     } | ||||
|     if (ev.code === 3001) { | ||||
|     if (code === 3001) { | ||||
|       this.onEmpty(); | ||||
|     } | ||||
|     // possible 403 or network issue. Make an request to confirm
 | ||||
|     return this.server | ||||
|       .getDevices() | ||||
|       .then(this.connect.bind(this)) // No HTTP error? Reconnect
 | ||||
|       .catch(async e => { | ||||
|         const event = new Event('error'); | ||||
|         event.error = e; | ||||
|         return this.dispatchAndWait(event); | ||||
|       }); | ||||
| 
 | ||||
|     await sleep(RECONNECT_DELAY); | ||||
| 
 | ||||
|     // Try to reconnect (if there is an error - we'll get an
 | ||||
|     // `error` event from `connect()` and hit the retry backoff logic in
 | ||||
|     // `ts/background.ts`)
 | ||||
|     await this.connect(); | ||||
|   } | ||||
| 
 | ||||
|   checkSocket(): void { | ||||
|     if (this.wsr) { | ||||
|       this.wsr.forceKeepAlive(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   handleRequest(request: IncomingWebSocketRequest) { | ||||
|  | @ -1076,14 +1112,8 @@ class MessageReceiverInner extends EventTarget { | |||
|     throw new Error('Received message with no content and no legacyMessage'); | ||||
|   } | ||||
| 
 | ||||
|   getStatus() { | ||||
|     if (this.socket) { | ||||
|       return this.socket.readyState; | ||||
|     } | ||||
|     if (this.hasConnected) { | ||||
|       return WebSocket.CLOSED; | ||||
|     } | ||||
|     return -1; | ||||
|   getStatus(): SocketStatus { | ||||
|     return this.socketStatus; | ||||
|   } | ||||
| 
 | ||||
|   async onDeliveryReceipt(envelope: EnvelopeClass): Promise<void> { | ||||
|  | @ -2693,6 +2723,7 @@ export default class MessageReceiver { | |||
|     this.hasEmptied = inner.hasEmptied.bind(inner); | ||||
|     this.removeEventListener = inner.removeEventListener.bind(inner); | ||||
|     this.stopProcessing = inner.stopProcessing.bind(inner); | ||||
|     this.checkSocket = inner.checkSocket.bind(inner); | ||||
|     this.unregisterBatchers = inner.unregisterBatchers.bind(inner); | ||||
| 
 | ||||
|     inner.connect(); | ||||
|  | @ -2707,7 +2738,7 @@ export default class MessageReceiver { | |||
|     attachment: AttachmentPointerClass | ||||
|   ) => Promise<DownloadAttachmentType>; | ||||
| 
 | ||||
|   getStatus: () => number; | ||||
|   getStatus: () => SocketStatus; | ||||
| 
 | ||||
|   hasEmptied: () => boolean; | ||||
| 
 | ||||
|  | @ -2717,6 +2748,8 @@ export default class MessageReceiver { | |||
| 
 | ||||
|   unregisterBatchers: () => void; | ||||
| 
 | ||||
|   checkSocket: () => void; | ||||
| 
 | ||||
|   getProcessedCount: () => number; | ||||
| 
 | ||||
|   static stringToArrayBuffer = MessageReceiverInner.stringToArrayBuffer; | ||||
|  |  | |||
|  | @ -11,7 +11,7 @@ | |||
| 
 | ||||
| import fetch, { Response } from 'node-fetch'; | ||||
| import ProxyAgent from 'proxy-agent'; | ||||
| import { Agent } from 'https'; | ||||
| import { Agent, RequestOptions } from 'https'; | ||||
| import pProps from 'p-props'; | ||||
| import { | ||||
|   compact, | ||||
|  | @ -25,9 +25,11 @@ import { pki } from 'node-forge'; | |||
| import is from '@sindresorhus/is'; | ||||
| import PQueue from 'p-queue'; | ||||
| import { v4 as getGuid } from 'uuid'; | ||||
| import { client as WebSocketClient, connection as WebSocket } from 'websocket'; | ||||
| import { z } from 'zod'; | ||||
| 
 | ||||
| import { Long } from '../window.d'; | ||||
| import { assert } from '../util/assert'; | ||||
| import { getUserAgent } from '../util/getUserAgent'; | ||||
| import { toWebSafeBase64 } from '../util/webSafeBase64'; | ||||
| import { isPackIdValid, redactPackId } from '../../js/modules/stickers'; | ||||
|  | @ -59,7 +61,6 @@ import { | |||
|   StorageServiceCredentials, | ||||
| } from '../textsecure.d'; | ||||
| 
 | ||||
| import { WebSocket } from './WebSocket'; | ||||
| import MessageSender from './SendMessage'; | ||||
| 
 | ||||
| // 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; | ||||
| } | ||||
| 
 | ||||
| function _createSocket( | ||||
| export type ConnectSocketOptions = Readonly<{ | ||||
|   certificateAuthority: string; | ||||
|   proxyUrl?: string; | ||||
|   version: string; | ||||
|   timeout?: number; | ||||
| }>; | ||||
| 
 | ||||
| const TEN_SECONDS = 1000 * 10; | ||||
| 
 | ||||
| async function _connectSocket( | ||||
|   url: string, | ||||
|   { | ||||
|     certificateAuthority, | ||||
|     proxyUrl, | ||||
|     version, | ||||
|   }: { certificateAuthority: string; proxyUrl?: string; version: string } | ||||
| ) { | ||||
|   let requestOptions; | ||||
|   if (proxyUrl) { | ||||
|     requestOptions = { | ||||
|     timeout = TEN_SECONDS, | ||||
|   }: ConnectSocketOptions | ||||
| ): Promise<WebSocket> { | ||||
|   let tlsOptions: RequestOptions = { | ||||
|     ca: certificateAuthority, | ||||
|   }; | ||||
|   if (proxyUrl) { | ||||
|     tlsOptions = { | ||||
|       ...tlsOptions, | ||||
|       agent: new ProxyAgent(proxyUrl), | ||||
|     }; | ||||
|   } else { | ||||
|     requestOptions = { | ||||
|       ca: certificateAuthority, | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   const headers = { | ||||
|     'User-Agent': getUserAgent(version), | ||||
|   }; | ||||
|   return new WebSocket(url, undefined, undefined, headers, requestOptions, { | ||||
|   const client = new WebSocketClient({ | ||||
|     tlsOptions, | ||||
|     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; | ||||
|  | @ -403,6 +458,56 @@ function getHostname(url: string): string { | |||
|   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( | ||||
|   providedUrl: string | null, | ||||
|   options: PromiseAjaxOptionsType | ||||
|  | @ -487,25 +592,11 @@ async function _promiseAjax( | |||
| 
 | ||||
|     fetch(url, fetchOptions) | ||||
|       .then(async response => { | ||||
|         if (options.serverUrl) { | ||||
|         if ( | ||||
|             response.status === 499 && | ||||
|           options.serverUrl && | ||||
|           getHostname(options.serverUrl) === getHostname(url) | ||||
|         ) { | ||||
|             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'); | ||||
|           } | ||||
|           await _handleStatusCode(response.status, unauthenticated); | ||||
|         } | ||||
| 
 | ||||
|         let resultPromise; | ||||
|  | @ -863,7 +954,7 @@ export type WebAPIType = { | |||
|     deviceId?: number, | ||||
|     options?: { accessKey?: string } | ||||
|   ) => Promise<ServerKeysType>; | ||||
|   getMessageSocket: () => WebSocket; | ||||
|   getMessageSocket: () => Promise<WebSocket>; | ||||
|   getMyKeys: () => Promise<number>; | ||||
|   getProfile: ( | ||||
|     identifier: string, | ||||
|  | @ -880,7 +971,7 @@ export type WebAPIType = { | |||
|       profileKeyCredentialRequest?: string; | ||||
|     } | ||||
|   ) => Promise<any>; | ||||
|   getProvisioningSocket: () => WebSocket; | ||||
|   getProvisioningSocket: () => Promise<WebSocket>; | ||||
|   getSenderCertificate: ( | ||||
|     withUuid?: boolean | ||||
|   ) => Promise<{ certificate: string }>; | ||||
|  | @ -1153,39 +1244,10 @@ export function initialize({ | |||
|         unauthenticated: param.unauthenticated, | ||||
|         accessKey: param.accessKey, | ||||
|       }).catch((e: Error) => { | ||||
|         const { code } = e; | ||||
|         if (code === 200) { | ||||
|           // Happens sometimes when we get no response. Might be nice to get 204 instead.
 | ||||
|           return null; | ||||
|         const translatedError = _translateError(e); | ||||
|         if (translatedError) { | ||||
|           throw translatedError; | ||||
|         } | ||||
|         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); | ||||
|       const fixedScheme = url | ||||
|         .replace('https://', 'wss://') | ||||
|  | @ -2327,20 +2389,20 @@ export function initialize({ | |||
|       const pass = encodeURIComponent(password); | ||||
|       const clientVersion = encodeURIComponent(version); | ||||
| 
 | ||||
|       return _createSocket( | ||||
|       return _connectSocket( | ||||
|         `${fixedScheme}/v1/websocket/?login=${login}&password=${pass}&agent=OWD&version=${clientVersion}`, | ||||
|         { certificateAuthority, proxyUrl, version } | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     function getProvisioningSocket() { | ||||
|     function getProvisioningSocket(): Promise<WebSocket> { | ||||
|       window.log.info('opening provisioning socket', url); | ||||
|       const fixedScheme = url | ||||
|         .replace('https://', 'wss://') | ||||
|         .replace('http://', 'ws://'); | ||||
|       const clientVersion = encodeURIComponent(version); | ||||
| 
 | ||||
|       return _createSocket( | ||||
|       return _connectSocket( | ||||
|         `${fixedScheme}/v1/websocket/provisioning/?agent=OWD&version=${clientVersion}`, | ||||
|         { certificateAuthority, proxyUrl, version } | ||||
|       ); | ||||
|  |  | |||
|  | @ -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; | ||||
|  | @ -27,12 +27,12 @@ | |||
|  * | ||||
|  */ | ||||
| 
 | ||||
| import { connection as WebSocket, IMessage } from 'websocket'; | ||||
| 
 | ||||
| import { ByteBufferClass } from '../window.d'; | ||||
| 
 | ||||
| import EventTarget from './EventTarget'; | ||||
| 
 | ||||
| import { WebSocket } from './WebSocket'; | ||||
| 
 | ||||
| class Request { | ||||
|   verb: string; | ||||
| 
 | ||||
|  | @ -92,14 +92,13 @@ export class IncomingWebSocketRequest { | |||
|     this.headers = request.headers; | ||||
| 
 | ||||
|     this.respond = (status, message) => { | ||||
|       socket.send( | ||||
|         new window.textsecure.protobuf.WebSocketMessage({ | ||||
|       const ab = new window.textsecure.protobuf.WebSocketMessage({ | ||||
|         type: window.textsecure.protobuf.WebSocketMessage.Type.RESPONSE, | ||||
|         response: { id: request.id, message, status }, | ||||
|       }) | ||||
|         .encode() | ||||
|           .toArrayBuffer() | ||||
|       ); | ||||
|         .toArrayBuffer(); | ||||
|       socket.sendBytes(Buffer.from(ab)); | ||||
|     }; | ||||
|   } | ||||
| } | ||||
|  | @ -111,8 +110,7 @@ class OutgoingWebSocketRequest { | |||
|   constructor(options: any, socket: WebSocket) { | ||||
|     const request = new Request(options); | ||||
|     outgoing[request.id] = request; | ||||
|     socket.send( | ||||
|       new window.textsecure.protobuf.WebSocketMessage({ | ||||
|     const ab = new window.textsecure.protobuf.WebSocketMessage({ | ||||
|       type: window.textsecure.protobuf.WebSocketMessage.Type.REQUEST, | ||||
|       request: { | ||||
|         verb: request.verb, | ||||
|  | @ -123,8 +121,8 @@ class OutgoingWebSocketRequest { | |||
|       }, | ||||
|     }) | ||||
|       .encode() | ||||
|         .toArrayBuffer() | ||||
|     ); | ||||
|       .toArrayBuffer(); | ||||
|     socket.sendBytes(Buffer.from(ab)); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
|  | @ -149,11 +147,13 @@ export default class WebSocketResource extends EventTarget { | |||
|     this.sendRequest = options => new OutgoingWebSocketRequest(options, socket); | ||||
| 
 | ||||
|     // eslint-disable-next-line no-param-reassign
 | ||||
|     socket.onmessage = socketMessage => { | ||||
|       const blob = socketMessage.data; | ||||
|       const handleArrayBuffer = (buffer: ArrayBuffer) => { | ||||
|     const onMessage = ({ type, binaryData }: IMessage): void => { | ||||
|       if (type !== 'binary' || !binaryData) { | ||||
|         throw new Error(`Unsupported websocket message type: ${type}`); | ||||
|       } | ||||
| 
 | ||||
|       const message = window.textsecure.protobuf.WebSocketMessage.decode( | ||||
|           buffer | ||||
|         binaryData | ||||
|       ); | ||||
|       if ( | ||||
|         message.type === | ||||
|  | @ -198,17 +198,7 @@ export default class WebSocketResource extends EventTarget { | |||
|         } | ||||
|       } | ||||
|     }; | ||||
| 
 | ||||
|       if (blob instanceof ArrayBuffer) { | ||||
|         handleArrayBuffer(blob); | ||||
|       } else { | ||||
|         const reader = new FileReader(); | ||||
|         reader.onload = () => { | ||||
|           handleArrayBuffer(reader.result as ArrayBuffer); | ||||
|         }; | ||||
|         reader.readAsArrayBuffer(blob as any); | ||||
|       } | ||||
|     }; | ||||
|     socket.on('message', onMessage); | ||||
| 
 | ||||
|     if (opts.keepalive) { | ||||
|       this.keepalive = new KeepAlive(this, { | ||||
|  | @ -217,15 +207,13 @@ export default class WebSocketResource extends EventTarget { | |||
|       }); | ||||
|       const resetKeepAliveTimer = this.keepalive.reset.bind(this.keepalive); | ||||
| 
 | ||||
|       socket.addEventListener('open', resetKeepAliveTimer); | ||||
|       socket.addEventListener('message', resetKeepAliveTimer); | ||||
|       socket.addEventListener( | ||||
|         'close', | ||||
|         this.keepalive.stop.bind(this.keepalive) | ||||
|       ); | ||||
|       this.keepalive.reset(); | ||||
| 
 | ||||
|       socket.on('message', resetKeepAliveTimer); | ||||
|       socket.on('close', this.keepalive.stop.bind(this.keepalive)); | ||||
|     } | ||||
| 
 | ||||
|     socket.addEventListener('close', () => { | ||||
|     socket.on('close', () => { | ||||
|       this.closed = true; | ||||
|     }); | ||||
| 
 | ||||
|  | @ -242,7 +230,7 @@ export default class WebSocketResource extends EventTarget { | |||
|       socket.close(code, reason); | ||||
|       // eslint-disable-next-line @typescript-eslint/ban-ts-comment
 | ||||
|       // @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
 | ||||
|       //   lost the internet connection. On the order of minutes. This speeds that
 | ||||
|  | @ -261,6 +249,13 @@ export default class WebSocketResource extends EventTarget { | |||
|       }, 5000); | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   public forceKeepAlive(): void { | ||||
|     if (!this.keepalive) { | ||||
|       return; | ||||
|     } | ||||
|     this.keepalive.send(); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| type KeepAliveOptionsType = { | ||||
|  | @ -269,15 +264,15 @@ type KeepAliveOptionsType = { | |||
| }; | ||||
| 
 | ||||
| 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( | ||||
|     websocketResource: WebSocketResource, | ||||
|  | @ -292,30 +287,46 @@ class KeepAlive { | |||
|     } | ||||
|   } | ||||
| 
 | ||||
|   stop() { | ||||
|     clearTimeout(this.keepAliveTimer); | ||||
|     clearTimeout(this.disconnectTimer); | ||||
|   public stop(): void { | ||||
|     this.clearTimers(); | ||||
|   } | ||||
| 
 | ||||
|   reset() { | ||||
|     clearTimeout(this.keepAliveTimer); | ||||
|     clearTimeout(this.disconnectTimer); | ||||
|     this.keepAliveTimer = setTimeout(() => { | ||||
|   public send(): void { | ||||
|     this.clearTimers(); | ||||
| 
 | ||||
|     if (this.disconnect) { | ||||
|       // automatically disconnect if server doesn't ack
 | ||||
|       this.disconnectTimer = setTimeout(() => { | ||||
|           clearTimeout(this.keepAliveTimer); | ||||
|         this.clearTimers(); | ||||
| 
 | ||||
|         this.wsr.close(3001, 'No response to keepalive request'); | ||||
|       }, 10000); | ||||
|     } else { | ||||
|       this.reset(); | ||||
|     } | ||||
|       window.log.info('Sending a keepalive message'); | ||||
| 
 | ||||
|     window.log.info('WebSocketResources: Sending a keepalive message'); | ||||
|     this.wsr.sendRequest({ | ||||
|       verb: 'GET', | ||||
|       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; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -4,8 +4,8 @@ | |||
| // Maps to values found here: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState
 | ||||
| // which are returned by libtextsecure's MessageReceiver
 | ||||
| export enum SocketStatus { | ||||
|   CONNECTING, | ||||
|   OPEN, | ||||
|   CLOSING, | ||||
|   CLOSED, | ||||
|   CONNECTING = 'CONNECTING', | ||||
|   OPEN = 'OPEN', | ||||
|   CLOSING = 'CLOSING', | ||||
|   CLOSED = 'CLOSED', | ||||
| } | ||||
|  |  | |||
							
								
								
									
										33
									
								
								ts/util/BackOff.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								ts/util/BackOff.ts
									
										
									
									
									
										Normal 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
									
									
								
							
							
						
						
									
										3
									
								
								ts/window.d.ts
									
										
									
									
										vendored
									
									
								
							|  | @ -108,6 +108,7 @@ import { ElectronLocaleType } from './util/mapToSupportLocale'; | |||
| import { SignalProtocolStore } from './SignalProtocolStore'; | ||||
| import { StartupQueue } from './util/StartupQueue'; | ||||
| import * as synchronousCrypto from './util/synchronousCrypto'; | ||||
| import { SocketStatus } from './types/SocketStatus'; | ||||
| import SyncRequest from './textsecure/SyncRequest'; | ||||
| import { ConversationColorType, CustomColorType } from './types/Colors'; | ||||
| 
 | ||||
|  | @ -190,7 +191,7 @@ declare global { | |||
|     getNodeVersion: () => string; | ||||
|     getServerPublicParams: () => string; | ||||
|     getSfuUrl: () => string; | ||||
|     getSocketStatus: () => number; | ||||
|     getSocketStatus: () => SocketStatus; | ||||
|     getSyncRequest: (timeoutMillis?: number) => SyncRequest; | ||||
|     getTitle: () => string; | ||||
|     waitForEmptyEventQueue: () => Promise<void>; | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Fedor Indutny
				Fedor Indutny