// // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import LibSignalClient import SignalCoreKit 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: NSObject { public typealias RequestSuccess = (HTTPResponse) -> Void public typealias RequestFailure = (OWSHTTPError) -> Void fileprivate typealias RequestSuccessInternal = (HTTPResponse, RequestInfo) -> Void // Track where Dependencies are used throughout this class. fileprivate struct GlobalDependencies: Dependencies {} public static let chatConnectionStateDidChange = Notification.Name("chatConnectionStateDidChange") fileprivate let serialQueue: DispatchQueue // MARK: - fileprivate let type: OWSChatConnectionType fileprivate let appExpiry: AppExpiry fileprivate let db: DB fileprivate static func label(forRequest request: TSRequest, connectionType: OWSChatConnectionType, requestInfo: RequestInfo?) -> String { var label = "\(connectionType), \(request)" if let requestInfo = requestInfo { label += ", [\(requestInfo.requestId)]" } return label } // This var must be thread-safe. public var currentState: OWSChatConnectionState { owsFailDebug("should be using a concrete subclass") return .closed } // This var is thread-safe. public final var canMakeRequests: Bool { currentState == .open } // This var must be thread-safe. public var hasEmptiedInitialQueue: Bool { false } // We cache this value instead of consulting [UIApplication sharedApplication].applicationState, // because UIKit only provides a "will resign active" notification, not a "did resign active" // notification. private let appIsActive = AtomicBool(false, lock: .sharedGlobal) private static let unsubmittedRequestTokenCounter = AtomicUInt(lock: .sharedGlobal) public typealias UnsubmittedRequestToken = UInt // This method is thread-safe. public func makeUnsubmittedRequestToken() -> UnsubmittedRequestToken { let token = Self.unsubmittedRequestTokenCounter.increment() unsubmittedRequestTokens.insert(token) applyDesiredSocketState() return token } private let unsubmittedRequestTokens = AtomicSet(lock: .sharedGlobal) // This method is thread-safe. fileprivate func removeUnsubmittedRequestToken(_ token: UnsubmittedRequestToken) { let hadToken = unsubmittedRequestTokens.remove(token) owsAssertDebug(hadToken) applyDesiredSocketState() } // MARK: - BackgroundKeepAlive fileprivate enum BackgroundKeepAliveRequestType { case didReceivePush case receiveMessage case receiveResponse var keepAliveDuration: TimeInterval { // If the app is in the background, it should keep the // websocket open if: switch self { case .didReceivePush: // Received a push notification in the last N seconds. return 20 case .receiveMessage: // It has received a message over the socket in the last N seconds. return 15 case .receiveResponse: // It has just received the response to a request. return 5 } // There are many other cases as well not associated with a fixed duration, // such as if currentWebSocket.hasPendingRequests; see shouldSocketBeOpen(). } } private struct BackgroundKeepAlive { let requestType: BackgroundKeepAliveRequestType let untilDate: Date } // This var should only be accessed with unfairLock acquired. private var _backgroundKeepAlive: BackgroundKeepAlive? private let unfairLock = UnfairLock() // This method is thread-safe. fileprivate func ensureBackgroundKeepAlive(_ requestType: BackgroundKeepAliveRequestType) { let keepAliveDuration = requestType.keepAliveDuration owsAssertDebug(keepAliveDuration > 0) let untilDate = Date().addingTimeInterval(keepAliveDuration) let didChange: Bool = unfairLock.withLock { if let oldValue = self._backgroundKeepAlive, oldValue.untilDate >= untilDate { return false } self._backgroundKeepAlive = BackgroundKeepAlive(requestType: requestType, untilDate: untilDate) return true } if didChange { var backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: "applicationWillResignActive") applyDesiredSocketState { assertOnQueue(self.serialQueue) owsAssertDebug(backgroundTask != nil) backgroundTask = nil } } } // This var is thread-safe. private var hasBackgroundKeepAlive: Bool { unfairLock.withLock { guard let backgroundKeepAlive = self._backgroundKeepAlive else { return false } guard backgroundKeepAlive.untilDate >= Date() else { // Cull expired values. self._backgroundKeepAlive = nil return false } return true } } fileprivate var logPrefix: String { "[\(type)]" } // MARK: - public init(type: OWSChatConnectionType, appExpiry: AppExpiry, db: DB) { AssertIsOnMainThread() self.serialQueue = DispatchQueue(label: "org.signal.chat-connection-\(type)") self.type = type self.appExpiry = appExpiry self.db = db super.init() AppReadiness.runNowOrWhenAppDidBecomeReadySync { [weak self] in guard let self = self else { return } self.observeNotifications() self.applyDesiredSocketState() } } // MARK: - Notifications // We want to observe these notifications lazily to avoid accessing // the data store in [application: didFinishLaunchingWithOptions:]. private func observeNotifications() { AssertIsOnMainThread() appIsActive.set(CurrentAppContext().isMainAppAndActive) NotificationCenter.default.addObserver(self, selector: #selector(applicationDidBecomeActive), name: .OWSApplicationDidBecomeActive, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(applicationWillResignActive), name: .OWSApplicationWillResignActive, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(registrationStateDidChange), name: .registrationStateDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(localNumberDidChange), name: .localNumberDidChange, object: nil) 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(appExpiryDidChange), name: AppExpiryImpl.AppExpiryDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(storiesEnabledStateDidChange), name: .storiesEnabledStateDidChange, object: nil) } // MARK: - private struct StateObservation { var currentState: OWSChatConnectionState var onOpen: [NSObject: CheckedContinuation] } /// 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: CheckedContinuation]) (oldState, continuationsToResolve) = stateObservation.update { let oldState = $0.currentState if newState == oldState { return (oldState, [:]) } $0.currentState = newState var continuationsToResolve: [NSObject: CheckedContinuation] = [:] 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() } NotificationCenter.default.postNotificationNameAsync(Self.chatConnectionStateDidChange, object: nil) } fileprivate var cachedCurrentState: OWSChatConnectionState { stateObservation.get().currentState } /// Only throws on cancellation. func waitForOpen() async throws { // There are three events that are relevant here: // A) The socket becomes open (or is already open) // B) The continuation is registered in the onOpen list // C) This task is cancelled. // // Let's exhaustively make sure all three are handled no matter the ordering: // - ABC: The continuation is resumed immediately at (1). // - ACB: The cancellation is ignored, and the continuation is resumed at (1). (This is fine.) // - BAC: The continuation is resumed within notifyStatusChange. // - BCA: The continuation is removed from the list and cancelled at (3). // - CAB: The cancellation is ignored, and the continuation is resumed at (1). (This is fine.) // - CBA: The cancellation is checked and propagated at (2). let cancellationToken = NSObject() try await withTaskCancellationHandler(operation: { try await withCheckedThrowingContinuation { continuation in // We are locking during an async task! *gasp* // This is only okay because *every* use of this lock does a short and finite amount of work. stateObservation.update { if $0.currentState == .open { continuation.resume() // (1) return } if Task.isCancelled { continuation.resume(throwing: CancellationError()) // (2) return } $0.onOpen[cancellationToken] = continuation } } }, onCancel: { stateObservation.update { let continuation = $0.onOpen.removeValue(forKey: cancellationToken) continuation?.resume(throwing: CancellationError()) // (3) } }) } // MARK: - Socket LifeCycle public static var canAppUseSocketsToMakeRequests: Bool { if FeatureFlags.deprecateREST { // When we deprecate REST, we will use web sockets in app extensions. return true } if !CurrentAppContext().isMainApp { return false } return true } // This var is thread-safe. public var shouldSocketBeOpen: Bool { desiredSocketState?.shouldSocketBeOpen ?? false } fileprivate enum DesiredSocketState: Equatable { case closed(reason: String) case open(reason: String) public var shouldSocketBeOpen: Bool { switch self { case .closed: return false case .open: return true } } } // This method is thread-safe. fileprivate var desiredSocketState: DesiredSocketState? { guard AppReadiness.isAppReady else { return .closed(reason: "!isAppReady") } guard DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else { return .closed(reason: "!isRegisteredAndReady") } guard !appExpiry.isExpired else { return .closed(reason: "appExpiry.isExpired") } guard Self.canAppUseSocketsToMakeRequests else { return .closed(reason: "!canAppUseSocketsToMakeRequests") } if !unsubmittedRequestTokens.isEmpty { return .open(reason: "unsubmittedRequestTokens") } if appIsActive.get() { // While app is active, keep web socket alive. return .open(reason: "appIsActive") } if hasBackgroundKeepAlive { // If app is doing any work in the background, keep web socket alive. return .open(reason: "hasBackgroundKeepAlive") } return nil } // This method is thread-safe. public func didReceivePush() { owsAssertDebug(AppReadiness.isAppReady) self.ensureBackgroundKeepAlive(.didReceivePush) } // This method aligns the socket state with the "desired" socket state. // // This method is thread-safe. fileprivate func applyDesiredSocketState(completion: (() -> Void)? = nil) { guard AppReadiness.isAppReady else { AppReadiness.runNowOrWhenAppDidBecomeReadySync { [weak self] in self?.applyDesiredSocketState(completion: completion) } return } serialQueue.async { [weak self] in guard let self = self else { completion?() return } var shouldHaveBackgroundKeepAlive = false if self.shouldSocketBeOpen { self.ensureWebsocketExists() // If we're keeping the webSocket open in the background, // ensure that the "BackgroundKeepAlive" state is active. shouldHaveBackgroundKeepAlive = !self.appIsActive.get() } else { self.disconnectIfNeeded() } if shouldHaveBackgroundKeepAlive { if nil == self.backgroundKeepAliveTimer { // Start a new timer that will fire every second while the socket is open in the background. // This timer will ensure we close the websocket when the time comes. self.backgroundKeepAliveTimer = OffMainThreadTimer(timeInterval: 1, repeats: true) { [weak self] timer in guard let self = self else { timer.invalidate() return } self.applyDesiredSocketState() } } if nil == self.backgroundKeepAliveBackgroundTask { self.backgroundKeepAliveBackgroundTask = OWSBackgroundTask(label: "BackgroundKeepAlive") { [weak self] (_) in AssertIsOnMainThread() self?.applyDesiredSocketState() } } } else { self.backgroundKeepAliveTimer?.invalidate() self.backgroundKeepAliveTimer = nil self.backgroundKeepAliveBackgroundTask = nil } completion?() } } // This timer is used to check periodically whether we should // close the socket. private var backgroundKeepAliveTimer: OffMainThreadTimer? // This is used to manage the iOS "background task" used to // keep the app alive in the background. private var backgroundKeepAliveBackgroundTask: OWSBackgroundTask? // This method must be thread-safe. fileprivate func cycleSocket() { serialQueue.async { self.disconnectIfNeeded() } 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 private func applicationDidBecomeActive(_ notification: NSNotification) { AssertIsOnMainThread() appIsActive.set(true) applyDesiredSocketState() } @objc private func applicationWillResignActive(_ notification: NSNotification) { AssertIsOnMainThread() appIsActive.set(false) applyDesiredSocketState() } @objc private func registrationStateDidChange(_ notification: NSNotification) { AssertIsOnMainThread() applyDesiredSocketState() } @objc private func localNumberDidChange(_ notification: NSNotification) { AssertIsOnMainThread() cycleSocket() } @objc private 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() cycleSocket() } @objc private func storiesEnabledStateDidChange(_ notification: NSNotification) { AssertIsOnMainThread() cycleSocket() } // MARK: - Message Sending public func makeRequest(_ request: TSRequest, unsubmittedRequestToken: UnsubmittedRequestToken) async throws -> HTTPResponse { guard !appExpiry.isExpired else { removeUnsubmittedRequestToken(unsubmittedRequestToken) guard let requestUrl = request.url else { owsFail("Missing requestUrl.") } throw OWSHTTPError.invalidAppState(requestUrl: requestUrl) } let connectionType = self.type let isIdentifiedConnection = connectionType == .identified let isIdentifiedRequest = request.shouldHaveAuthorizationHeaders && !request.isUDRequest owsAssertDebug(isIdentifiedConnection == isIdentifiedRequest) let (response, requestInfo) = try await withCheckedThrowingContinuation { continuation in self.serialQueue.async { self.makeRequestInternal( request, unsubmittedRequestToken: unsubmittedRequestToken, success: { continuation.resume(returning: ($0, $1)) }, failure: { continuation.resume(throwing: $0) } ) } } let label = Self.label(forRequest: request, connectionType: connectionType, requestInfo: requestInfo) Logger.info("\(label): Request Succeeded (\(response.responseStatusCode))") Self.outageDetection.reportConnectionSuccess() return response } fileprivate func makeRequestInternal(_ request: TSRequest, unsubmittedRequestToken: UnsubmittedRequestToken, success: @escaping RequestSuccessInternal, failure: @escaping RequestFailure) { assertOnQueue(self.serialQueue) owsFailDebug("should be using a concrete subclass") guard let requestUrl = request.url else { owsFail("Missing requestUrl.") } failure(.invalidAppState(requestUrl: requestUrl)) } } public class OWSChatConnectionUsingSSKWebSocket: OWSChatConnection { // TODO: Should we use a higher-priority queue? fileprivate static let messageProcessingQueue = DispatchQueue(label: "org.signal.chat-connection.message-processing") private static let socketReconnectDelaySeconds: TimeInterval = 5 private var _currentWebSocket = AtomicOptional(nil, lock: .sharedGlobal) fileprivate var currentWebSocket: WebSocketConnection? { get { _currentWebSocket.get() } set { let oldValue = _currentWebSocket.swap(newValue) if oldValue != nil || newValue != nil { owsAssertDebug(oldValue?.id != newValue?.id) } oldValue?.reset() notifyStatusChange(newState: currentState) } } // MARK: - public override var currentState: OWSChatConnectionState { guard let currentWebSocket = self.currentWebSocket else { return .closed } switch currentWebSocket.state { case .open: return .open case .connecting: return .connecting case .disconnected: return .closed } } // This var is thread-safe. public override var hasEmptiedInitialQueue: Bool { guard let currentWebSocket = self.currentWebSocket else { return false } return currentWebSocket.hasEmptiedInitialQueue.get() } fileprivate override var logPrefix: String { if let currentWebSocket = currentWebSocket { return currentWebSocket.logPrefix } else { return super.logPrefix } } // This method is thread-safe. fileprivate override var desiredSocketState: DesiredSocketState? { if let desire = super.desiredSocketState { return desire } if let currentWebSocket, currentWebSocket.hasPendingRequests { return .open(reason: "hasPendingRequests") } return nil } // MARK: - Message Sending fileprivate override func makeRequestInternal(_ request: TSRequest, unsubmittedRequestToken: UnsubmittedRequestToken, success: @escaping RequestSuccessInternal, failure: @escaping RequestFailure) { assertOnQueue(self.serialQueue) defer { removeUnsubmittedRequestToken(unsubmittedRequestToken) } guard let requestInfo = RequestInfo(request: request, connectionType: type, success: success, failure: failure) else { // Failure already reported return } let label = Self.label(forRequest: request, connectionType: type, requestInfo: requestInfo) let requestUrl = requestInfo.requestUrl guard let currentWebSocket = currentWebSocket, currentWebSocket.state == .open else { owsFailDebug("\(label) Missing currentWebSocket.") failure(.networkFailure(requestUrl: requestUrl)) return } owsAssertDebug(requestUrl.scheme == nil) owsAssertDebug(requestUrl.host == nil) owsAssertDebug(!requestUrl.path.hasPrefix("/")) let requestBuilder = WebSocketProtoWebSocketRequestMessage.builder(verb: requestInfo.httpMethod, path: "/\(requestUrl.relativeString)", requestID: requestInfo.requestId) let httpHeaders = OWSHttpHeaders(httpHeaders: request.allHTTPHeaderFields, overwriteOnConflict: false) httpHeaders.addDefaultHeaders() if let existingBody = request.httpBody { requestBuilder.setBody(existingBody) } else { // TODO: Do we need body & headers for requests with no parameters? let jsonData: Data do { jsonData = try JSONSerialization.data(withJSONObject: request.parameters, options: []) } catch { owsFailDebug("\(label) Error: \(error).") requestInfo.didFailInvalidRequest() return } requestBuilder.setBody(jsonData) // If we're going to use the json serialized parameters as our body, we should overwrite // the Content-Type on the request. httpHeaders.addHeader("Content-Type", value: "application/json", overwriteOnConflict: true) } for (key, value) in httpHeaders.headers { requestBuilder.addHeaders("\(key):\(value)") } do { let requestProto = try requestBuilder.build() let messageBuilder = WebSocketProtoWebSocketMessage.builder() messageBuilder.setType(.request) messageBuilder.setRequest(requestProto) let messageData = try messageBuilder.buildSerializedData() guard currentWebSocket.state == .open else { owsFailDebug("\(label) Socket not open.") requestInfo.didFailInvalidRequest() return } Logger.info("\(label) Making request") currentWebSocket.sendRequest(requestInfo: requestInfo, messageData: messageData, delegate: self) } catch { owsFailDebug("\(label), Error: \(error).") requestInfo.didFailInvalidRequest() return } } private func processWebSocketResponseMessage(_ message: WebSocketProtoWebSocketResponseMessage, currentWebSocket: WebSocketConnection) { assertOnQueue(serialQueue) let requestId = message.requestID let responseStatus = message.status let responseData: Data? = message.hasBody ? message.body : nil if DebugFlags.internalLogging, message.hasMessage, let responseMessage = message.message { Logger.info("received WebSocket response \(currentWebSocket.logPrefix), requestId: \(message.requestID), status: \(message.status), message: \(responseMessage)") } else { Logger.info("received WebSocket response \(currentWebSocket.logPrefix), requestId: \(message.requestID), status: \(message.status)") } ensureBackgroundKeepAlive(.receiveResponse) let headers = OWSHttpHeaders() headers.addHeaderList(message.headers, overwriteOnConflict: true) guard let requestInfo = currentWebSocket.popRequestInfo(forRequestId: requestId) else { Logger.warn("Received response to unknown request \(currentWebSocket.logPrefix)") return } requestInfo.complete(status: Int(responseStatus), headers: headers, data: responseData) // We may have been holding the websocket open, waiting for this response. // Check if we should close the websocket. applyDesiredSocketState() } // MARK: - fileprivate func processWebSocketRequestMessage(_ message: WebSocketProtoWebSocketRequestMessage, currentWebSocket: WebSocketConnection) { assertOnQueue(self.serialQueue) let httpMethod = message.verb.nilIfEmpty ?? "" let httpPath = message.path.nilIfEmpty ?? "" owsAssertDebug(!httpMethod.isEmpty) owsAssertDebug(!httpPath.isEmpty) Logger.info("Got message \(currentWebSocket.logPrefix): verb: \(httpMethod), path: \(httpPath)") if httpMethod == "PUT", httpPath == "/api/v1/message" { // If we receive a message over the socket while the app is in the background, // prolong how long the socket stays open. // // TODO: NSE ensureBackgroundKeepAlive(.receiveMessage) handleIncomingMessage(message, currentWebSocket: currentWebSocket) } else if httpPath == "/api/v1/queue/empty" { // Queue is drained. handleEmptyQueueMessage(message, currentWebSocket: currentWebSocket) } else { Logger.warn("Unsupported WebSocket Request \(currentWebSocket.logPrefix)") sendWebSocketMessageAcknowledgement(message, currentWebSocket: currentWebSocket) } } private func handleIncomingMessage(_ message: WebSocketProtoWebSocketRequestMessage, currentWebSocket: WebSocketConnection) { assertOnQueue(self.serialQueue) var backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: "handleIncomingMessage") let ackMessage = { (processingError: Error?, serverTimestamp: UInt64) in let ackBehavior = MessageProcessor.handleMessageProcessingOutcome(error: processingError) switch ackBehavior { case .shouldAck: self.sendWebSocketMessageAcknowledgement(message, currentWebSocket: currentWebSocket) case .shouldNotAck(let error): Logger.info("Skipping ack of message with serverTimestamp \(serverTimestamp) because of error: \(error)") } owsAssertDebug(backgroundTask != nil) backgroundTask = nil } let headers = OWSHttpHeaders() headers.addHeaderList(message.headers, overwriteOnConflict: true) var serverDeliveryTimestamp: UInt64 = 0 if let timestampString = headers.value(forHeader: "x-signal-timestamp") { if let timestamp = UInt64(timestampString) { serverDeliveryTimestamp = timestamp } else { owsFailDebug("Invalidly formatted timestamp: \(timestampString)") } } if serverDeliveryTimestamp == 0 { owsFailDebug("Missing server delivery timestamp") } guard let encryptedEnvelope = message.body else { ackMessage(OWSGenericError("Missing encrypted envelope on message \(currentWebSocket.logPrefix)"), serverDeliveryTimestamp) return } let envelopeSource: EnvelopeSource = { switch self.type { case .identified: return .websocketIdentified case .unidentified: return .websocketUnidentified } }() Self.messageProcessingQueue.async { Self.messageProcessor.processReceivedEnvelopeData( encryptedEnvelope, serverDeliveryTimestamp: serverDeliveryTimestamp, envelopeSource: envelopeSource ) { error in self.serialQueue.async { ackMessage(error, serverDeliveryTimestamp) } } } } private func handleEmptyQueueMessage(_ message: WebSocketProtoWebSocketRequestMessage, currentWebSocket: WebSocketConnection) { assertOnQueue(self.serialQueue) // Queue is drained. sendWebSocketMessageAcknowledgement(message, currentWebSocket: currentWebSocket) guard !currentWebSocket.hasEmptiedInitialQueue.get() else { owsFailDebug("Unexpected emptyQueueMessage \(currentWebSocket.logPrefix)") return } // We need to flush the message processing and serial queues // to ensure that all received messages are enqueued and // processed before we: a) mark the queue as empty. b) notify. // // The socket might close and re-open while we're // flushing the queues. Therefore we capture currentWebSocket // flushing to ensure that we handle this case correctly. Self.messageProcessingQueue.async { [weak self] in self?.serialQueue.async { guard let self = self else { return } if currentWebSocket.hasEmptiedInitialQueue.tryToSetFlag() { self.notifyStatusChange(newState: self.currentState) } // We may have been holding the websocket open, waiting to drain the // queue. Check if we should close the websocket. self.applyDesiredSocketState() } } } private func sendWebSocketMessageAcknowledgement(_ request: WebSocketProtoWebSocketRequestMessage, currentWebSocket: WebSocketConnection) { assertOnQueue(self.serialQueue) do { try currentWebSocket.sendResponse(for: request, status: 200, message: "OK") } catch { owsFailDebugUnlessNetworkFailure(error) } } // This method is thread-safe. private var webSocketAuthenticationQueryItems: [URLQueryItem]? { switch type { case .unidentified: // UD socket is unauthenticated. return nil case .identified: let login = DependenciesBridge.shared.tsAccountManager.storedServerUsernameWithMaybeTransaction ?? "" let password = DependenciesBridge.shared.tsAccountManager.storedServerAuthTokenWithMaybeTransaction ?? "" owsAssertDebug(login.nilIfEmpty != nil) owsAssertDebug(password.nilIfEmpty != nil) return [ URLQueryItem(name: "login", value: login), URLQueryItem(name: "password", value: password) ] } } fileprivate override func ensureWebsocketExists() { assertOnQueue(serialQueue) // Try to reuse the existing socket (if any) if it is in a valid state. if let currentWebSocket = self.currentWebSocket { switch currentWebSocket.state { case .open: self.clearReconnect() return case .connecting: // If we want the socket to be open and it's not open, // start up the reconnect timer immediately (don't wait for an error). // There's little harm in it and this will make us more robust to edge // cases. self.ensureReconnectTimer() return case .disconnected: break } } let signalServiceType: SignalServiceType switch type { case .identified: signalServiceType = .mainSignalServiceIdentified case .unidentified: signalServiceType = .mainSignalServiceUnidentified } let request = WebSocketRequest( signalService: signalServiceType, urlPath: "v1/websocket/", urlQueryItems: webSocketAuthenticationQueryItems, extraHeaders: StoryManager.buildStoryHeaders() ) guard let webSocket = GlobalDependencies.webSocketFactory.buildSocket( request: request, callbackScheduler: self.serialQueue ) else { owsFailDebug("Missing webSocket.") return } webSocket.delegate = self let newWebSocket = WebSocketConnection(connectionType: type, webSocket: webSocket) self.currentWebSocket = newWebSocket // `connect` could hypothetically call a delegate method (e.g. if // the socket failed immediately for some reason), so we update currentWebSocket // _before_ calling it, not after. webSocket.connect() self.serialQueue.asyncAfter(deadline: .now() + 30) { [weak self, weak newWebSocket] in guard let self, let newWebSocket, self.currentWebSocket?.id == newWebSocket.id else { return } if !newWebSocket.hasConnected.get() { Logger.warn("Websocket failed to connect.") self.cycleSocket() } } // If we want the socket to be open and it's not open, // start up the reconnect timer immediately (don't wait for an error). // There's little harm in it and this will make us more robust to edge // cases. self.ensureReconnectTimer() } fileprivate override func disconnectIfNeeded() { self.clearReconnect() self.currentWebSocket = nil } // MARK: - Reconnect private var reconnectTimer: OffMainThreadTimer? fileprivate func ensureReconnectTimer() { assertOnQueue(serialQueue) if let reconnectTimer = self.reconnectTimer { owsAssertDebug(reconnectTimer.isValid) } else { // TODO: It'd be nice to do exponential backoff. self.reconnectTimer = OffMainThreadTimer(timeInterval: Self.socketReconnectDelaySeconds, repeats: true) { [weak self] timer in guard let self = self else { timer.invalidate() return } self.applyDesiredSocketState() } } } private func clearReconnect() { assertOnQueue(serialQueue) reconnectTimer?.invalidate() reconnectTimer = nil } } // MARK: - private class RequestInfo { let request: TSRequest let requestUrl: URL let httpMethod: String let requestId: UInt64 = Cryptography.randomUInt64() let connectionType: OWSChatConnectionType let startDate = Date() var intervalSinceStartDateFormatted: String { startDate.formatIntervalSinceNow } // We use an enum to ensure that the completion handlers are // released as soon as the message completes. private enum Status { case incomplete(success: RequestSuccess, failure: RequestFailure) case complete } private let status: AtomicValue private let backgroundTask: OWSBackgroundTask typealias RequestSuccess = OWSChatConnection.RequestSuccessInternal typealias RequestFailure = OWSChatConnection.RequestFailure init?(request: TSRequest, connectionType: OWSChatConnectionType, success: @escaping RequestSuccess, failure: @escaping RequestFailure) { let fallbackLabel: () -> String = { OWSChatConnection.label(forRequest: request, connectionType: connectionType, requestInfo: nil) } guard let requestUrl = request.url else { owsFailDebug("\(fallbackLabel()) Missing requestUrl.") failure(.invalidRequest(requestUrl: request.url!)) return nil } guard let httpMethod = request.httpMethod.nilIfEmpty else { owsFailDebug("\(fallbackLabel()) Missing httpMethod.") failure(.invalidRequest(requestUrl: request.url!)) return nil } self.request = request self.requestUrl = requestUrl self.httpMethod = httpMethod self.connectionType = connectionType self.status = AtomicValue(.incomplete(success: success, failure: failure), lock: .sharedGlobal) self.backgroundTask = OWSBackgroundTask(label: "ChatRequestInfo") } func complete(status: Int, headers: OWSHttpHeaders, data: Data?) { if (200...299).contains(status) { let response = HTTPResponseImpl(requestUrl: requestUrl, status: status, headers: headers, bodyData: data) didSucceed(response: response) } else { let error = HTTPUtils.preprocessMainServiceHTTPError( request: request, requestUrl: requestUrl, responseStatus: status, responseHeaders: headers, responseData: data ) didFail(error: error) } } private func didSucceed(response: HTTPResponse) { // Ensure that we only complete once. switch status.swap(.complete) { case .complete: return case .incomplete(let success, _): success(response, self) } } // Returns true if the message timed out. func timeoutIfNecessary() -> Bool { return didFail(error: OWSHTTPError.networkFailure(requestUrl: requestUrl)) } func didFailInvalidRequest() { didFail(error: OWSHTTPError.invalidRequest(requestUrl: requestUrl)) } func didFailDueToNetwork() { didFail(error: OWSHTTPError.networkFailure(requestUrl: requestUrl)) } @discardableResult private func didFail(error: Error) -> Bool { // Ensure that we only complete once. switch status.swap(.complete) { case .complete: return false case .incomplete(_, let failure): Logger.warn("\(error)") failure(error as! OWSHTTPError) return true } } } // MARK: - extension OWSChatConnectionUsingSSKWebSocket: SSKWebSocketDelegate { public func websocketDidConnect(socket eventSocket: SSKWebSocket) { assertOnQueue(self.serialQueue) guard let currentWebSocket = self.currentWebSocket, currentWebSocket.id == eventSocket.id else { // Ignore events from obsolete web sockets. return } currentWebSocket.didConnect(delegate: self) // If socket opens, we know we're not de-registered. if type == .identified { let tsAccountManager = DependenciesBridge.shared.tsAccountManager if tsAccountManager.registrationStateWithMaybeSneakyTransaction.isDeregistered { DependenciesBridge.shared.db.write { tx in DependenciesBridge.shared.registrationStateChangeManager.setIsDeregisteredOrDelinked(false, tx: tx) } } } outageDetection.reportConnectionSuccess() notifyStatusChange(newState: .open) } public func websocketDidDisconnectOrFail(socket eventSocket: SSKWebSocket, error: Error) { assertOnQueue(self.serialQueue) guard let currentWebSocket = self.currentWebSocket, currentWebSocket.id == eventSocket.id else { // Ignore events from obsolete web sockets. return } Logger.warn("Websocket did fail \(logPrefix): \(error)") self.currentWebSocket = nil if type == .identified, case WebSocketError.httpError(statusCode: 403, _) = error { DependenciesBridge.shared.db.write { tx in DependenciesBridge.shared.registrationStateChangeManager.setIsDeregisteredOrDelinked(true, tx: tx) } } if shouldSocketBeOpen { // If we should retry, use `ensureReconnectTimer` to reconnect after a delay. ensureReconnectTimer() } else { // Otherwise clean up and align state. applyDesiredSocketState() } outageDetection.reportConnectionFailure() } public func websocket(_ eventSocket: SSKWebSocket, didReceiveData data: Data) { assertOnQueue(self.serialQueue) let message: WebSocketProtoWebSocketMessage do { message = try WebSocketProtoWebSocketMessage(serializedData: data) } catch { owsFailDebug("Failed to deserialize message: \(error)") return } guard let currentWebSocket = self.currentWebSocket, currentWebSocket.id == eventSocket.id else { // Ignore events from obsolete web sockets. return } if !message.hasType { owsFailDebug("webSocket:didReceiveResponse: missing type.") } else if message.unwrappedType == .request { if let request = message.request { processWebSocketRequestMessage(request, currentWebSocket: currentWebSocket) } else { owsFailDebug("Missing request.") } } else if message.unwrappedType == .response { if let response = message.response { processWebSocketResponseMessage(response, currentWebSocket: currentWebSocket) } else { owsFailDebug("Missing response.") } } else { owsFailDebug("webSocket:didReceiveResponse: unknown.") } } } // MARK: - extension OWSChatConnectionUsingSSKWebSocket: WebSocketConnectionDelegate { fileprivate func webSocketSendHeartBeat(_ webSocket: WebSocketConnection) { if shouldSocketBeOpen { webSocket.writePing() } else { Logger.warn("Closing web socket: \(logPrefix).") applyDesiredSocketState() } } fileprivate func webSocketRequestDidTimeout() { cycleSocket() } } // MARK: - private protocol WebSocketConnectionDelegate: AnyObject { func webSocketSendHeartBeat(_ webSocket: WebSocketConnection) func webSocketRequestDidTimeout() } // MARK: - private class WebSocketConnection { private let connectionType: OWSChatConnectionType private let webSocket: SSKWebSocket private let unfairLock = UnfairLock() public var id: UInt { webSocket.id } public let hasEmptiedInitialQueue = AtomicBool(false, lock: .sharedGlobal) public var state: SSKWebSocketState { webSocket.state } private var requestInfoMap = AtomicDictionary(lock: .sharedGlobal) public var hasPendingRequests: Bool { !requestInfoMap.isEmpty } public let hasConnected = AtomicBool(false, lock: .sharedGlobal) public var logPrefix: String { "[\(connectionType): \(id)]" } init(connectionType: OWSChatConnectionType, webSocket: SSKWebSocket) { owsAssertDebug(!CurrentAppContext().isRunningTests) self.connectionType = connectionType self.webSocket = webSocket } deinit { reset() } private var heartbeatTimer: OffMainThreadTimer? func didConnect(delegate: WebSocketConnectionDelegate) { hasConnected.set(true) startHeartbeat(delegate: delegate) } private func startHeartbeat(delegate: WebSocketConnectionDelegate) { let heartbeatPeriodSeconds: TimeInterval = 30 self.heartbeatTimer = OffMainThreadTimer(timeInterval: heartbeatPeriodSeconds, repeats: true) { [weak self, weak delegate] timer in guard let self = self, let delegate = delegate else { owsFailDebug("Missing self or delegate.") timer.invalidate() return } delegate.webSocketSendHeartBeat(self) } } func writePing() { webSocket.writePing() } func reset() { unfairLock.withLock { webSocket.delegate = nil webSocket.disconnect(code: nil) } heartbeatTimer?.invalidate() self.heartbeatTimer = nil let requestInfos = requestInfoMap.removeAllValues() failPendingMessages(requestInfos: requestInfos) } private func failPendingMessages(requestInfos: [RequestInfo]) { guard !requestInfos.isEmpty else { return } Logger.info("\(logPrefix): \(requestInfos.count).") for requestInfo in requestInfos { requestInfo.didFailDueToNetwork() } } // This method is thread-safe. fileprivate func sendRequest(requestInfo: RequestInfo, messageData: Data, delegate: WebSocketConnectionDelegate) { requestInfoMap[requestInfo.requestId] = requestInfo webSocket.write(data: messageData) let socketTimeoutSeconds: TimeInterval = 10 DispatchQueue.global().asyncAfter(deadline: .now() + socketTimeoutSeconds) { [weak delegate, weak requestInfo] in guard let delegate = delegate, let requestInfo = requestInfo else { return } if requestInfo.timeoutIfNecessary() { delegate.webSocketRequestDidTimeout() } } } fileprivate func popRequestInfo(forRequestId requestId: UInt64) -> RequestInfo? { requestInfoMap.removeValue(forKey: requestId) } fileprivate func sendResponse(for request: WebSocketProtoWebSocketRequestMessage, status: UInt32, message: String) throws { try webSocket.sendResponse(for: request, status: status, message: message) } } internal class OWSChatConnectionWithLibSignalShadowing: OWSChatConnectionUsingSSKWebSocket { private var libsignalNet: Net @Atomic private var chatService: ChatService private var _shadowingFrequency: Double private var shadowingFrequency: Double { get { assertOnQueue(serialQueue) return _shadowingFrequency } set { assertOnQueue(serialQueue) _shadowingFrequency = newValue } } private var previousUnexpectedReconnectsForThisInstance = AtomicUInt(lock: .init()) private let statsStore: SDSKeyValueStore = SDSKeyValueStore(collection: "OWSChatConnectionWithLibSignalShadowing") private struct Stats: Codable { var requestsCompared: UInt64 = 0 var healthcheckBadStatusCount: UInt64 = 0 var unexpectedReconnects: UInt64 = 0 var healthcheckFailures: UInt64 = 0 var requestsDuringInactive: UInt64 = 0 var lastNotifyTimestamp: Date = Date(timeIntervalSince1970: 0) mutating func notifyAndResetIfNeeded() { if shouldNotify() { GlobalDependencies.notificationPresenter.notifyTestPopulation(ofErrorMessage: "Experimental WebSocket Transport is seeing too many errors") self = Stats() self.lastNotifyTimestamp = Date() } } func shouldNotify() -> Bool { if requestsCompared < 1000 { return false } if abs(self.lastNotifyTimestamp.timeIntervalSinceNow) < 24 * 60 * 60 { return false } return healthcheckBadStatusCount + healthcheckFailures > 20 || requestsDuringInactive > 50 || unexpectedReconnects > 50 } } private static func makeChatService(libsignalNet: Net) -> ChatService { return libsignalNet.createChatService(username: "", password: "") } internal init(libsignalNet: Net, type: OWSChatConnectionType, appExpiry: AppExpiry, db: DB, shadowingFrequency: Double) { owsAssert((0.0...1.0).contains(shadowingFrequency)) self.libsignalNet = libsignalNet self.chatService = Self.makeChatService(libsignalNet: libsignalNet) self._shadowingFrequency = shadowingFrequency super.init(type: type, appExpiry: appExpiry, db: db) } fileprivate override func isSignalProxyReadyDidChange(_ notification: NSNotification) { self.chatService = Self.makeChatService(libsignalNet: libsignalNet) super.isSignalProxyReadyDidChange(notification) // Sometimes the super implementation cycles the main socket, // but sometimes it waits for the proxy to close the socket. // In that case we need to apply the correct state for the libsignal shadow. applyDesiredSocketState() } private func shouldSendShadowRequest() -> Bool { assertOnQueue(serialQueue) if CurrentAppContext().isRunningTests { return false } if GlobalDependencies.signalService.isCensorshipCircumventionManuallyDisabled { // libsignal-net currently always tries censorship circumvention mode as a fallback, // so it should work in scenarios where CC is *on*. return false } return shadowingFrequency == 1.0 || Double.random(in: 0.0..<1.0) < shadowingFrequency } internal func updateShadowingFrequency(_ newFrequency: Double) { owsAssert((0.0...1.0).contains(newFrequency)) serialQueue.async { self.shadowingFrequency = newFrequency } } fileprivate override func ensureWebsocketExists() { super.ensureWebsocketExists() // We do this asynchronously, the same way ensureWebsocketExists() is itself invoked asynchronously. // There is *technically* a chance of spuriously failing a future request, // since we never check for success: // 1. The SSKWebSocket-based chat socket opens. // 2. This Task starts, but gets suspended at connectUnauthenticated(). // 3. An entire request succeeds on the SSKWebSocket-based socket. // 4. A shadowing request Task is kicked off. // 5. The shadowing request fails because (2) never got through to the underlying socket. // This is *extremely* unlikely, though. These get tracked like premature disconnects (discussed below). Task { do { owsAssertDebug(type == .unidentified) let debugInfo = try await chatService.connectUnauthenticated() Logger.verbose("\(logPrefix): libsignal shadowing socket connected: \(debugInfo.connectionInfo)") } catch { Logger.error("\(logPrefix): failed to connect libsignal: \(error)") } } } fileprivate override func disconnectIfNeeded() { super.disconnectIfNeeded() // Doing this asynchronously means there is a chance of spuriously failing a future shadow request: // 1. An entire request succeeds on the SSKWebSocket-based socket. // 2. The libsignal web socket is disconnected. // 3. The shadow request on the libsignal is thus cancelled. // We track these errors separately just in case. Task { do { try await chatService.disconnect() } catch { Logger.error("\(logPrefix): failed to disconnect libsignal: \(error)") } } } fileprivate override func makeRequestInternal(_ request: TSRequest, unsubmittedRequestToken: UnsubmittedRequestToken, success: @escaping RequestSuccessInternal, failure: @escaping RequestFailure) { super.makeRequestInternal(request, unsubmittedRequestToken: unsubmittedRequestToken, success: { [weak self] response, requestInfo in success(response, requestInfo) if let self, self.shouldSendShadowRequest() { let shouldNotify = self.shadowingFrequency == 1.0 Task { await self.sendShadowRequest(originalRequestId: requestInfo.requestId, notifyOnFailure: shouldNotify) } } }, failure: failure) } private func sendShadowRequest(originalRequestId: UInt64, notifyOnFailure: Bool) async { let updateStatsAsync = { (modify: @escaping (inout Stats) -> Void) in // We care that stats are updated atomically, but not urgently. self.db.asyncWrite { [weak self] transaction in guard let self else { return } let key = "Stats-\(self.type)" var stats: Stats do { stats = try self.statsStore.getCodableValue(forKey: key, transaction: transaction) ?? Stats() } catch { owsFailDebug("Failed to load stats (resetting): \(error)") stats = Stats() } modify(&stats) if notifyOnFailure { stats.notifyAndResetIfNeeded() } do { try self.statsStore.setCodable(stats, key: key, transaction: transaction) } catch { owsFailDebug("Failed to update stats: \(error)") } } } do { let (healthCheckResult, debugInfo) = try await chatService.unauthenticatedSendAndDebug(.init(method: "GET", pathAndQuery: "/v1/keepalive", timeout: 3)) let succeeded = (200...299).contains(healthCheckResult.status) if !succeeded { Logger.warn("\(logPrefix): [\(originalRequestId)] keepalive via libsignal responded with status [\(healthCheckResult.status)] (\(debugInfo.connectionInfo))") } else { Logger.verbose("\(logPrefix): [\(originalRequestId)] keepalive via libsignal responded with status [\(healthCheckResult.status)] (\(debugInfo.connectionInfo))") } updateStatsAsync { [previousUnexpectedReconnectsForThisInstance] stats in stats.requestsCompared += 1 if !succeeded { stats.healthcheckBadStatusCount += 1 } if debugInfo.reconnectCount > 0 { let previousUnexpectedReconnects = previousUnexpectedReconnectsForThisInstance.swap(UInt(debugInfo.reconnectCount)) if debugInfo.reconnectCount > previousUnexpectedReconnects { stats.unexpectedReconnects += UInt64(debugInfo.reconnectCount) - UInt64(previousUnexpectedReconnects) } else { // Maybe we're processing results out of order. Just put the previous number back. // This isn't atomic, but we're in a DB write transaction anyway, so we effectively have a lock. previousUnexpectedReconnectsForThisInstance.set(previousUnexpectedReconnects) } } } } catch { Logger.warn("\(logPrefix): [\(originalRequestId)] failed to send keepalive via libsignal: \(error)") updateStatsAsync { switch error { case SignalError.chatServiceInactive(_): $0.requestsDuringInactive += 1 default: $0.healthcheckFailures += 1 } } } } } internal class OWSChatConnectionUsingLibSignal: OWSChatConnection { private let libsignalNet: Net private var _chatService: ChatService private var chatService: ChatService { get { assertOnQueue(serialQueue) return _chatService } set { assertOnQueue(serialQueue) _chatService = newValue } } // Libsignal's ChatService expects callers to avoid racing connect() and disconnect() calls. // We track the completion of the most recent connect() and disconnect() to do so. // Combined with currentState, this lets us decide whether an additional connect() or disconnect() // would be redundant. private enum TransitionTask { case connecting(Task) case disconnecting(Task) } private var _mostRecentTransition: TransitionTask? private var mostRecentTransition: TransitionTask? { get { assertOnQueue(serialQueue) return _mostRecentTransition } set { assertOnQueue(serialQueue) _mostRecentTransition = newValue } } internal init(libsignalNet: Net, type: OWSChatConnectionType, appExpiry: AppExpiry, db: DB) { self.libsignalNet = libsignalNet self._chatService = Self.makeChatService(libsignalNet: libsignalNet) super.init(type: type, appExpiry: appExpiry, db: db) } private static func makeChatService(libsignalNet: Net) -> ChatService { return libsignalNet.createChatService(username: "", password: "") } fileprivate override func isSignalProxyReadyDidChange(_ notification: NSNotification) { // We'd like to *immediately* switch over to the new proxy settings, // but the new chat service will start closed, // and we need to make sure there's not a window where we claim to be open but aren't. // Similarly, we need to not race between marking ourselves closed // and installing the new chat service, or the old one could get re-opened. // Using the serialQueue to manage all this is consistent with the previous implementation. self.serialQueue.async { self.chatService = Self.makeChatService(libsignalNet: self.libsignalNet) self.mostRecentTransition = nil self.notifyStatusChange(newState: .closed) } // Note that this includes its own serialQueue.async, so we might as well do it here. applyDesiredSocketState() } fileprivate override func ensureWebsocketExists() { assertOnQueue(serialQueue) let disconnectingTask: Task? if case .disconnecting(let task) = mostRecentTransition { disconnectingTask = task } else if currentState == .closed { disconnectingTask = nil } else { owsAssertDebug(mostRecentTransition != nil, "once out of the closed state, we should always have a transition") // 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 } notifyStatusChange(newState: .connecting) mostRecentTransition = .connecting(Task { [chatService] in // Finish disconnecting before we try to reopen. await disconnectingTask?.value func connectionAttemptCompleted(_ state: OWSChatConnectionState) { self.serialQueue.async { if self.chatService === chatService && self.currentState == .connecting { self.notifyStatusChange(newState: state) } else { // We finished connecting, but either we've since been asked to disconnect, // or the chat config has changed (causing chatService to be recreated). } } } do { owsAssertDebug(type == .unidentified, "identified connection not supported yet") try await chatService.connectUnauthenticated() connectionAttemptCompleted(.open) outageDetection.reportConnectionSuccess() return true } catch { Logger.error("\(self.logPrefix): failed to connect: \(error)") connectionAttemptCompleted(.closed) outageDetection.reportConnectionFailure() return false } }) } fileprivate override func disconnectIfNeeded() { assertOnQueue(serialQueue) guard case .connecting(let connectingTask) = mostRecentTransition else { // Either we are already disconnecting, // or we finished disconnecting, // or we were never connected to begin with. return } mostRecentTransition = .disconnecting( Task { [chatService] in do { // Finish opening before disconnecting. // (We can't use waitForOpen() here because that will wait forever if the connection fails.) if await connectingTask.value { try await chatService.disconnect() } } catch { Logger.info("\(self.logPrefix): error while disconnecting: \(error)") } } ) notifyStatusChange(newState: .closed) } public override var currentState: OWSChatConnectionState { // We can't really be more precise than this because of (1) libsignal handling reconnects, // and (2) async means it'll immediately be out of date. This describes intent, anyway. return cachedCurrentState } fileprivate override var logPrefix: String { "[\(type): libsignal]" } fileprivate override func makeRequestInternal(_ request: TSRequest, unsubmittedRequestToken: UnsubmittedRequestToken, success: @escaping RequestSuccessInternal, failure: @escaping RequestFailure) { var unsubmittedRequestTokenForEarlyExit: Optional = unsubmittedRequestToken defer { if let unsubmittedRequestTokenForEarlyExit { removeUnsubmittedRequestToken(unsubmittedRequestTokenForEarlyExit) } } guard let requestInfo = RequestInfo(request: request, connectionType: type, success: success, failure: failure) else { // Failure already reported by the init. return } let label = Self.label(forRequest: request, connectionType: type, requestInfo: requestInfo) let httpHeaders = OWSHttpHeaders(httpHeaders: request.allHTTPHeaderFields, overwriteOnConflict: false) httpHeaders.addDefaultHeaders() let body: Data if let existingBody = request.httpBody { body = existingBody } else { // TODO: Do we need body & headers for requests with no parameters? do { body = try JSONSerialization.data(withJSONObject: request.parameters, options: []) } catch { owsFailDebug("\(label) Error: \(error).") requestInfo.didFailInvalidRequest() return } // If we're going to use the json serialized parameters as our body, we should overwrite // the Content-Type on the request. httpHeaders.addHeader("Content-Type", value: "application/json", overwriteOnConflict: true) } let requestUrl = requestInfo.requestUrl owsAssertDebug(requestUrl.scheme == nil) owsAssertDebug(requestUrl.host == nil) owsAssertDebug(!requestUrl.path.hasPrefix("/")) let libsignalRequest = ChatService.Request(method: requestInfo.httpMethod, pathAndQuery: "/\(requestUrl.relativeString)", headers: httpHeaders.headers, body: body, timeout: 10) Logger.info("\(label) Making request") unsubmittedRequestTokenForEarlyExit = nil _ = Promise.wrapAsync { [self, chatService] in // LibSignalClient's ChatService doesn't keep track of outstanding requests, // so we keep the request token alive until we get the response instead. defer { removeUnsubmittedRequestToken(unsubmittedRequestToken) } return try await chatService.unauthenticatedSendAndDebug(libsignalRequest) }.done(on: self.serialQueue) { (response: ChatService.Response, debugInfo: ChatService.DebugInfo) in if DebugFlags.internalLogging { Logger.info("\(label) received response, status: \(response.status), message: \(response.message), route: \(debugInfo.connectionInfo)") } else { Logger.info("\(label) received response, status: \(response.status)") } self.ensureBackgroundKeepAlive(.receiveResponse) let headers = OWSHttpHeaders(httpHeaders: response.headers, overwriteOnConflict: false) requestInfo.complete(status: Int(response.status), headers: headers, data: response.body) // We may have been holding the websocket open, waiting for this response. // Check if we should close the websocket. self.applyDesiredSocketState() }.catch(on: self.serialQueue) { error in switch error as? SignalError { case .connectionTimeoutError(_): _ = requestInfo.timeoutIfNecessary() // libsignal handles reconnecting after timeouts case .webSocketError(_), .connectionFailed(_): requestInfo.didFailDueToNetwork() default: owsFailDebug("\(label) failed with an unexpected error: \(error)") requestInfo.didFailDueToNetwork() } } } }