Improve WebSocket health monitoring.

This commit is contained in:
Cody Henthorne
2021-07-27 13:40:33 -04:00
committed by GitHub
parent fc6db45e59
commit 712b0c147a
20 changed files with 559 additions and 351 deletions

View File

@@ -0,0 +1,44 @@
package org.thoughtcrime.securesms.net;
import java.util.Arrays;
/**
* Track error occurrences by time and indicate if too many occur within the
* time limit.
*/
public final class HttpErrorTracker {
private final long[] timestamps;
private final long errorTimeRange;
public HttpErrorTracker(int samples, long errorTimeRange) {
this.timestamps = new long[samples];
this.errorTimeRange = errorTimeRange;
}
public synchronized boolean addSample(long now) {
long errorsMustBeAfter = now - errorTimeRange;
int count = 1;
int minIndex = 0;
for (int i = 0; i < timestamps.length; i++) {
if (timestamps[i] < errorsMustBeAfter) {
timestamps[i] = 0;
} else if (timestamps[i] != 0) {
count++;
}
if (timestamps[i] < timestamps[minIndex]) {
minIndex = i;
}
}
timestamps[minIndex] = now;
if (count >= timestamps.length) {
Arrays.fill(timestamps, 0);
return true;
}
return false;
}
}

View File

@@ -1,89 +0,0 @@
package org.thoughtcrime.securesms.net;
import android.app.Application;
import androidx.annotation.NonNull;
import org.greenrobot.eventbus.EventBus;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.whispersystems.signalservice.api.websocket.ConnectivityListener;
import okhttp3.Response;
/**
* Our standard listener for reacting to the state of the websocket. Translates the state into a
* LiveData for observation.
*/
public class PipeConnectivityListener implements ConnectivityListener {
private static final String TAG = Log.tag(PipeConnectivityListener.class);
private final Application application;
private final DefaultValueLiveData<State> state;
public PipeConnectivityListener(@NonNull Application application) {
this.application = application;
this.state = new DefaultValueLiveData<>(State.DISCONNECTED);
}
@Override
public void onConnected() {
Log.i(TAG, "onConnected()");
TextSecurePreferences.setUnauthorizedReceived(application, false);
state.postValue(State.CONNECTED);
}
@Override
public void onConnecting() {
Log.i(TAG, "onConnecting()");
state.postValue(State.CONNECTING);
}
@Override
public void onDisconnected() {
Log.w(TAG, "onDisconnected()");
if (state.getValue() != State.FAILURE) {
state.postValue(State.DISCONNECTED);
}
}
@Override
public void onAuthenticationFailure() {
Log.w(TAG, "onAuthenticationFailure()");
TextSecurePreferences.setUnauthorizedReceived(application, true);
EventBus.getDefault().post(new ReminderUpdateEvent());
state.postValue(State.FAILURE);
}
@Override
public boolean onGenericFailure(Response response, Throwable throwable) {
Log.w(TAG, "onGenericFailure() Response: " + response, throwable);
state.postValue(State.FAILURE);
if (SignalStore.proxy().isProxyEnabled()) {
Log.w(TAG, "Encountered an error while we had a proxy set! Terminating the connection to prevent retry spam.");
ApplicationDependencies.closeConnections();
return false;
} else {
return true;
}
}
public void reset() {
state.postValue(State.DISCONNECTED);
}
public @NonNull DefaultValueLiveData<State> getState() {
return state;
}
public enum State {
DISCONNECTED, CONNECTING, CONNECTED, FAILURE
}
}

View File

@@ -0,0 +1,171 @@
package org.thoughtcrime.securesms.net;
import android.app.Application;
import androidx.annotation.NonNull;
import org.greenrobot.eventbus.EventBus;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.whispersystems.libsignal.util.guava.Preconditions;
import org.whispersystems.signalservice.api.SignalWebSocket;
import org.whispersystems.signalservice.api.util.SleepTimer;
import org.whispersystems.signalservice.api.websocket.HealthMonitor;
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState;
import org.whispersystems.signalservice.internal.websocket.WebSocketConnection;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import io.reactivex.rxjava3.schedulers.Schedulers;
/**
* Monitors the health of the identified and unidentified WebSockets. If either one appears to be
* unhealthy, will trigger restarting both.
* <p>
* The monitor is also responsible for sending heartbeats/keep-alive messages to prevent
* timeouts.
*/
public final class SignalWebSocketHealthMonitor implements HealthMonitor {
private static final String TAG = Log.tag(SignalWebSocketHealthMonitor.class);
private static final long KEEP_ALIVE_SEND_CADENCE = TimeUnit.SECONDS.toMillis(WebSocketConnection.KEEPALIVE_TIMEOUT_SECONDS);
private static final long MAX_TIME_SINCE_SUCCESSFUL_KEEP_ALIVE = KEEP_ALIVE_SEND_CADENCE * 3;
private final Application context;
private SignalWebSocket signalWebSocket;
private final SleepTimer sleepTimer;
private volatile KeepAliveSender keepAliveSender;
private final HealthState identified = new HealthState();
private final HealthState unidentified = new HealthState();
public SignalWebSocketHealthMonitor(@NonNull Application context, @NonNull SleepTimer sleepTimer) {
this.context = context;
this.sleepTimer = sleepTimer;
}
public void monitor(@NonNull SignalWebSocket signalWebSocket) {
Preconditions.checkNotNull(signalWebSocket);
Preconditions.checkArgument(this.signalWebSocket == null, "monitor can only be called once");
this.signalWebSocket = signalWebSocket;
//noinspection ResultOfMethodCallIgnored
signalWebSocket.getWebSocketState()
.subscribeOn(Schedulers.computation())
.observeOn(Schedulers.computation())
.distinctUntilChanged()
.subscribe(s -> onStateChange(s, identified));
//noinspection ResultOfMethodCallIgnored
signalWebSocket.getUnidentifiedWebSocketState()
.subscribeOn(Schedulers.computation())
.observeOn(Schedulers.computation())
.distinctUntilChanged()
.subscribe(s -> onStateChange(s, unidentified));
}
private synchronized void onStateChange(WebSocketConnectionState connectionState, HealthState healthState) {
switch (connectionState) {
case CONNECTED:
TextSecurePreferences.setUnauthorizedReceived(context, false);
break;
case AUTHENTICATION_FAILED:
TextSecurePreferences.setUnauthorizedReceived(context, true);
EventBus.getDefault().post(new ReminderUpdateEvent());
break;
case FAILED:
if (SignalStore.proxy().isProxyEnabled()) {
Log.w(TAG, "Encountered an error while we had a proxy set! Terminating the connection to prevent retry spam.");
ApplicationDependencies.closeConnections();
}
break;
}
healthState.needsKeepAlive = connectionState == WebSocketConnectionState.CONNECTED;
if (keepAliveSender == null && isKeepAliveNecessary()) {
keepAliveSender = new KeepAliveSender();
keepAliveSender.start();
} else if (keepAliveSender != null && !isKeepAliveNecessary()) {
keepAliveSender.shutdown();
keepAliveSender = null;
}
}
@Override
public void onKeepAliveResponse(long sentTimestamp, boolean isIdentifiedWebSocket) {
if (isIdentifiedWebSocket) {
identified.lastKeepAliveReceived = System.currentTimeMillis();
} else {
unidentified.lastKeepAliveReceived = System.currentTimeMillis();
}
}
@Override
public void onMessageError(int status, boolean isIdentifiedWebSocket) {
if (status == 409) {
HealthState healthState = (isIdentifiedWebSocket ? identified : unidentified);
if (healthState.mismatchErrorTracker.addSample(System.currentTimeMillis())) {
Log.w(TAG, "Received too many mismatch device errors, forcing new websockets.");
signalWebSocket.forceNewWebSockets();
}
}
}
private boolean isKeepAliveNecessary() {
return identified.needsKeepAlive || unidentified.needsKeepAlive;
}
private static class HealthState {
private final HttpErrorTracker mismatchErrorTracker = new HttpErrorTracker(5, TimeUnit.MINUTES.toMillis(1));
private volatile boolean needsKeepAlive;
private volatile long lastKeepAliveReceived;
}
/**
* Sends periodic heartbeats/keep-alives over both WebSockets to prevent connection timeouts. If
* either WebSocket fails 3 times to get a return heartbeat both are forced to be recreated.
*/
private class KeepAliveSender extends Thread {
private volatile boolean shouldKeepRunning = true;
public void run() {
identified.lastKeepAliveReceived = System.currentTimeMillis();
unidentified.lastKeepAliveReceived = System.currentTimeMillis();
while (shouldKeepRunning && isKeepAliveNecessary()) {
try {
sleepTimer.sleep(KEEP_ALIVE_SEND_CADENCE);
if (shouldKeepRunning && isKeepAliveNecessary()) {
long keepAliveRequiredSinceTime = System.currentTimeMillis() - MAX_TIME_SINCE_SUCCESSFUL_KEEP_ALIVE;
if (identified.lastKeepAliveReceived < keepAliveRequiredSinceTime || unidentified.lastKeepAliveReceived < keepAliveRequiredSinceTime) {
Log.w(TAG, "Missed keep alives, identified last: " + identified.lastKeepAliveReceived +
" unidentified last: " + unidentified.lastKeepAliveReceived +
" needed by: " + keepAliveRequiredSinceTime);
signalWebSocket.forceNewWebSockets();
} else {
signalWebSocket.sendKeepAlive();
}
}
} catch (Throwable e) {
Log.w(TAG, e);
}
}
}
public void shutdown() {
shouldKeepRunning = false;
}
}
}