@@ -45,7 +45,7 @@ internal protocol RoomLifecycleManager: Sendable {
4545 func performDetachOperation( ) async throws
4646 func performReleaseOperation( ) async
4747 var roomStatus : RoomStatus { get async }
48- func onChange ( bufferingPolicy: BufferingPolicy ) async -> Subscription < RoomStatusChange >
48+ func onRoomStatusChange ( bufferingPolicy: BufferingPolicy ) async -> Subscription < RoomStatusChange >
4949 func waitToBeAbleToPerformPresenceOperations( requestedByFeature requester: RoomFeature ) async throws ( ARTErrorInfo)
5050}
5151
@@ -88,7 +88,7 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
8888 private var contributorAnnotations : ContributorAnnotations
8989 private var listenForStateChangesTask : Task < Void , Never > !
9090 // TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36)
91- private var subscriptions : [ Subscription < RoomStatusChange > ] = [ ]
91+ private var roomStatusChangeSubscriptions : [ Subscription < RoomStatusChange > ] = [ ]
9292 private var operationResultContinuations = OperationResultContinuations ( )
9393
9494 // MARK: - Initializers and `deinit`
@@ -188,7 +188,8 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
188188 case detaching( detachOperationID: UUID )
189189 case detached
190190 case detachedDueToRetryOperation( retryOperationID: UUID )
191- case suspendedAwaitingStartOfRetryOperation( error: ARTErrorInfo )
191+ // `retryOperationTask` is exposed so that tests can wait for the triggered RETRY operation to complete.
192+ case suspendedAwaitingStartOfRetryOperation( retryOperationTask: Task < Void , Never > , error: ARTErrorInfo )
192193 case suspended( retryOperationID: UUID , error: ARTErrorInfo )
193194 case failed( error: ARTErrorInfo )
194195 case releasing( releaseOperationID: UUID )
@@ -210,7 +211,7 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
210211 . detaching
211212 case . detached, . detachedDueToRetryOperation:
212213 . detached
213- case let . suspendedAwaitingStartOfRetryOperation( error) :
214+ case let . suspendedAwaitingStartOfRetryOperation( _ , error) :
214215 . suspended( error: error)
215216 case let . suspended( _, error) :
216217 . suspended( error: error)
@@ -318,12 +319,36 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
318319 status. toRoomStatus
319320 }
320321
321- internal func onChange ( bufferingPolicy: BufferingPolicy ) -> Subscription < RoomStatusChange > {
322+ internal func onRoomStatusChange ( bufferingPolicy: BufferingPolicy ) -> Subscription < RoomStatusChange > {
322323 let subscription : Subscription < RoomStatusChange > = . init( bufferingPolicy: bufferingPolicy)
323- subscriptions . append ( subscription)
324+ roomStatusChangeSubscriptions . append ( subscription)
324325 return subscription
325326 }
326327
328+ #if DEBUG
329+ // TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36)
330+ /// Supports the ``testsOnly_onRoomStatusChange()`` method.
331+ private var statusChangeSubscriptions : [ Subscription < StatusChange > ] = [ ]
332+
333+ internal struct StatusChange {
334+ internal var current : Status
335+ internal var previous : Status
336+ }
337+
338+ /// Allows tests to subscribe to changes to the manager’s internal status (which exposes more cases and additional metadata, compared to the ``RoomStatus`` exposed by ``onRoomStatusChange(bufferingPolicy:)``).
339+ internal func testsOnly_onStatusChange( ) -> Subscription < StatusChange > {
340+ let subscription : Subscription < StatusChange > = . init( bufferingPolicy: . unbounded)
341+ statusChangeSubscriptions. append ( subscription)
342+ return subscription
343+ }
344+
345+ internal func emitStatusChange( _ change: StatusChange ) {
346+ for subscription in statusChangeSubscriptions {
347+ subscription. emit ( change)
348+ }
349+ }
350+ #endif
351+
327352 /// Updates ``status`` and emits a status change event.
328353 private func changeStatus( to new: Status ) {
329354 logger. log ( message: " Transitioning from \( status) to \( new) " , level: . info)
@@ -333,12 +358,17 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
333358 // Avoid a double-emit of room status when changing from `.suspendedAwaitingStartOfRetryOperation` to `.suspended`.
334359 if new. toRoomStatus != previous. toRoomStatus {
335360 let statusChange = RoomStatusChange ( current: status. toRoomStatus, previous: previous. toRoomStatus)
336- emitStatusChange ( statusChange)
361+ emitRoomStatusChange ( statusChange)
337362 }
363+
364+ #if DEBUG
365+ let statusChange = StatusChange ( current: status, previous: previous)
366+ emitStatusChange ( statusChange)
367+ #endif
338368 }
339369
340- private func emitStatusChange ( _ change: RoomStatusChange ) {
341- for subscription in subscriptions {
370+ private func emitRoomStatusChange ( _ change: RoomStatusChange ) {
371+ for subscription in roomStatusChangeSubscriptions {
342372 subscription. emit ( change)
343373 }
344374 }
@@ -480,7 +510,14 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
480510
481511 clearTransientDisconnectTimeouts ( )
482512
483- changeStatus ( to: . suspendedAwaitingStartOfRetryOperation( error: reason) )
513+ // My understanding is that, since this task is being created inside an actor’s synchronous code, the two .suspended* statuses will always come in the right order; i.e. first .suspendedAwaitingStartOfRetryOperation and then .suspended.
514+ let retryOperationTask = scheduleAnOperation (
515+ kind: . retry(
516+ triggeringContributor: contributor,
517+ errorForSuspendedStatus: reason
518+ )
519+ )
520+ changeStatus ( to: . suspendedAwaitingStartOfRetryOperation( retryOperationTask: retryOperationTask, error: reason) )
484521 }
485522 case . attaching:
486523 if !hasOperationInProgress, !contributorAnnotations[ contributor] . hasTransientDisconnectTimeout {
@@ -690,6 +727,27 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
690727 try result. get ( )
691728 }
692729
730+ /// The kinds of operation that you can schedule using ``scheduleAnOperation(kind:)``.
731+ private enum OperationKind {
732+ /// The RETRY operation.
733+ case retry( triggeringContributor: Contributor , errorForSuspendedStatus: ARTErrorInfo )
734+ }
735+
736+ /// Requests that a room lifecycle operation be performed asynchronously.
737+ private func scheduleAnOperation( kind: OperationKind ) -> Task < Void , Never > {
738+ logger. log ( message: " Scheduling operation \( kind) " , level: . debug)
739+ return Task {
740+ logger. log ( message: " Performing scheduled operation \( kind) " , level: . debug)
741+ switch kind {
742+ case let . retry( triggeringContributor, errorForSuspendedStatus) :
743+ await performRetryOperation (
744+ triggeredByContributor: triggeringContributor,
745+ errorForSuspendedStatus: errorForSuspendedStatus
746+ )
747+ }
748+ }
749+ }
750+
693751 // MARK: - ATTACH operation
694752
695753 internal func performAttachOperation( ) async throws {
@@ -752,9 +810,16 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
752810 case . suspended:
753811 // CHA-RL1h2
754812 let error = ARTErrorInfo ( chatError: . attachmentFailed( feature: contributor. feature, underlyingError: contributorAttachError) )
755- changeStatus ( to: . suspendedAwaitingStartOfRetryOperation( error: error) )
756813
757814 // CHA-RL1h3
815+ // My understanding is that, since this task is being created inside an actor’s synchronous code, the two .suspended* statuses will always come in the right order; i.e. first .suspendedAwaitingStartOfRetryOperation and then .suspended.
816+ let retryOperationTask = scheduleAnOperation (
817+ kind: . retry(
818+ triggeringContributor: contributor,
819+ errorForSuspendedStatus: error
820+ )
821+ )
822+ changeStatus ( to: . suspendedAwaitingStartOfRetryOperation( retryOperationTask: retryOperationTask, error: error) )
758823 throw error
759824 case . failed:
760825 // CHA-RL1h4
0 commit comments