diff --git a/Sources/AblyChat/RoomLifecycleManager.swift b/Sources/AblyChat/RoomLifecycleManager.swift index f79251bc..6267f1c3 100644 --- a/Sources/AblyChat/RoomLifecycleManager.swift +++ b/Sources/AblyChat/RoomLifecycleManager.swift @@ -55,6 +55,7 @@ internal actor RoomLifecycleManager { private var listenForStateChangesTask: Task! // TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36) private var subscriptions: [Subscription] = [] + private var operationResultContinuations = OperationResultContinuations() // MARK: - Initializers and `deinit` @@ -376,12 +377,88 @@ internal actor RoomLifecycleManager { status.operationID != nil } + /// Stores bookkeeping information needed for allowing one operation to await the result of another. + private struct OperationResultContinuations { + typealias Continuation = CheckedContinuation + + private var operationResultContinuationsByOperationID: [UUID: [Continuation]] = [:] + + mutating func addContinuation(_ continuation: Continuation, forResultOfOperationWithID operationID: UUID) { + operationResultContinuationsByOperationID[operationID, default: []].append(continuation) + } + + mutating func removeContinuationsForResultOfOperationWithID(_ waitedOperationID: UUID) -> [Continuation] { + operationResultContinuationsByOperationID.removeValue(forKey: waitedOperationID) ?? [] + } + } + + /// Waits for the operation with ID `waitedOperationID` to complete, re-throwing any error thrown by that operation. + /// + /// Note that this method currently treats all waited operations as throwing. If you wish to wait for an operation that you _know_ to be non-throwing (which the RELEASE operation currently is) then you’ll need to call this method with `try!` or equivalent. (It might be possible to improve this in the future, but I didn’t want to put much time into figuring it out.) + /// + /// - Parameters: + /// - waitedOperationID: The ID of the operation whose completion will be awaited. + /// - waitingOperationID: The ID of the operation which is awaiting this result. Only used for logging. + private func waitForCompletionOfOperationWithID( + _ waitedOperationID: UUID, + waitingOperationID: UUID + ) async throws { + logger.log(message: "Operation \(waitingOperationID) started waiting for result of operation \(waitedOperationID)", level: .debug) + + do { + try await withCheckedThrowingContinuation { (continuation: OperationResultContinuations.Continuation) in + // TODO: how can we be sure that these don't result in actor hopping? i.e. that the synchronous part of `resultOfOperationWithID` happens immediately + operationResultContinuations.addContinuation(continuation, forResultOfOperationWithID: waitedOperationID) + } + + logger.log(message: "Operation \(waitingOperationID) completed waiting for result of operation \(waitedOperationID), which completed successfully", level: .debug) + } catch { + logger.log(message: "Operation \(waitingOperationID) completed waiting for result of operation \(waitedOperationID), which threw error \(error)", level: .debug) + } + } + + // TODO: who calls this? I don't think the `defer` can? but we don't want to have to call it each time an operation completes. ok, that's what i'm implementing with performAnOperation + private func operationWithID(_ operationID: UUID, didCompleteWithResult result: Result) { + logger.log(message: "Operation \(operationID) completed with result \(result)", level: .debug) + let continuationsToResume = operationResultContinuations.removeContinuationsForResultOfOperationWithID(operationID) + + for continuation in continuationsToResume { + continuation.resume(with: result) + } + } + + // TODO: make it clear that what we have _not_ implemented is a queue — each operation has its own logic for whether it should proceed in relation to other operations + + // TODO: what is this + // TODO: use this for all operations + // TODO: note that the operation is isolated to the actor (or is it? not sure — find out) + private func performAnOperation(_ body: (UUID) async throws(Failure) -> Void) async throws(Failure) { + let operationID = UUID() + logger.log(message: "Performing operation \(operationID)", level: .debug) + let result: Result + do { + try await body(operationID) + result = .success(()) + } catch { + result = .failure(error) + } + + // TODO: I guess the fact that the operation hasn't returned doesn't matter? + operationWithID(operationID, didCompleteWithResult: result.mapError { $0 }) + + try result.get() + } + // MARK: - ATTACH operation /// Implements CHA-RL1’s `ATTACH` operation. internal func performAttachOperation() async throws { - let operationID = UUID() + try await performAnOperation { operationID in + try await bodyOfAttachOperation(operationID: operationID) + } + } + private func bodyOfAttachOperation(operationID: UUID) async throws { switch status { case .attached: // CHA-RL1a @@ -476,8 +553,12 @@ internal actor RoomLifecycleManager { /// Implements CHA-RL2’s DETACH operation. internal func performDetachOperation() async throws { - let operationID = UUID() + try await performAnOperation { operationID in + try await bodyOfDetachOperation(operationID: operationID) + } + } + private func bodyOfDetachOperation(operationID: UUID) async throws { switch status { case .detached: // CHA-RL2a @@ -559,8 +640,12 @@ internal actor RoomLifecycleManager { /// Implements CHA-RL3’s RELEASE operation. internal func performReleaseOperation() async { - let operationID = UUID() + await performAnOperation { operationID in + await bodyOfReleaseOperation(operationID: operationID) + } + } + private func bodyOfReleaseOperation(operationID: UUID) async { switch status { case .released: // CHA-RL3a