1070 lines
40 KiB
Swift
1070 lines
40 KiB
Swift
//
|
|
// Copyright 2021 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
import LibSignalClient
|
|
|
|
public enum OWSChatConnectionType: Int, CaseIterable, CustomDebugStringConvertible {
|
|
case identified = 0
|
|
case unidentified = 1
|
|
|
|
public var debugDescription: String {
|
|
switch self {
|
|
case .identified:
|
|
return "[type: identified]"
|
|
case .unidentified:
|
|
return "[type: unidentified]"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public enum OWSChatConnectionState: Int, CustomDebugStringConvertible {
|
|
case closed = 0
|
|
case connecting = 1
|
|
case open = 2
|
|
|
|
public var debugDescription: String {
|
|
switch self {
|
|
case .closed:
|
|
return "closed"
|
|
case .connecting:
|
|
return "connecting"
|
|
case .open:
|
|
return "open"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
public class OWSChatConnection {
|
|
// TODO: Should we use a higher-priority queue?
|
|
fileprivate static let messageProcessingQueue = DispatchQueue(label: "org.signal.chat-connection.message-processing")
|
|
|
|
public static let chatConnectionStateDidChange = Notification.Name("chatConnectionStateDidChange")
|
|
|
|
fileprivate let serialQueue: DispatchQueue
|
|
|
|
// MARK: -
|
|
|
|
fileprivate let type: OWSChatConnectionType
|
|
fileprivate let appExpiry: AppExpiry
|
|
fileprivate let appReadiness: AppReadiness
|
|
fileprivate let db: any DB
|
|
fileprivate let accountManager: TSAccountManager
|
|
fileprivate let registrationStateChangeManager: RegistrationStateChangeManager
|
|
|
|
// This var must be thread-safe.
|
|
public var currentState: OWSChatConnectionState {
|
|
owsFailDebug("should be using a concrete subclass")
|
|
return .closed
|
|
}
|
|
|
|
public var hasEmptiedInitialQueue: Bool {
|
|
get async {
|
|
return false
|
|
}
|
|
}
|
|
|
|
fileprivate var logPrefix: String {
|
|
"[\(type)]"
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public init(
|
|
type: OWSChatConnectionType,
|
|
accountManager: TSAccountManager,
|
|
appExpiry: AppExpiry,
|
|
appReadiness: AppReadiness,
|
|
db: any DB,
|
|
registrationStateChangeManager: RegistrationStateChangeManager
|
|
) {
|
|
AssertIsOnMainThread()
|
|
|
|
self.serialQueue = DispatchQueue(label: "org.signal.chat-connection-\(type)")
|
|
self.type = type
|
|
self.appExpiry = appExpiry
|
|
self.appReadiness = appReadiness
|
|
self.db = db
|
|
self.accountManager = accountManager
|
|
self.registrationStateChangeManager = registrationStateChangeManager
|
|
|
|
appReadiness.runNowOrWhenAppDidBecomeReadySync { [weak self] in
|
|
self?.appDidBecomeReady()
|
|
}
|
|
}
|
|
|
|
// MARK: - Notifications
|
|
|
|
// We want to observe these notifications lazily to avoid accessing
|
|
// the data store in [application: didFinishLaunchingWithOptions:].
|
|
fileprivate func appDidBecomeReady() {
|
|
AssertIsOnMainThread()
|
|
|
|
NotificationCenter.default.addObserver(self,
|
|
selector: #selector(isCensorshipCircumventionActiveDidChange),
|
|
name: .isCensorshipCircumventionActiveDidChange,
|
|
object: nil)
|
|
NotificationCenter.default.addObserver(self,
|
|
selector: #selector(isSignalProxyReadyDidChange),
|
|
name: .isSignalProxyReadyDidChange,
|
|
object: nil)
|
|
NotificationCenter.default.addObserver(self, selector: #selector(storiesEnabledStateDidChange), name: .storiesEnabledStateDidChange, object: nil)
|
|
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(registrationStateDidChange),
|
|
name: .registrationStateDidChange,
|
|
object: nil
|
|
)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(appExpiryDidChange),
|
|
name: AppExpiry.AppExpiryDidChange,
|
|
object: nil
|
|
)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private struct StateObservation {
|
|
var currentState: OWSChatConnectionState
|
|
var onOpen: [NSObject: CancellableContinuation<Void>]
|
|
}
|
|
|
|
/// This lock is sometimes waited on within an async context; make sure *all* uses release the lock quickly.
|
|
private let stateObservation = AtomicValue(
|
|
StateObservation(currentState: .closed, onOpen: [:]),
|
|
lock: .init()
|
|
)
|
|
|
|
fileprivate func notifyStatusChange(newState: OWSChatConnectionState) {
|
|
// Technically this would be safe to call from anywhere,
|
|
// but requiring it to be on the serial queue means it's less likely
|
|
// for a caller to check a condition that's immediately out of date (a race).
|
|
assertOnQueue(serialQueue)
|
|
|
|
let (oldState, continuationsToResolve): (OWSChatConnectionState, [NSObject: CancellableContinuation<Void>])
|
|
(oldState, continuationsToResolve) = stateObservation.update {
|
|
let oldState = $0.currentState
|
|
if newState == oldState {
|
|
return (oldState, [:])
|
|
}
|
|
$0.currentState = newState
|
|
|
|
var continuationsToResolve: [NSObject: CancellableContinuation<Void>] = [:]
|
|
if case .open = newState {
|
|
continuationsToResolve = $0.onOpen
|
|
$0.onOpen = [:]
|
|
}
|
|
|
|
return (oldState, continuationsToResolve)
|
|
}
|
|
if newState != oldState {
|
|
Logger.info("\(logPrefix): \(oldState) -> \(newState)")
|
|
}
|
|
for (_, waiter) in continuationsToResolve {
|
|
waiter.resume(with: .success(()))
|
|
}
|
|
NotificationCenter.default.postOnMainThread(name: Self.chatConnectionStateDidChange, object: nil)
|
|
}
|
|
|
|
fileprivate var cachedCurrentState: OWSChatConnectionState {
|
|
stateObservation.get().currentState
|
|
}
|
|
|
|
/// Only throws on cancellation.
|
|
func waitForOpen() async throws {
|
|
let cancellationToken = NSObject()
|
|
let cancellableContinuation = CancellableContinuation<Void>()
|
|
stateObservation.update {
|
|
if $0.currentState == .open {
|
|
cancellableContinuation.resume(with: .success(()))
|
|
} else {
|
|
$0.onOpen[cancellationToken] = cancellableContinuation
|
|
}
|
|
}
|
|
try await withTaskCancellationHandler(
|
|
operation: cancellableContinuation.wait,
|
|
onCancel: {
|
|
// Don't cancel because CancellableContinuation does that.
|
|
// We just clean up the state so that we don't leak memory.
|
|
stateObservation.update { _ = $0.onOpen.removeValue(forKey: cancellationToken) }
|
|
}
|
|
)
|
|
}
|
|
|
|
private func waitForOpen(timeout: TimeInterval) async {
|
|
_ = try? await withCooperativeTimeout(
|
|
seconds: timeout,
|
|
operation: { try await self.waitForOpen() }
|
|
)
|
|
}
|
|
|
|
func waitForDisconnectIfClosed() async {
|
|
owsFail("Subclasses must provide an implementation.")
|
|
}
|
|
|
|
// Access on serialQueue.
|
|
private var onSocketShouldBeClosed = [NSObject: CancellableContinuation<Void>]()
|
|
|
|
func waitUntilSocketShouldBeClosed() async throws {
|
|
let cancellationToken = NSObject()
|
|
let cancellableContinuation = CancellableContinuation<Void>()
|
|
serialQueue.async {
|
|
if self.shouldSocketBeOpen() {
|
|
self.onSocketShouldBeClosed[cancellationToken] = cancellableContinuation
|
|
} else {
|
|
cancellableContinuation.resume(with: .success(()))
|
|
}
|
|
}
|
|
try await withTaskCancellationHandler(
|
|
operation: cancellableContinuation.wait,
|
|
onCancel: {
|
|
// Don't cancel because CancellableContinuation does that.
|
|
// We just clean up the state so that we don't leak memory.
|
|
self.serialQueue.async {
|
|
self.onSocketShouldBeClosed.removeValue(forKey: cancellationToken)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
// MARK: - Socket LifeCycle
|
|
|
|
public static var mustAppUseSocketsToMakeRequests: Bool {
|
|
switch CurrentAppContext().type {
|
|
case .main:
|
|
return true
|
|
case .nse:
|
|
return false // because there is a kill switch
|
|
case .share:
|
|
return false
|
|
}
|
|
}
|
|
|
|
public static var canAppUseSocketsToMakeRequests: Bool {
|
|
switch CurrentAppContext().type {
|
|
case .main:
|
|
return true
|
|
case .nse:
|
|
return FeatureFlags.notificationServiceWebSocket && RemoteConfig.current.isNotificationServiceWebSocketEnabled
|
|
case .share:
|
|
return false
|
|
}
|
|
}
|
|
|
|
public var canOpenWebSocket: Bool {
|
|
return serialQueue.sync { self.canOpenWebSocketError == nil }
|
|
}
|
|
|
|
/// Tracks app-wide, "fatal" errors that block web sockets.
|
|
///
|
|
/// If this property is nonnil, the app shouldn't attempt to open a
|
|
/// connection to the server. If `makeRequest` is called while this property
|
|
/// is nonnil, the request will fail with this error.
|
|
///
|
|
/// This property is used for "fatal" errors: "the user isn't registered",
|
|
/// "the app has expired", "this extension doesn't ever use web sockets",
|
|
/// etc. Transient errors ("no network", "the server returned a 5xx", etc.)
|
|
/// don't use this property.
|
|
///
|
|
/// Must be accessed on `serialQueue`.
|
|
private var canOpenWebSocketError: OWSHTTPError? = .networkFailure(.genericFailure)
|
|
|
|
func updateCanOpenWebSocket() {
|
|
serialQueue.async(_updateCanOpenWebSocket)
|
|
}
|
|
|
|
private func _updateCanOpenWebSocket() {
|
|
assertOnQueue(serialQueue)
|
|
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
|
|
|
|
let oldValue = (canOpenWebSocketError == nil)
|
|
canOpenWebSocketError = {
|
|
guard !appExpiry.isExpired(now: Date()) else {
|
|
return .invalidAppState
|
|
}
|
|
guard Self.canAppUseSocketsToMakeRequests else {
|
|
return .networkFailure(.genericFailure)
|
|
}
|
|
guard tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else {
|
|
return .networkFailure(.genericFailure)
|
|
}
|
|
return nil
|
|
}()
|
|
let newValue = (canOpenWebSocketError == nil)
|
|
if newValue != oldValue {
|
|
_applyDesiredSocketState()
|
|
}
|
|
}
|
|
|
|
public final class ConnectionToken {
|
|
private let tokenId: Int
|
|
private weak var chatConnection: OWSChatConnection?
|
|
|
|
fileprivate init(tokenId: Int, chatConnection: OWSChatConnection) {
|
|
self.tokenId = tokenId
|
|
self.chatConnection = chatConnection
|
|
}
|
|
|
|
deinit {
|
|
guard let chatConnection else {
|
|
return
|
|
}
|
|
let didRelease = chatConnection.releaseConnection(self.tokenId)
|
|
owsAssertDebug(!didRelease, "You must explicitly call releaseConnection().")
|
|
}
|
|
|
|
public func releaseConnection() {
|
|
guard let chatConnection else {
|
|
return
|
|
}
|
|
let didRelease = chatConnection.releaseConnection(self.tokenId)
|
|
owsAssertDebug(didRelease, "You can't call releaseConnection() multiple times.")
|
|
}
|
|
}
|
|
|
|
struct ConnectionTokenState {
|
|
var tokenId = 0
|
|
|
|
/// Maps token IDs to how many "connected elsewhere" events can occur before
|
|
/// the token ID is implicitly released. (Note: The owner of the token is
|
|
/// still responsible for calling relaseConnection.)
|
|
var activeTokenIds = [Int: Int]()
|
|
|
|
func shouldSocketBeOpen() -> Bool {
|
|
return self.activeTokenIds.contains(where: { $0.value > 0 })
|
|
}
|
|
}
|
|
|
|
private let connectionTokenState = AtomicValue(ConnectionTokenState(), lock: .init())
|
|
|
|
/// If another process opens a connection, should this one reconnect and
|
|
/// reclaim the connection?
|
|
///
|
|
/// The Notification Service shouldn't be launched while the Main App is
|
|
/// running -- the Main App should always have an open web socket that's
|
|
/// being informed of new messages. Therefore, if the Notification Service
|
|
/// claims the connection, the Main App should take it back. The
|
|
/// Notification Service will allow this to happen and won't try to
|
|
/// reconnect until it receives a new notification.
|
|
public func requestConnection(shouldReconnectIfConnectedElsewhere: Bool) -> ConnectionToken {
|
|
let (connectionToken, shouldConnect) = connectionTokenState.update {
|
|
$0.tokenId += 1
|
|
let shouldConnect = $0.activeTokenIds.isEmpty
|
|
// If we want to reconnect, set the number of retries to "Int.max" (aka
|
|
// "infinity"). If we shouldn't reconnect, set the number of retries to 1.
|
|
$0.activeTokenIds[$0.tokenId] = shouldReconnectIfConnectedElsewhere ? Int.max : 1
|
|
let connectionToken = ConnectionToken(tokenId: $0.tokenId, chatConnection: self)
|
|
return (connectionToken, shouldConnect)
|
|
}
|
|
if shouldConnect {
|
|
applyDesiredSocketState()
|
|
}
|
|
return connectionToken
|
|
}
|
|
|
|
private func releaseConnection(_ tokenId: Int) -> Bool {
|
|
let (didRelease, shouldDisconnect) = connectionTokenState.update {
|
|
let didRelease = $0.activeTokenIds.removeValue(forKey: tokenId) != nil
|
|
return (didRelease, $0.activeTokenIds.isEmpty)
|
|
}
|
|
if shouldDisconnect {
|
|
applyDesiredSocketState()
|
|
}
|
|
return didRelease
|
|
}
|
|
|
|
fileprivate final func didConnectElsewhere() {
|
|
assertOnQueue(serialQueue)
|
|
|
|
// Decrement the number of retries for every token. Don't delete them
|
|
// because the callers are still responsible for releasing them.
|
|
connectionTokenState.update {
|
|
$0.activeTokenIds = $0.activeTokenIds.mapValues { $0 - 1 }
|
|
}
|
|
|
|
// If the socket shouldn't be open, notify anybody who's waiting.
|
|
if !shouldSocketBeOpen() {
|
|
notifySocketShouldBeClosed()
|
|
}
|
|
}
|
|
|
|
// This method aligns the socket state with the "desired" socket state.
|
|
//
|
|
// This method is thread-safe.
|
|
fileprivate final func applyDesiredSocketState() {
|
|
serialQueue.async(self._applyDesiredSocketState)
|
|
}
|
|
|
|
private func shouldSocketBeOpen() -> Bool {
|
|
assertOnQueue(serialQueue)
|
|
|
|
return (
|
|
(canOpenWebSocketError == nil)
|
|
&& connectionTokenState.update { $0.shouldSocketBeOpen() }
|
|
)
|
|
}
|
|
|
|
fileprivate final func _applyDesiredSocketState() {
|
|
assertOnQueue(serialQueue)
|
|
|
|
if shouldSocketBeOpen() {
|
|
owsPrecondition(appReadiness.isAppReady)
|
|
ensureWebsocketExists()
|
|
} else {
|
|
disconnectIfNeeded()
|
|
notifySocketShouldBeClosed()
|
|
}
|
|
}
|
|
|
|
private func notifySocketShouldBeClosed() {
|
|
assertOnQueue(serialQueue)
|
|
|
|
// Notify any listeners that we're no longer trying to connect.
|
|
let continuationsToResume = self.onSocketShouldBeClosed
|
|
self.onSocketShouldBeClosed = [:]
|
|
for (_, continuationToResume) in continuationsToResume {
|
|
continuationToResume.resume(with: .success(()))
|
|
}
|
|
}
|
|
|
|
// This method must be thread-safe.
|
|
fileprivate func cycleSocket() {
|
|
serialQueue.async {
|
|
self.disconnectIfNeeded()
|
|
self._applyDesiredSocketState()
|
|
}
|
|
}
|
|
|
|
fileprivate func ensureWebsocketExists() {
|
|
assertOnQueue(serialQueue)
|
|
owsFailDebug("should be using a concrete subclass")
|
|
}
|
|
|
|
fileprivate func disconnectIfNeeded() {
|
|
assertOnQueue(serialQueue)
|
|
owsFailDebug("should be using a concrete subclass")
|
|
}
|
|
|
|
// MARK: - Notifications
|
|
|
|
@objc
|
|
fileprivate func registrationStateDidChange(_ notification: NSNotification) {
|
|
AssertIsOnMainThread()
|
|
|
|
updateCanOpenWebSocket()
|
|
}
|
|
|
|
@objc
|
|
fileprivate func isCensorshipCircumventionActiveDidChange(_ notification: NSNotification) {
|
|
AssertIsOnMainThread()
|
|
|
|
cycleSocket()
|
|
}
|
|
|
|
@objc
|
|
fileprivate func isSignalProxyReadyDidChange(_ notification: NSNotification) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard SignalProxy.isEnabledAndReady else {
|
|
// When we tear down the relay, everything gets canceled.
|
|
return
|
|
}
|
|
// When we start the relay, we need to reconnect.
|
|
cycleSocket()
|
|
}
|
|
|
|
@objc
|
|
private func appExpiryDidChange(_ notification: NSNotification) {
|
|
AssertIsOnMainThread()
|
|
|
|
updateCanOpenWebSocket()
|
|
}
|
|
|
|
@objc
|
|
fileprivate func storiesEnabledStateDidChange(_ notification: NSNotification) {
|
|
AssertIsOnMainThread()
|
|
|
|
cycleSocket()
|
|
}
|
|
|
|
// MARK: - Message Sending
|
|
|
|
func makeRequest(_ request: TSRequest) async throws -> HTTPResponse {
|
|
owsAssertDebug(Self.canAppUseSocketsToMakeRequests)
|
|
|
|
let requestId = UInt64.random(in: .min ... .max)
|
|
let requestDescription = "\(request) [\(requestId)]"
|
|
do {
|
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
|
|
self.serialQueue.async {
|
|
if let canOpenWebSocketError = self.canOpenWebSocketError {
|
|
continuation.resume(throwing: canOpenWebSocketError)
|
|
} else {
|
|
continuation.resume()
|
|
}
|
|
}
|
|
}
|
|
|
|
// After 30 seconds, we try anyways. We'll probably fail.
|
|
await waitForOpen(timeout: 30)
|
|
|
|
Logger.info("Sending… -> \(requestDescription)")
|
|
|
|
let backgroundTask = OWSBackgroundTask(label: #function)
|
|
defer { backgroundTask.end() }
|
|
|
|
let response = try await self.makeRequestInternal(request, requestId: requestId)
|
|
|
|
Logger.info("HTTP \(response.responseStatusCode) <- \(requestDescription)")
|
|
|
|
OutageDetection.shared.reportConnectionSuccess()
|
|
return response
|
|
} catch {
|
|
if let statusCode = error.httpStatusCode {
|
|
Logger.warn("HTTP \(statusCode) <- \(requestDescription)")
|
|
} else {
|
|
Logger.warn("Failure. <- \(requestDescription): \(error)")
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
fileprivate func makeRequestInternal(_ request: TSRequest, requestId: UInt64) async throws(OWSHTTPError) -> any HTTPResponse {
|
|
owsFail("must be using a concrete subclass")
|
|
}
|
|
|
|
fileprivate final func handleRequestResponse(
|
|
requestUrl: URL,
|
|
responseStatus: Int,
|
|
responseHeaders: HttpHeaders,
|
|
responseData: Data?
|
|
) async throws(OWSHTTPError) -> HTTPResponse {
|
|
if (200...299).contains(responseStatus) {
|
|
let response = HTTPResponseImpl(
|
|
requestUrl: requestUrl,
|
|
status: responseStatus,
|
|
headers: responseHeaders,
|
|
bodyData: responseData
|
|
)
|
|
return response
|
|
} else {
|
|
let error = await HTTPUtils.preprocessMainServiceHTTPError(
|
|
requestUrl: requestUrl,
|
|
responseStatus: responseStatus,
|
|
responseHeaders: responseHeaders,
|
|
responseData: responseData
|
|
)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// MARK: - Reconnect
|
|
|
|
fileprivate static let socketReconnectDelay: TimeInterval = 5
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
internal class OWSChatConnectionUsingLibSignal<Connection: ChatConnection>: OWSChatConnection, ConnectionEventsListener {
|
|
fileprivate let libsignalNet: Net
|
|
|
|
fileprivate enum ConnectionState {
|
|
case closed(task: Task<Void, Never>?)
|
|
case connecting(token: NSObject, task: Task<Connection?, Never>)
|
|
case open(Connection)
|
|
|
|
var asExternalState: OWSChatConnectionState {
|
|
switch self {
|
|
case .closed: .closed
|
|
case .connecting(token: _, task: _): .connecting
|
|
case .open(_): .open
|
|
}
|
|
}
|
|
|
|
func isCurrentlyConnecting(_ token: NSObject) -> Bool {
|
|
guard case .connecting(token: let activeToken, task: _) = self else {
|
|
return false
|
|
}
|
|
return activeToken === token
|
|
}
|
|
|
|
func isActive(_ connection: Connection) -> Bool {
|
|
guard case .open(let activeConnection) = self else {
|
|
return false
|
|
}
|
|
return activeConnection === connection
|
|
}
|
|
|
|
func waitToFinishConnecting(cancel: Bool = false) async -> Connection? {
|
|
switch self {
|
|
case .closed:
|
|
return nil
|
|
case .connecting(token: _, task: let task):
|
|
if cancel {
|
|
task.cancel()
|
|
}
|
|
return await task.value
|
|
case .open(let connection):
|
|
return connection
|
|
}
|
|
}
|
|
}
|
|
|
|
private var _connection: ConnectionState = .closed(task: nil)
|
|
fileprivate var connection: ConnectionState {
|
|
get {
|
|
assertOnQueue(serialQueue)
|
|
return _connection
|
|
}
|
|
set {
|
|
assertOnQueue(serialQueue)
|
|
_connection = newValue
|
|
notifyStatusChange(newState: newValue.asExternalState)
|
|
}
|
|
}
|
|
|
|
internal init(libsignalNet: Net, type: OWSChatConnectionType, accountManager: TSAccountManager, appExpiry: AppExpiry, appReadiness: AppReadiness, db: any DB, registrationStateChangeManager: RegistrationStateChangeManager) {
|
|
self.libsignalNet = libsignalNet
|
|
super.init(type: type, accountManager: accountManager, appExpiry: appExpiry, appReadiness: appReadiness, db: db, registrationStateChangeManager: registrationStateChangeManager)
|
|
}
|
|
|
|
fileprivate func connectChatService() async throws -> Connection {
|
|
fatalError("must be overridden by subclass")
|
|
}
|
|
|
|
fileprivate override func isSignalProxyReadyDidChange(_ notification: NSNotification) {
|
|
AssertIsOnMainThread()
|
|
// The libsignal connection needs to be recreated whether the proxy is going up,
|
|
// changing, or going down.
|
|
cycleSocket()
|
|
}
|
|
|
|
fileprivate override func ensureWebsocketExists() {
|
|
assertOnQueue(serialQueue)
|
|
|
|
switch connection {
|
|
case .open(_):
|
|
return
|
|
case .connecting(_, _):
|
|
// The most recent transition was attempting to connect, and we have not yet observed a failure.
|
|
// That's as good as we're going to get.
|
|
return
|
|
case .closed(_):
|
|
break
|
|
}
|
|
|
|
// Unique while live.
|
|
let token = NSObject()
|
|
connection = .connecting(token: token, task: Task { [token] () -> Connection? in
|
|
func connectionAttemptCompleted(_ state: ConnectionState) async -> Connection? {
|
|
// We're not done until self.connection has been updated.
|
|
// (Otherwise, we might try to send requests before calling start(listener:).)
|
|
return await withCheckedContinuation { continuation in
|
|
self.serialQueue.async {
|
|
guard self.connection.isCurrentlyConnecting(token) else {
|
|
// We finished connecting, but we've since been asked to disconnect
|
|
// (either because we should be offline, or because config has changed).
|
|
continuation.resume(returning: nil)
|
|
return
|
|
}
|
|
|
|
self.connection = state
|
|
|
|
if case .open(let connection) = state {
|
|
continuation.resume(returning: connection)
|
|
} else {
|
|
continuation.resume(returning: nil)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
do {
|
|
let chatService = try await self.connectChatService()
|
|
if type == .identified {
|
|
self.didConnectIdentified()
|
|
}
|
|
OutageDetection.shared.reportConnectionSuccess()
|
|
return await connectionAttemptCompleted(.open(chatService))
|
|
|
|
} catch is CancellationError {
|
|
// We've been asked to disconnect, no other action necessary.
|
|
// (We could even skip updating state, since the disconnect action should have already set it to "closed",
|
|
// but just in case it's still on "connecting" we'll continue on to execute that cleanup.)
|
|
return await connectionAttemptCompleted(.closed(task: nil))
|
|
} catch SignalError.appExpired(_) {
|
|
await appExpiry.setHasAppExpiredAtCurrentVersion(db: db)
|
|
} catch SignalError.deviceDeregistered(_) {
|
|
serialQueue.async {
|
|
if self.connection.isCurrentlyConnecting(token) {
|
|
self.db.write { tx in
|
|
self.registrationStateChangeManager.setIsDeregisteredOrDelinked(true, tx: tx)
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
Logger.error("\(self.logPrefix): failed to connect: \(error)")
|
|
OutageDetection.shared.reportConnectionFailure()
|
|
}
|
|
let result = await connectionAttemptCompleted(.closed(task: nil))
|
|
self.reconnectAfterFailure()
|
|
return result
|
|
})
|
|
}
|
|
|
|
fileprivate func didConnectIdentified() {
|
|
// Overridden by subclass.
|
|
}
|
|
|
|
fileprivate override func disconnectIfNeeded() {
|
|
assertOnQueue(serialQueue)
|
|
|
|
let previousConnection = connection
|
|
if case .closed = previousConnection {
|
|
// Either we are already disconnecting,
|
|
// or we finished disconnecting,
|
|
// or we were never connected to begin with.
|
|
return
|
|
}
|
|
// Spin off a background task to disconnect the previous connection.
|
|
connection = .closed(task: Task {
|
|
do {
|
|
try await previousConnection.waitToFinishConnecting(cancel: true)?.disconnect()
|
|
} catch {
|
|
Logger.warn("\(self.logPrefix): error while disconnecting: \(error)")
|
|
}
|
|
})
|
|
}
|
|
|
|
override func waitForDisconnectIfClosed() async {
|
|
let connection = await withCheckedContinuation { continuation in
|
|
serialQueue.async { continuation.resume(returning: self.connection) }
|
|
}
|
|
switch connection {
|
|
case .open(_), .connecting(_, _):
|
|
break
|
|
case .closed(let disconnectTask):
|
|
await disconnectTask?.value
|
|
}
|
|
}
|
|
|
|
public override var currentState: OWSChatConnectionState {
|
|
// We update cachedCurrentState based on lifecycle events,
|
|
// so this should be accurate (with the usual caveats about races).
|
|
return cachedCurrentState
|
|
}
|
|
|
|
fileprivate override var logPrefix: String {
|
|
"[\(type): libsignal]"
|
|
}
|
|
|
|
fileprivate override func makeRequestInternal(_ request: TSRequest, requestId: UInt64) async throws(OWSHTTPError) -> any HTTPResponse {
|
|
var httpHeaders = request.headers
|
|
httpHeaders.addDefaultHeaders()
|
|
request.applyAuth(to: &httpHeaders, willSendViaWebSocket: true)
|
|
|
|
let body: Data
|
|
switch request.body {
|
|
case .data(let bodyData):
|
|
body = bodyData
|
|
case .parameters(let bodyParameters):
|
|
// TODO: Do we need body & headers for requests with no parameters?
|
|
do {
|
|
body = try TSRequest.Body.encodedParameters(bodyParameters)
|
|
} catch {
|
|
owsFailDebug("[\(requestId)]: \(error).")
|
|
throw .invalidRequest
|
|
}
|
|
|
|
// If we're going to use the json serialized parameters as our body, we should overwrite
|
|
// the Content-Type on the request.
|
|
httpHeaders["Content-Type"] = "application/json"
|
|
}
|
|
|
|
let requestUrl = request.url
|
|
owsAssertDebug(requestUrl.scheme == nil)
|
|
owsAssertDebug(requestUrl.host == nil)
|
|
owsAssertDebug(!requestUrl.path.hasPrefix("/"))
|
|
|
|
guard let httpMethod = request.method.nilIfEmpty else {
|
|
throw .invalidRequest
|
|
}
|
|
|
|
let libsignalRequest = ChatConnection.Request(method: httpMethod, pathAndQuery: "/\(requestUrl.relativeString)", headers: httpHeaders.headers, body: body, timeout: request.timeoutInterval)
|
|
|
|
let connection = await withCheckedContinuation { continuation in
|
|
self.serialQueue.async { continuation.resume(returning: self.connection) }
|
|
}
|
|
let chatService = await connection.waitToFinishConnecting()
|
|
|
|
let connectionInfo: ConnectionInfo
|
|
let response: ChatConnection.Response
|
|
do {
|
|
guard let chatService else {
|
|
throw SignalError.chatServiceInactive("no connection to chat server")
|
|
}
|
|
|
|
connectionInfo = chatService.info()
|
|
response = try await chatService.send(libsignalRequest)
|
|
} catch {
|
|
switch error as? SignalError {
|
|
case .connectionTimeoutError(_), .requestTimeoutError(_):
|
|
// cycleSocket(), but only if the chatService we just used is the one that's still connected.
|
|
self.serialQueue.async { [weak chatService] in
|
|
if let chatService, self.connection.isActive(chatService) {
|
|
self.disconnectIfNeeded()
|
|
}
|
|
}
|
|
applyDesiredSocketState()
|
|
throw .networkFailure(.genericTimeout)
|
|
case .webSocketError(_), .connectionFailed(_):
|
|
throw .networkFailure(.genericFailure)
|
|
default:
|
|
owsFailDebug("[\(requestId)] failed with an unexpected error: \(error)")
|
|
throw .networkFailure(.genericFailure)
|
|
}
|
|
}
|
|
|
|
if DebugFlags.internalLogging {
|
|
Logger.info("received response for requestId: \(requestId), message: \(response.message), route: \(connectionInfo)")
|
|
}
|
|
|
|
#if TESTABLE_BUILD
|
|
if response.status/100 != 2 {
|
|
HTTPUtils.logCurl(for: request)
|
|
}
|
|
#endif
|
|
|
|
let headers = HttpHeaders(httpHeaders: response.headers, overwriteOnConflict: false)
|
|
return try await handleRequestResponse(
|
|
requestUrl: request.url,
|
|
responseStatus: Int(response.status),
|
|
responseHeaders: headers,
|
|
responseData: response.body
|
|
)
|
|
}
|
|
|
|
func connectionWasInterrupted(_ service: Connection, error: Error?) {
|
|
self.serialQueue.async { [self] in
|
|
guard connection.isActive(service) else {
|
|
// Already done with this service.
|
|
if let error {
|
|
Logger.warn("\(logPrefix) previous service was disconnected: \(error)")
|
|
}
|
|
return
|
|
}
|
|
|
|
if case SignalError.connectedElsewhere(_)? = error {
|
|
Logger.info("We were disconnected because another process connected.")
|
|
didConnectElsewhere()
|
|
} else if let error {
|
|
Logger.warn("\(logPrefix) disconnected: \(error)")
|
|
} else {
|
|
owsFailDebug("\(logPrefix) libsignal disconnected us without being asked")
|
|
}
|
|
|
|
connection = .closed(task: nil)
|
|
OutageDetection.shared.reportConnectionFailure()
|
|
self.reconnectAfterFailure()
|
|
}
|
|
}
|
|
|
|
private func reconnectAfterFailure() {
|
|
// Wait a few seconds before retrying to reduce server load.
|
|
self.serialQueue.asyncAfter(deadline: .now() + Self.socketReconnectDelay) { [weak self] in
|
|
self?._applyDesiredSocketState()
|
|
}
|
|
}
|
|
}
|
|
|
|
internal class OWSUnauthConnectionUsingLibSignal: OWSChatConnectionUsingLibSignal<UnauthenticatedChatConnection> {
|
|
init(libsignalNet: Net, accountManager: TSAccountManager, appExpiry: AppExpiry, appReadiness: AppReadiness, db: any DB, registrationStateChangeManager: RegistrationStateChangeManager) {
|
|
super.init(libsignalNet: libsignalNet, type: .unidentified, accountManager: accountManager, appExpiry: appExpiry, appReadiness: appReadiness, db: db, registrationStateChangeManager: registrationStateChangeManager)
|
|
}
|
|
|
|
fileprivate override var connection: ConnectionState {
|
|
didSet {
|
|
if case .open(let service) = connection {
|
|
service.start(listener: self)
|
|
}
|
|
}
|
|
}
|
|
|
|
override func connectChatService() async throws -> UnauthenticatedChatConnection {
|
|
return try await libsignalNet.connectUnauthenticatedChat()
|
|
}
|
|
}
|
|
|
|
internal class OWSAuthConnectionUsingLibSignal: OWSChatConnectionUsingLibSignal<AuthenticatedChatConnection>, ChatConnectionListener {
|
|
private var _hasEmptiedInitialQueue = false
|
|
override var hasEmptiedInitialQueue: Bool {
|
|
get async {
|
|
return await withCheckedContinuation { continuation in
|
|
serialQueue.async {
|
|
continuation.resume(returning: self._hasEmptiedInitialQueue)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var _keepaliveSenderTask: Task<Void, Never>?
|
|
private var keepaliveSenderTask: Task<Void, Never>? {
|
|
get {
|
|
assertOnQueue(serialQueue)
|
|
return _keepaliveSenderTask
|
|
}
|
|
set {
|
|
assertOnQueue(serialQueue)
|
|
_keepaliveSenderTask?.cancel()
|
|
_keepaliveSenderTask = newValue
|
|
}
|
|
}
|
|
|
|
init(libsignalNet: Net, accountManager: TSAccountManager, appExpiry: AppExpiry, appReadiness: AppReadiness, db: any DB, registrationStateChangeManager: RegistrationStateChangeManager) {
|
|
super.init(libsignalNet: libsignalNet, type: .identified, accountManager: accountManager, appExpiry: appExpiry, appReadiness: appReadiness, db: db, registrationStateChangeManager: registrationStateChangeManager)
|
|
}
|
|
|
|
fileprivate override func connectChatService() async throws -> AuthenticatedChatConnection {
|
|
let (username, password) = db.read { tx in
|
|
(accountManager.storedServerUsername(tx: tx), accountManager.storedServerAuthToken(tx: tx))
|
|
}
|
|
// Note that we still try to connect for an unregistered user, so that we get a consistent error thrown.
|
|
return try await libsignalNet.connectAuthenticatedChat(username: username ?? "", password: password ?? "", receiveStories: StoryManager.areStoriesEnabled)
|
|
}
|
|
|
|
fileprivate override var connection: ConnectionState {
|
|
didSet {
|
|
assertOnQueue(serialQueue)
|
|
|
|
switch connection {
|
|
case .connecting(token: _, task: _):
|
|
break
|
|
case .open(let service):
|
|
// Note that we don't get callbacks until this point.
|
|
service.start(listener: self)
|
|
if accountManager.registrationStateWithMaybeSneakyTransaction.isDeregistered {
|
|
db.write { tx in
|
|
registrationStateChangeManager.setIsDeregisteredOrDelinked(false, tx: tx)
|
|
}
|
|
}
|
|
keepaliveSenderTask = makeKeepaliveTask(service)
|
|
case .closed:
|
|
keepaliveSenderTask = nil
|
|
_hasEmptiedInitialQueue = false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Starts a task to call `/v1/keepalive` at regular intervals to allow the server to do some consistency checks.
|
|
///
|
|
/// This is on top of the websocket pings libsignal already uses to keep connections alive.
|
|
func makeKeepaliveTask(_ chat: AuthenticatedChatConnection) -> Task<Void, Never> {
|
|
let keepaliveInterval: TimeInterval = 30
|
|
return Task(priority: .low) { [logPrefix = self.logPrefix, weak chat] in
|
|
while true {
|
|
do {
|
|
// This does not quite send keepalives "every 30 seconds".
|
|
// Instead, it sends the next keepalive *at least 30 seconds* after the *response* for the previous one arrives.
|
|
try await Task.sleep(nanoseconds: keepaliveInterval.clampedNanoseconds)
|
|
guard let chat else {
|
|
// We've disconnected.
|
|
return
|
|
}
|
|
|
|
// Skip the full overhead of makeRequest(...).
|
|
// We don't need keepalives to count as background activity or anything like that.
|
|
var httpHeaders = HttpHeaders()
|
|
httpHeaders.addDefaultHeaders()
|
|
// This 30-second timeout doesn't inherently need to match the send interval above,
|
|
// but neither do we need an especially tight timeout here either.
|
|
let request = ChatConnection.Request(method: "GET", pathAndQuery: "/v1/keepalive", headers: httpHeaders.headers, body: nil, timeout: 30)
|
|
Logger.debug("\(logPrefix) Sending /v1/keepalive")
|
|
_ = try await chat.send(request)
|
|
|
|
} catch is CancellationError,
|
|
SignalError.chatServiceInactive(_) {
|
|
// No action necessary, we're done with this service.
|
|
return
|
|
} catch SignalError.rateLimitedError(retryAfter: let delay, message: _) {
|
|
// Not likely to happen, but best to be careful about it if it does.
|
|
if delay > keepaliveInterval {
|
|
// Wait out the part of the delay longer than 30s.
|
|
// Ignore cancellation here; when we get back to the top of the loop we'll check it then.
|
|
_ = try? await Task.sleep(nanoseconds: (delay - keepaliveInterval).clampedNanoseconds)
|
|
}
|
|
} catch {
|
|
// Also no action necessary! Log just in case the failure has something interesting going on,
|
|
// but continue to rely on libsignal reporting disconnects via delegate callback.
|
|
// Importantly, we will continue to send keepalives until disconnected, in case this was a temporary thing.
|
|
Logger.info("\(logPrefix) /v1/keepalive failed: \(error)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func chatConnection(_ chat: AuthenticatedChatConnection, didReceiveAlerts alerts: [String]) {
|
|
self.serialQueue.async { [self] in
|
|
guard self.connection.isActive(chat) else {
|
|
// We have since disconnected from the chat service instance that reported the alerts.
|
|
return
|
|
}
|
|
|
|
if !alerts.isEmpty {
|
|
Logger.warn("ignoring \(alerts.count) alerts from the server")
|
|
}
|
|
}
|
|
}
|
|
|
|
func chatConnection(_ chat: AuthenticatedChatConnection, didReceiveIncomingMessage envelope: Data, serverDeliveryTimestamp: UInt64, sendAck: @escaping () throws -> Void) {
|
|
let backgroundTask = OWSBackgroundTask(label: "handleIncomingMessage")
|
|
|
|
Self.messageProcessingQueue.async {
|
|
SSKEnvironment.shared.messageProcessorRef.processReceivedEnvelopeData(
|
|
envelope,
|
|
serverDeliveryTimestamp: serverDeliveryTimestamp,
|
|
envelopeSource: .websocketIdentified
|
|
) {
|
|
defer { backgroundTask.end() }
|
|
|
|
do {
|
|
// Note that this does not wait for a response.
|
|
try sendAck()
|
|
} catch {
|
|
Logger.warn("Failed to ack message with serverTimestamp \(serverDeliveryTimestamp): \(error)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func chatConnectionDidReceiveQueueEmpty(_ chat: AuthenticatedChatConnection) {
|
|
// We need to "flush" (i.e., "jump through") the message processing queue
|
|
// to ensure that all received messages (see prior method) are enqueued for
|
|
// processing before we: a) mark the queue as empty, b) notify.
|
|
//
|
|
// The socket might close and re-open while we're flushing the queue, so
|
|
// we make sure it's still active before marking the queue as empty.
|
|
Self.messageProcessingQueue.async {
|
|
self.serialQueue.async {
|
|
guard self.connection.isActive(chat) else {
|
|
// We have since disconnected from the chat service instance that reported the empty queue.
|
|
return
|
|
}
|
|
let alreadyEmptied = self._hasEmptiedInitialQueue
|
|
self._hasEmptiedInitialQueue = true
|
|
|
|
if !alreadyEmptied {
|
|
// This notification is used to wake up anything waiting for hasEmptiedInitialQueue.
|
|
self.notifyStatusChange(newState: self.currentState)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|