فهرست منبع

Refactor connecting logic & bug fixes for iOS and the ack protocol

Add reconnect failed page
Remove WelcomeStateParams
Always explicitly stop the WebClientService before initialising it (again or for the first time)
Allow to set the connection buildup state when stopping the WebClientService
Fix always send a push when trying to connect to iOS before all initial data has been loaded
Unbind events when stopping the WebClientService to prevent events from leaking into new connections
Change PeerConnectionHelper.close logic from waiting until the peer connection is closed to abruptly closing the peer connection and unbinding all event handlers
Remove PeerConnectionHelper.onConnectionStateChange
Remove the handler argument from PeerConnectionHelper.createSecureDataChannel
Change ChunkCache.chunks to only hand out filtered chunks
Change ChunkCache.transfer to return the amount of chunks that have been transferred
Fix ChunkCache.prune to prune with the correct offset
Change ChunkCache.prune to also return the amount of acknowledged chunks as well as the amount of chunks left
Allow to set the connection buildup state in StateService.reset and default to 'new' instead of 'connecting'
Add more logging for the chunk cache pruning/acknowledgement and transfer phase
Add MessagePack logging when sending messages
Fix msgpackVisualizer to convert a Uint8Array into a "byte string" first to avoid encoding errors
Bump SaltyRTC client and task versions
Lennart Grahl 7 سال پیش
والد
کامیت
acd84398da

+ 2 - 1
public/i18n/de.json

@@ -52,7 +52,8 @@
         "WAITING_FOR_APP_MANUAL": "Google Play Services nicht installiert. Bitte starten Sie die Sitzung manuell.",
         "CONNECTING_TO_SERVER": "Verbinden mit Server\u2026",
         "CONNECTING_TO_APP": "Verbindung zu App wird aufgebaut\u2026",
-        "CONNECTION_CLOSED": "Server-Verbindung wurde geschlossen."
+        "CONNECTION_CLOSED": "Server-Verbindung wurde geschlossen.",
+        "RECONNECT_FAILED": "Verbindung zur App fehlgeschlagen."
     },
     "troubleshooting": {
         "SLOW_CONNECT": "Verbindungsaufbau scheint länger zu dauern<br>als normal …",

+ 2 - 1
public/i18n/en.json

@@ -52,7 +52,8 @@
         "WAITING_FOR_APP_MANUAL": "Google Play Services not installed. Please start the session manually.",
         "CONNECTING_TO_SERVER": "Connecting to server\u2026",
         "CONNECTING_TO_APP": "Connection to app is being established\u2026",
-        "CONNECTION_CLOSED": "Connection to server has been closed."
+        "CONNECTION_CLOSED": "Connection to server has been closed.",
+        "RECONNECT_FAILED": "Connecting to app failed."
     },
     "troubleshooting": {
         "SLOW_CONNECT": "Connecting seems to take longer than usual …",

+ 37 - 21
src/controllers/status.ts

@@ -22,6 +22,7 @@ import {StateService} from '../services/state';
 import {WebClientService} from '../services/webclient';
 
 import GlobalConnectionState = threema.GlobalConnectionState;
+import DisconnectReason = threema.DisconnectReason;
 
 /**
  * This controller handles state changes globally.
@@ -169,16 +170,18 @@ export class StatusController {
             // Collapse status bar
             this.collapseStatusBar();
 
-            // Reset state
-            this.stateService.reset();
+            // Reset connection & state
+            this.webClientService.stop({
+                reason: DisconnectReason.SessionError,
+                send: false,
+                close: true,
+                redirect: false,
+                connectionBuildupState: 'reconnect_failed',
+            });
 
             // Redirect to welcome page
-            this.$state.go('welcome', {
-                initParams: {
-                    keyStore: originalKeyStore,
-                    peerTrustedKey: originalPeerPermanentKeyBytes,
-                },
-            });
+            // TODO: Add a new state welcome.error (also for iOS)
+            this.$state.go('welcome');
         };
 
         // Handlers for reconnecting timeout
@@ -197,12 +200,17 @@ export class StatusController {
 
         // Function to soft-reconnect. Does not reset the loaded data.
         const doSoftReconnect = () => {
-            this.webClientService.stop(threema.DisconnectReason.SessionStopped, {
+            this.webClientService.stop({
+                reason: DisconnectReason.SessionStopped,
                 send: true,
                 close: false,
                 redirect: false,
             });
-            this.webClientService.init(originalKeyStore, originalPeerPermanentKeyBytes, true);
+            this.webClientService.init({
+                keyStore: originalKeyStore,
+                peerTrustedKey: originalPeerPermanentKeyBytes,
+                resume: true,
+            });
             this.webClientService.start().then(
                 () => {
                     // Cancel timeout
@@ -254,31 +262,39 @@ export class StatusController {
 
         // Handler for failed reconnection attempts
         const reconnectionFailed = () => {
-            // Reset state
-            this.stateService.reset();
+            // Reset connection & state
+            this.webClientService.stop({
+                reason: DisconnectReason.SessionError,
+                send: false,
+                close: true,
+                redirect: false,
+                connectionBuildupState: 'reconnect_failed',
+            });
 
             // Redirect to welcome page
-            this.$state.go('welcome', {
-                initParams: {
-                    keyStore: originalKeyStore,
-                    peerTrustedKey: originalPeerPermanentKeyBytes,
-                },
-            });
+            this.$state.go('welcome');
         };
 
-        const skipPush = true;
         // Delay connecting a bit to wait for old websocket to close
         // TODO: Make this more robust and hopefully faster
+        const skipPush = !this.$state.includes('welcome');
         const startTimeout = 500;
         this.$log.debug(this.logTag, 'Stopping old connection');
-        this.webClientService.stop(threema.DisconnectReason.SessionStopped, {
+        this.webClientService.stop({
+            reason: DisconnectReason.SessionStopped,
             send: true,
             close: false,
             redirect: false,
+            connectionBuildupState: 'push',
         });
         this.$timeout(() => {
             this.$log.debug(this.logTag, 'Starting new connection');
-            this.webClientService.init(originalKeyStore, originalPeerPermanentKeyBytes, true);
+            this.webClientService.init({
+                keyStore: originalKeyStore,
+                peerTrustedKey: originalPeerPermanentKeyBytes,
+                resume: true,
+            });
+
             this.webClientService.start(skipPush).then(
                 () => { /* ok */ },
                 (error) => {

+ 1 - 1
src/helpers.ts

@@ -258,7 +258,7 @@ export function escapeRegExp(str: string) {
  * msgpack encoded data.
  */
 export function msgpackVisualizer(bytes: Uint8Array): string {
-    return 'https://msgpack.dbrgn.ch#base64=' + encodeURIComponent(btoa(bytes as any));
+    return 'https://msgpack.dbrgn.ch#base64=' + encodeURIComponent(btoa(String.fromCharCode.apply(null, bytes)));
 }
 
 /**

+ 4 - 2
src/partials/messenger.ts

@@ -1009,7 +1009,8 @@ class NavigationController {
             .ok(this.$translate.instant('common.YES'))
             .cancel(this.$translate.instant('common.CANCEL'));
         this.$mdDialog.show(confirm).then(() => {
-            this.webClientService.stop(threema.DisconnectReason.SessionStopped, {
+            this.webClientService.stop({
+                reason: threema.DisconnectReason.SessionStopped,
                 send: true,
                 close: true,
                 redirect: true,
@@ -1031,7 +1032,8 @@ class NavigationController {
             .ok(this.$translate.instant('common.YES'))
             .cancel(this.$translate.instant('common.CANCEL'));
         this.$mdDialog.show(confirm).then(() => {
-            this.webClientService.stop(threema.DisconnectReason.SessionDeleted, {
+            this.webClientService.stop({
+                reason: threema.DisconnectReason.SessionDeleted,
                 send: true,
                 close: true,
                 redirect: true,

+ 11 - 0
src/partials/welcome.html

@@ -138,5 +138,16 @@
             </md-button>
         </div>
 
+        <div ng-if="ctrl.state === 'reconnect_failed'">
+            <p class="state error">
+                <strong><span translate>common.ERROR</span>:</strong> <span translate>connecting.RECONNECT_FAILED</span><br>
+                <span translate>welcome.PLEASE_RELOAD</span>
+            </p>
+            <br>
+            <md-button class="md-raised md-primary" ng-click="ctrl.reload()">
+                <i class="material-icons">refresh</i> <span translate>welcome.RELOAD</span>
+            </md-button>
+        </div>
+
     </div>
 </div>

+ 28 - 19
src/partials/welcome.ts

@@ -20,7 +20,6 @@
 /// <reference path="../types/broadcastchannel.d.ts" />
 
 import {
-    StateParams as UiStateParams,
     StateProvider as UiStateProvider,
     StateService as UiStateService,
 } from '@uirouter/angularjs';
@@ -36,6 +35,7 @@ import {VersionService} from '../services/version';
 import {WebClientService} from '../services/webclient';
 
 import GlobalConnectionState = threema.GlobalConnectionState;
+import DisconnectReason = threema.DisconnectReason;
 
 class DialogController {
     // TODO: This is also used in partials/messenger.ts. We could somehow
@@ -53,10 +53,6 @@ class DialogController {
     }
 }
 
-interface WelcomeStateParams extends UiStateParams {
-    initParams: null | {keyStore: saltyrtc.KeyStore, peerTrustedKey: Uint8Array};
-}
-
 class WelcomeController {
 
     private static REDIRECT_DELAY = 500;
@@ -94,12 +90,12 @@ class WelcomeController {
     private browserWarningShown: boolean = false;
 
     public static $inject = [
-        '$scope', '$state', '$stateParams', '$timeout', '$interval', '$log', '$window', '$mdDialog', '$translate',
+        '$scope', '$state', '$timeout', '$interval', '$log', '$window', '$mdDialog', '$translate',
         'WebClientService', 'TrustedKeyStore', 'StateService', 'PushService', 'BrowserService',
         'VersionService', 'SettingsService', 'ControllerService',
         'BROWSER_MIN_VERSIONS', 'CONFIG',
     ];
-    constructor($scope: ng.IScope, $state: UiStateService, $stateParams: WelcomeStateParams,
+    constructor($scope: ng.IScope, $state: UiStateService,
                 $timeout: ng.ITimeoutService, $interval: ng.IIntervalService,
                 $log: ng.ILogService, $window: ng.IWindowService, $mdDialog: ng.material.IDialogService,
                 $translate: ng.translate.ITranslateService,
@@ -130,6 +126,8 @@ class WelcomeController {
         this.settingsService = settingsService;
         this.config = config;
 
+        // TODO: Allow to trigger below behaviour by using state parameters
+
         // Determine whether browser warning should be shown
         this.browser = browserService.getBrowser();
         const version = this.browser.version;
@@ -198,12 +196,7 @@ class WelcomeController {
         }
 
         // Determine connection mode
-        if ($stateParams.initParams !== null) {
-            this.mode = 'unlock';
-            const keyStore = $stateParams.initParams.keyStore;
-            const peerTrustedKey = $stateParams.initParams.peerTrustedKey;
-            this.reconnect(keyStore, peerTrustedKey);
-        } else if (hasTrustedKey) {
+        if (hasTrustedKey) {
             this.mode = 'unlock';
             this.unlock();
         } else {
@@ -261,7 +254,15 @@ class WelcomeController {
         this.$log.info(this.logTag, 'Initialize session by scanning QR code...');
 
         // Initialize webclient with new keystore
-        this.webClientService.init();
+        this.webClientService.stop({
+            reason: DisconnectReason.SessionStopped,
+            send: false,
+            close: true,
+            redirect: false,
+        });
+        this.webClientService.init({
+            resume: false,
+        });
 
         // Set up the broadcast channel that checks whether we're already connected in another tab
         this.setupBroadcastChannel(this.webClientService.salty.keyStore.publicKeyHex);
@@ -384,7 +385,17 @@ class WelcomeController {
      * Reconnect using a specific keypair and peer public key.
      */
     private reconnect(keyStore: saltyrtc.KeyStore, peerTrustedKey: Uint8Array): void {
-        this.webClientService.init(keyStore, peerTrustedKey);
+        this.webClientService.stop({
+            reason: DisconnectReason.SessionStopped,
+            send: false,
+            close: true,
+            redirect: false,
+        });
+        this.webClientService.init({
+            keyStore: keyStore,
+            peerTrustedKey: peerTrustedKey,
+            resume: false,
+        });
         this.start();
     }
 
@@ -476,15 +487,13 @@ class WelcomeController {
 
         this.$mdDialog.show(confirm).then(() =>  {
             // Force-stop the webclient
-            this.webClientService.stop(threema.DisconnectReason.SessionDeleted, {
+            this.webClientService.stop({
+                reason: DisconnectReason.SessionDeleted,
                 send: true,
                 close: true,
                 redirect: false,
             });
 
-            // Reset state
-            this.stateService.updateConnectionBuildupState('new');
-
             // Go back to scan mode
             this.mode = 'scan';
             this.password = '';

+ 18 - 6
src/protocol/cache.ts

@@ -47,20 +47,25 @@ export class ChunkCache {
 
     /**
      * Get a reference to the currently cached chunks.
+     *
+     * Note: Blacklisted chunks will be filtered automatically.
      */
     public get chunks(): CachedChunk[] {
-        return this.cache;
+        return this.cache.filter((chunk) => chunk !== null);
     }
 
     /**
-     * Transfer an array of cached chunks to this cache instance.
+     * Transfer an array of cached chunks to this cache instance and return the
+     * amount of chunks that have been transferred.
      */
-    public transfer(cache: CachedChunk[]): void {
+    public transfer(cache: CachedChunk[]): number {
         // Add chunks but remove all which should not be retransmitted
         cache = cache.filter((chunk) => chunk !== null);
+        const count = cache.length;
         for (const chunk of cache) {
             this.append(chunk);
         }
+        return count;
     }
 
     /**
@@ -76,9 +81,11 @@ export class ChunkCache {
     }
 
     /**
-     * Prune cached chunks that have been acknowledged.
+     * Prune cached chunks that have been acknowledged. Return the
+     * amount of chunks which have been acknowledged and the amount of
+     * chunks left in the cache.
      */
-    public prune(theirSequenceNumber: number): void {
+    public prune(theirSequenceNumber: number): { acknowledged: number, left: number } {
         try {
             this._sequenceNumber.validate(theirSequenceNumber);
         } catch (error) {
@@ -87,7 +94,7 @@ export class ChunkCache {
 
         // Calculate the slice start index for the chunk cache
         // Important: Our sequence number is one chunk ahead!
-        const beginOffset = theirSequenceNumber + 1 - this._sequenceNumber.get();
+        const beginOffset = theirSequenceNumber - this._sequenceNumber.get();
         if (beginOffset > 0) {
             throw new Error('Remote travelled through time and acknowledged a chunk which is in the future');
         } else if (-beginOffset > this.cache.length) {
@@ -95,9 +102,14 @@ export class ChunkCache {
         }
 
         // Slice our cache & recalculate size
+        const chunkCountBefore = this.cache.length;
         this.cache = beginOffset === 0 ? [] : this.cache.slice(beginOffset);
         this._byteLength = this.cache
             .filter((chunk) => chunk !== null)
             .reduce((sum, chunk) => sum + chunk.byteLength, 0);
+        return {
+            acknowledged: chunkCountBefore + beginOffset,
+            left: this.cache.length,
+        };
     }
 }

+ 14 - 54
src/services/peerconnection.ts

@@ -39,9 +39,6 @@ export class PeerConnectionHelper {
     public connectionState: TaskConnectionState = TaskConnectionState.New;
     public onConnectionStateChange: (state: TaskConnectionState) => void = null;
 
-    // Internal callback when connection closes
-    private onConnectionClosed: () => void = null;
-
     // Debugging
     private censorCandidates: boolean;
 
@@ -179,14 +176,10 @@ export class PeerConnectionHelper {
     /**
      * Create a new secure data channel.
      */
-    public createSecureDataChannel(label: string, onopenHandler?): saltyrtc.tasks.webrtc.SecureDataChannel {
+    public createSecureDataChannel(label: string): saltyrtc.tasks.webrtc.SecureDataChannel {
         const dc: RTCDataChannel = this.pc.createDataChannel(label);
         dc.binaryType = 'arraybuffer';
-        const sdc: saltyrtc.tasks.webrtc.SecureDataChannel = this.webrtcTask.wrapDataChannel(dc);
-        if (onopenHandler !== undefined) {
-            sdc.onopen = onopenHandler;
-        }
-        return sdc;
+        return this.webrtcTask.wrapDataChannel(dc);
     }
 
     /**
@@ -198,56 +191,23 @@ export class PeerConnectionHelper {
             if (this.onConnectionStateChange !== null) {
                 this.$timeout(() => this.onConnectionStateChange(state), 0);
             }
-            if (this.onConnectionClosed !== null && state === TaskConnectionState.Disconnected) {
-                this.$timeout(() => this.onConnectionClosed(), 0);
-            }
         }
     }
 
     /**
-     * Close the peer connection.
-     *
-     * Return a promise that resolves once the connection is actually closed.
+     * Unbind all event handler and abruptly close the peer connection.
      */
-    public close(): ng.IPromise<{}> {
-        return this.$q((resolve, reject) => {
-            const signalingClosed = this.pc.signalingState as string === 'closed'; // Legacy
-            const connectionClosed = this.pc.connectionState === 'closed';
-            if (!signalingClosed && !connectionClosed) {
-
-                // If connection state is not yet "disconnected", register a callback
-                // for the disconnect event.
-                if (this.connectionState !== 'disconnected') {
-                    // Disconnect timeout
-                    let timeout: ng.IPromise<any>;
-
-                    // Handle connection closed event
-                    this.onConnectionClosed = () => {
-                        this.$timeout.cancel(timeout);
-                        this.onConnectionClosed = null;
-                        resolve();
-                    };
-
-                    // Launch timeout
-                    timeout = this.$timeout(() => {
-                        this.onConnectionClosed = null;
-                        reject('Timeout');
-                    }, 2000);
-                }
-
-                // Close connection
-                setTimeout(() => {
-                    this.pc.close();
-                }, 0);
-
-                // If connection state is already "disconnected", resolve immediately.
-                if (this.connectionState === 'disconnected') {
-                    resolve();
-                }
-            } else {
-                resolve();
-            }
-        });
+    public close(): void {
+        this.webrtcTask.off();
+        this.pc.onnegotiationneeded = null;
+        this.pc.onconnectionstatechange = null;
+        this.pc.onsignalingstatechange = null;
+        this.pc.onicecandidate = null;
+        this.pc.onicecandidateerror = null;
+        this.pc.oniceconnectionstatechange = null;
+        this.pc.onicegatheringstatechange = null;
+        this.pc.ondatachannel = null;
+        this.pc.close();
     }
 
     /**

+ 5 - 4
src/services/state.ts

@@ -43,7 +43,7 @@ export class StateService {
     public taskConnectionState: TaskConnectionState;
 
     // Connection buildup state
-    public connectionBuildupState: threema.ConnectionBuildupState = 'connecting';
+    public connectionBuildupState: threema.ConnectionBuildupState;
     public progress = 0;
     private progressInterval: ng.IPromise<any> = null;
     public slowConnect = false;
@@ -238,8 +238,8 @@ export class StateService {
     /**
      * Reset all states.
      */
-    public reset(): void {
-        this.$log.debug(this.logTag, 'Reset');
+    public reset(connectionBuildupState: threema.ConnectionBuildupState = 'new'): void {
+        this.$log.debug(this.logTag, 'Reset states');
 
         // Reset state
         this.signalingConnectionState = 'new';
@@ -247,6 +247,7 @@ export class StateService {
         this.stage = Stage.Signaling;
         this.state = GlobalConnectionState.Error;
         this.wasConnected = false;
-        this.connectionBuildupState = 'connecting';
+        this.connectionBuildupState = connectionBuildupState;
+        this.progress = 0;
     }
 }

+ 101 - 47
src/services/webclient.ts

@@ -45,6 +45,7 @@ import {SequenceNumber} from '../protocol/sequence_number';
 import InitializationStep = threema.InitializationStep;
 import ContactReceiverFeature = threema.ContactReceiverFeature;
 import DisconnectReason = threema.DisconnectReason;
+import ConnectionBuildupState = threema.ConnectionBuildupState;
 
 /**
  * Payload of a connectionInfo message.
@@ -352,21 +353,25 @@ export class WebClientService {
     /**
      * Initialize the webclient service.
      *
-     * Warning: Do not call this with `resumeSession` set to `false` in case
+     * Warning: Do not call this with `flags.resume` set to `false` in case
      *          messages can be queued by the user.
      */
-    public init(keyStore?: saltyrtc.KeyStore, peerTrustedKey?: Uint8Array, resumeSession = true): void {
-        // Reset state
-        this.stateService.reset();
+    public init(flags: {
+        keyStore?: saltyrtc.KeyStore,
+        peerTrustedKey?: Uint8Array,
+        resume: boolean,
+    }): void {
+        let keyStore = flags.keyStore;
+        let resume = flags.resume;
 
         // Reset fields in case the session should explicitly not be resumed
-        if (!resumeSession) {
+        if (!resume) {
             this._resetFields();
         }
 
         // Only move the previous connection's instances if the previous
         // connection was successful (and if there was one at all).
-        if (resumeSession &&
+        if (resume &&
             this.outgoingMessageSequenceNumber && this.unchunker &&
             this.previousChunkCache === this.currentChunkCache) {
             // Move instances that we need to re-establish a previous session
@@ -376,7 +381,7 @@ export class WebClientService {
         } else {
             // Discard session
             this.discardSession({ resetMessageSequenceNumber: true });
-            resumeSession = false;
+            resume = false;
         }
 
         // Initialise connection caches
@@ -431,8 +436,8 @@ export class WebClientService {
             .withKeyStore(keyStore)
             .usingTasks(tasks)
             .withPingInterval(30);
-        if (keyStore !== undefined && peerTrustedKey !== undefined) {
-            builder = builder.withTrustedPeerKey(peerTrustedKey);
+        if (flags.peerTrustedKey !== undefined) {
+            builder = builder.withTrustedPeerKey(flags.peerTrustedKey);
         }
         this.salty = builder.asInitiator();
         if (this.config.DEBUG) {
@@ -528,7 +533,7 @@ export class WebClientService {
 
             // Otherwise, no handover is necessary.
             } else {
-                this.onHandover(resumeSession);
+                this.onHandover(resume);
                 return;
             }
         });
@@ -545,7 +550,7 @@ export class WebClientService {
             // Ignore handovers requested by non-WebRTC tasks
             if (this.chosenTask === threema.ChosenTask.WebRTC) {
                 this.$log.debug(this.logTag, 'Handover done');
-                this.onHandover(resumeSession);
+                this.onHandover(resume);
             }
         });
 
@@ -606,7 +611,8 @@ export class WebClientService {
      */
     private failSession() {
         // Stop session
-        this.stop(DisconnectReason.SessionError, {
+        this.stop({
+            reason: DisconnectReason.SessionError,
             send: true,
             close: true,
             redirect: true,
@@ -653,20 +659,22 @@ export class WebClientService {
         }
 
         // Remove chunks that have been received by the remote side
+        const size = this.previousChunkCache.byteLength;
+        let result;
+        this.$log.debug(`Pruning cache (local-sn=${this.previousChunkCache.sequenceNumber.get()}, ` +
+            `remote-sn=${remoteInfo.resume.sequenceNumber})`);
         try {
-            this.previousChunkCache.prune(remoteInfo.resume.sequenceNumber);
+            result = this.previousChunkCache.prune(remoteInfo.resume.sequenceNumber);
         } catch (error) {
             // Not recoverable
             throw new Error(`Unable to resume session: ${error}`);
         }
+        this.$log.debug(`Chunk cache pruned, acknowledged: ${result.acknowledged}, left: ${result.left}, size: ` +
+            `${size} -> ${this.previousChunkCache.byteLength}`);
 
         // Transfer the cache (filters chunks which should not be retransmitted)
-        this.currentChunkCache.transfer(this.previousChunkCache.chunks);
-
-        // Resend chunks
-        for (const chunk of this.currentChunkCache.chunks) {
-            this.sendChunk(chunk, true, false);
-        }
+        const transferred = this.currentChunkCache.transfer(this.previousChunkCache.chunks);
+        this.$log.debug(`Chunk cache transferred (${transferred} chunks)`);
 
         // Invalidate the previous connection cache & id
         // Note: This MUST be done immediately after the session has been
@@ -676,6 +684,13 @@ export class WebClientService {
         this.previousIncomingChunkSequenceNumber = null;
         this.previousChunkCache = null;
 
+        // Resend chunks
+        const chunks = this.currentChunkCache.chunks;
+        this.$log.debug(this.logTag, `Sending cached chunks: ${chunks.length}`);
+        for (const chunk of chunks) {
+            this.sendChunk(chunk, true, false);
+        }
+
         // Resumed!
         return true;
     }
@@ -724,14 +739,16 @@ export class WebClientService {
             this.previousConnectionId !== null &&
             this.previousIncomingChunkSequenceNumber !== null &&
             this.previousChunkCache !== null;
-        this.$log.debug(this.logTag, 'Sending connection info');
         if (resumeSession) {
+            const incomingSequenceNumber = this.previousIncomingChunkSequenceNumber.get();
+            this.$log.debug(this.logTag, `Sending connection info (resume=yes, sn-in=${incomingSequenceNumber})`);
             this._sendConnectionInfo(
                 this.currentConnectionId.buffer,
                 this.previousConnectionId.buffer,
-                this.previousIncomingChunkSequenceNumber.get(),
+                incomingSequenceNumber,
             );
         } else {
+            this.$log.debug(this.logTag, 'Sending connection info (resume=no)');
             this._sendConnectionInfo(this.currentConnectionId.buffer);
         }
 
@@ -746,7 +763,14 @@ export class WebClientService {
             this.failSession();
             return;
         }
-        this.$log.debug(this.logTag, 'Received connection info');
+        let outgoingSequenceNumber: string | number = 'n/a';
+        let remoteResume = 'no';
+        if (remoteInfo.resume !== undefined) {
+            outgoingSequenceNumber = remoteInfo.resume.sequenceNumber;
+            remoteResume = 'yes';
+        }
+        this.$log.debug(this.logTag, `Received connection info (resume=${remoteResume}, ` +
+            `sn-out=${outgoingSequenceNumber})`);
 
         // Resume the session (if both requested to resume the same connection)
         let sessionWasResumed;
@@ -838,15 +862,13 @@ export class WebClientService {
         if (this.chosenTask === threema.ChosenTask.WebRTC) {
             // Create secure data channel
             this.$log.debug(this.logTag, 'Create SecureDataChannel "' + WebClientService.DC_LABEL + '"...');
-            this.secureDataChannel = this.pcHelper.createSecureDataChannel(
-                WebClientService.DC_LABEL,
-                (event: Event) => {
-                    this.$log.debug(this.logTag, 'SecureDataChannel open');
-                    this.onConnectionEstablished(resumeSession).catch((error) => {
-                        this.$log.error(this.logTag, 'Error during handshake:', error);
-                    });
-                },
-            );
+            this.secureDataChannel = this.pcHelper.createSecureDataChannel(WebClientService.DC_LABEL);
+            this.secureDataChannel.onopen = () => {
+                this.$log.debug(this.logTag, 'SecureDataChannel open');
+                this.onConnectionEstablished(resumeSession).catch((error) => {
+                    this.$log.error(this.logTag, 'Error during handshake:', error);
+                });
+            };
 
             // Handle incoming messages
             this.secureDataChannel.onmessage = (ev: MessageEvent) => {
@@ -968,34 +990,41 @@ export class WebClientService {
      *   has been replaced, a protocol error occurred or in case `redirect` has
      *   been set to `true`.
      * @redirect will redirect to the welcome page if set to `true`.
+     * @connectionBuildupState: The connection buildup state the state service
+     *   will be reset to.
      */
     public stop(
-        reason: DisconnectReason,
-        flags: { send: boolean, close: boolean, redirect: boolean },
+        args: {
+            reason: DisconnectReason,
+            send: boolean,
+            close: boolean,
+            redirect: boolean,
+            connectionBuildupState?: ConnectionBuildupState,
+        },
     ): void {
-        this.$log.info(this.logTag, 'Disconnecting...');
-        let close = flags.close;
+        this.$log.info(this.logTag, 'Stopping');
+        let close = args.close;
         let remove = false;
 
         // A redirect to the welcome page always implies a close
-        if (flags.redirect) {
+        if (args.redirect) {
             close = true;
         }
 
         // Session deleted: Force close and delete
-        if (reason === DisconnectReason.SessionDeleted) {
+        if (args.reason === DisconnectReason.SessionDeleted) {
             close = true;
             remove = true;
         }
 
         // Session replaced or error'ed: Force close
-        if (reason === DisconnectReason.SessionReplaced || reason === DisconnectReason.SessionError) {
+        if (args.reason === DisconnectReason.SessionReplaced || args.reason === DisconnectReason.SessionError) {
             close = true;
         }
 
         // Send disconnect reason to the remote peer if requested
-        if (flags.send && this.stateService.state === threema.GlobalConnectionState.Ok) {
-            this._sendUpdate(WebClientService.SUB_TYPE_CONNECTION_DISCONNECT, false, undefined, {reason: reason});
+        if (args.send && this.stateService.state === threema.GlobalConnectionState.Ok) {
+            this._sendUpdate(WebClientService.SUB_TYPE_CONNECTION_DISCONNECT, false, undefined, {reason: args.reason});
         }
 
         // Stop ack timer
@@ -1006,7 +1035,7 @@ export class WebClientService {
         }
 
         // Reset states
-        this.stateService.reset();
+        this.stateService.reset(args.connectionBuildupState);
 
         // Reset the unread count
         this.resetUnreadCount();
@@ -1030,23 +1059,34 @@ export class WebClientService {
             this.$log.debug(this.logTag, 'Session closed (cannot be resumed)');
         } else {
             this.previousChunkCache = this.currentChunkCache;
-            this.$log.debug(this.logTag, 'Session remains open (can be resumed)');
+            this.$log.debug(this.logTag, 'Session remains open (can be resumed at sn-out=' +
+                `${this.previousChunkCache.sequenceNumber.get()})`);
         }
 
         // Close data channel
         if (this.secureDataChannel !== null) {
             this.$log.debug(this.logTag, 'Closing secure datachannel');
+            this.secureDataChannel.onopen = null;
+            this.secureDataChannel.onmessage = null;
+            this.secureDataChannel.onbufferedamountlow = null;
+            this.secureDataChannel.onerror = null;
+            this.secureDataChannel.onclose = null;
             this.secureDataChannel.close();
         }
 
         // Close SaltyRTC connection
+        if (this.relayedDataTask !== null) {
+            this.relayedDataTask.off();
+        }
         if (this.salty !== null) {
             this.$log.debug(this.logTag, 'Closing signaling');
+            this.salty.off();
             this.salty.disconnect();
         }
 
         // Close peer connection
         if (this.pcHelper !== null) {
+            this.pcHelper.onConnectionStateChange = null;
             this.pcHelper.close();
             this.$log.debug(this.logTag, 'Peer connection closed');
         } else {
@@ -1054,7 +1094,7 @@ export class WebClientService {
         }
 
         // Done, redirect now if requested
-        if (flags.redirect) {
+        if (args.redirect) {
             this.$timeout(() => {
                 this.$state.go('welcome');
             }, 0);
@@ -2035,14 +2075,18 @@ export class WebClientService {
 
         // Remove chunks which have already been received by the remote side
         const size = this.currentChunkCache.byteLength;
+        let result;
+        this.$log.debug(`Pruning cache (local-sn=${this.currentChunkCache.sequenceNumber.get()}, ` +
+            `remote-sn=${sequenceNumber})`);
         try {
-            this.currentChunkCache.prune(sequenceNumber);
+            result = this.currentChunkCache.prune(sequenceNumber);
         } catch (error) {
             this.$log.error(this.logTag, error);
             this.failSession();
             return;
         }
-        this.$log.debug(`Chunk cache size ${size} in bytes -> ${this.currentChunkCache.byteLength}`);
+        this.$log.debug(`Chunk cache pruned, acknowledged: ${result.acknowledged}, left: ${result.left}, size: ` +
+            `${size} -> ${this.currentChunkCache.byteLength}`);
 
         // Clear pending ack requests
         if (this.pendingAckRequest !== null && sequenceNumber >= this.pendingAckRequest) {
@@ -2088,7 +2132,8 @@ export class WebClientService {
         }
 
         // Stop and show an alert on the welcome page
-        this.stop(reason, {
+        this.stop({
+            reason: reason,
             send: false,
             close: true,
             redirect: true,
@@ -3419,6 +3464,9 @@ export class WebClientService {
                 {
                     // Send bytes through WebRTC DataChannel
                     const bytes: Uint8Array = this.msgpackEncode(message);
+                    if (this.config.MSGPACK_DEBUGGING) {
+                        this.$log.debug('Outgoing message payload: ' + msgpackVisualizer(bytes));
+                    }
                     this.secureDataChannel.send(bytes);
                 }
                 break;
@@ -3430,6 +3478,9 @@ export class WebClientService {
 
                     // Send bytes through e2e encrypted WebSocket
                     const bytes: Uint8Array = this.msgpackEncode(message);
+                    if (this.config.MSGPACK_DEBUGGING) {
+                        this.$log.debug('Outgoing message payload: ' + msgpackVisualizer(bytes));
+                    }
 
                     // Increment the outgoing message sequence number
                     const messageSequenceNumber = this.outgoingMessageSequenceNumber.increment();
@@ -3482,6 +3533,9 @@ export class WebClientService {
         }
 
         // Add to chunk cache
+        if (this.config.MSG_DEBUGGING) {
+            this.$log.debug(`[Chunk] Caching chunk (retransmit=${retransmit}:`, chunk);
+        }
         try {
             chunkCache.append(retransmit ? chunk : null);
         } catch (error) {
@@ -3503,7 +3557,7 @@ export class WebClientService {
      * Handle an incoming chunk from the underlying transport.
      */
     private receiveChunk(chunk: Uint8Array): void {
-        if (this.config.MSG_DEBUGGING && this.config.DEBUG) {
+        if (this.config.MSG_DEBUGGING) {
             this.$log.debug('[Chunk] Received chunk:', chunk);
         }
 

+ 2 - 1
src/threema.d.ts

@@ -401,10 +401,11 @@ declare namespace threema {
      * - loading: Loading initial data
      * - done: Initial loading is finished
      * - closed: Connection is closed
+     * - reconnect_failed: Reconnecting failed after several attempts
      *
      */
     type ConnectionBuildupState = 'new' | 'connecting' | 'push' | 'manual_start' | 'already_connected'
-        | 'waiting' | 'peer_handshake' | 'loading' | 'done' | 'closed';
+        | 'waiting' | 'peer_handshake' | 'loading' | 'done' | 'closed' | 'reconnect_failed';
 
     interface ConnectionBuildupStateChange {
         state: ConnectionBuildupState;