Skip to content

Commit cbbb1f8

Browse files
Integrate lifecycle manager into existing room operations
Replace the existing temporary implementations of room attach / detach / status with those provided by the room lifecycle manager. Part of #47.
1 parent a1703dc commit cbbb1f8

12 files changed

+302
-187
lines changed

Sources/AblyChat/ChatClient.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ public actor DefaultChatClient: ChatClient {
2020
self.realtime = realtime
2121
self.clientOptions = clientOptions ?? .init()
2222
logger = DefaultInternalLogger(logHandler: self.clientOptions.logHandler, logLevel: self.clientOptions.logLevel)
23-
rooms = DefaultRooms(realtime: realtime, clientOptions: self.clientOptions, logger: logger)
23+
let roomLifecycleManagerFactory = DefaultRoomLifecycleManagerFactory()
24+
rooms = DefaultRooms(realtime: realtime, clientOptions: self.clientOptions, logger: logger, lifecycleManagerFactory: roomLifecycleManagerFactory)
2425
}
2526

2627
public nonisolated var connection: any Connection {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import Ably
2+
3+
internal actor DefaultRoomLifecycleContributor: RoomLifecycleContributor {
4+
internal let channel: DefaultRoomLifecycleContributorChannel
5+
internal let feature: RoomFeature
6+
7+
internal init(channel: DefaultRoomLifecycleContributorChannel, feature: RoomFeature) {
8+
self.channel = channel
9+
self.feature = feature
10+
}
11+
12+
// MARK: - Discontinuities
13+
14+
internal func emitDiscontinuity(_: ARTErrorInfo) {
15+
// TODO: https://github.com/ably-labs/ably-chat-swift/issues/47
16+
}
17+
}
18+
19+
internal final class DefaultRoomLifecycleContributorChannel: RoomLifecycleContributorChannel {
20+
private let underlyingChannel: any RealtimeChannelProtocol
21+
22+
internal init(underlyingChannel: any RealtimeChannelProtocol) {
23+
self.underlyingChannel = underlyingChannel
24+
}
25+
26+
internal func attach() async throws(ARTErrorInfo) {
27+
try await underlyingChannel.attachAsync()
28+
}
29+
30+
internal func detach() async throws(ARTErrorInfo) {
31+
try await underlyingChannel.detachAsync()
32+
}
33+
34+
internal var state: ARTRealtimeChannelState {
35+
underlyingChannel.state
36+
}
37+
38+
internal var errorReason: ARTErrorInfo? {
39+
underlyingChannel.errorReason
40+
}
41+
42+
internal func subscribeToState() async -> Subscription<ARTChannelStateChange> {
43+
// TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36)
44+
let subscription = Subscription<ARTChannelStateChange>(bufferingPolicy: .unbounded)
45+
underlyingChannel.on { subscription.emit($0) }
46+
return subscription
47+
}
48+
}

Sources/AblyChat/Room.swift

Lines changed: 27 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public protocol Room: AnyObject, Sendable {
1919
var options: RoomOptions { get }
2020
}
2121

22-
public struct RoomStatusChange: Sendable {
22+
public struct RoomStatusChange: Sendable, Equatable {
2323
public var current: RoomStatus
2424
public var previous: RoomStatus
2525

@@ -29,7 +29,7 @@ public struct RoomStatusChange: Sendable {
2929
}
3030
}
3131

32-
internal actor DefaultRoom: Room {
32+
internal actor DefaultRoom<LifecycleManagerFactory: RoomLifecycleManagerFactory>: Room where LifecycleManagerFactory.Contributor == DefaultRoomLifecycleContributor {
3333
internal nonisolated let roomID: String
3434
internal nonisolated let options: RoomOptions
3535
private let chatAPI: ChatAPI
@@ -39,21 +39,17 @@ internal actor DefaultRoom: Room {
3939
// Exposed for testing.
4040
private nonisolated let realtime: RealtimeClient
4141

42-
/// The channels that contribute to this room.
43-
private let channels: [RoomFeature: RealtimeChannelProtocol]
42+
private let lifecycleManager: any RoomLifecycleManager
4443

4544
#if DEBUG
4645
internal nonisolated var testsOnly_realtime: RealtimeClient {
4746
realtime
4847
}
4948
#endif
5049

51-
internal private(set) var status: RoomStatus = .initialized
52-
// TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36)
53-
private var statusSubscriptions: [Subscription<RoomStatusChange>] = []
5450
private let logger: InternalLogger
5551

56-
internal init(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger) async throws {
52+
internal init(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger, lifecycleManagerFactory: LifecycleManagerFactory) async throws {
5753
self.realtime = realtime
5854
self.roomID = roomID
5955
self.options = options
@@ -64,7 +60,13 @@ internal actor DefaultRoom: Room {
6460
throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.")
6561
}
6662

67-
channels = Self.createChannels(roomID: roomID, realtime: realtime)
63+
let channels = Self.createChannels(roomID: roomID, realtime: realtime)
64+
let contributors = Self.createContributors(channels: channels)
65+
66+
lifecycleManager = await lifecycleManagerFactory.createManager(
67+
contributors: contributors,
68+
logger: logger
69+
)
6870

6971
messages = await DefaultMessages(
7072
channel: channels[.messages]!,
@@ -75,12 +77,20 @@ internal actor DefaultRoom: Room {
7577
}
7678

7779
private static func createChannels(roomID: String, realtime: RealtimeClient) -> [RoomFeature: RealtimeChannelProtocol] {
78-
.init(uniqueKeysWithValues: [RoomFeature.messages, RoomFeature.typing, RoomFeature.reactions].map { feature in
80+
.init(uniqueKeysWithValues: [RoomFeature.messages].map { feature in
7981
let channel = realtime.getChannel(feature.channelNameForRoomID(roomID))
82+
8083
return (feature, channel)
8184
})
8285
}
8386

87+
private static func createContributors(channels: [RoomFeature: RealtimeChannelProtocol]) -> [DefaultRoomLifecycleContributor] {
88+
channels.map { entry in
89+
let (feature, channel) = entry
90+
return .init(channel: .init(underlyingChannel: channel), feature: feature)
91+
}
92+
}
93+
8494
public nonisolated var presence: any Presence {
8595
fatalError("Not yet implemented")
8696
}
@@ -98,44 +108,22 @@ internal actor DefaultRoom: Room {
98108
}
99109

100110
public func attach() async throws {
101-
for channel in channels.map(\.value) {
102-
do {
103-
try await channel.attachAsync()
104-
} catch {
105-
logger.log(message: "Failed to attach channel \(channel), error \(error)", level: .error)
106-
throw error
107-
}
108-
}
109-
transition(to: .attached)
111+
try await lifecycleManager.performAttachOperation()
110112
}
111113

112114
public func detach() async throws {
113-
for channel in channels.map(\.value) {
114-
do {
115-
try await channel.detachAsync()
116-
} catch {
117-
logger.log(message: "Failed to detach channel \(channel), error \(error)", level: .error)
118-
throw error
119-
}
120-
}
121-
transition(to: .detached)
115+
try await lifecycleManager.performDetachOperation()
122116
}
123117

124118
// MARK: - Room status
125119

126-
internal func onStatusChange(bufferingPolicy: BufferingPolicy) -> Subscription<RoomStatusChange> {
127-
let subscription: Subscription<RoomStatusChange> = .init(bufferingPolicy: bufferingPolicy)
128-
statusSubscriptions.append(subscription)
129-
return subscription
120+
internal func onStatusChange(bufferingPolicy: BufferingPolicy) async -> Subscription<RoomStatusChange> {
121+
await lifecycleManager.onChange(bufferingPolicy: bufferingPolicy)
130122
}
131123

132-
/// Sets ``status`` to the given status, and emits a status change to all subscribers added via ``onStatusChange(bufferingPolicy:)``.
133-
internal func transition(to newStatus: RoomStatus) {
134-
logger.log(message: "Transitioning to \(newStatus)", level: .debug)
135-
let statusChange = RoomStatusChange(current: newStatus, previous: status)
136-
status = newStatus
137-
for subscription in statusSubscriptions {
138-
subscription.emit(statusChange)
124+
internal var status: RoomStatus {
125+
get async {
126+
await lifecycleManager.roomStatus
139127
}
140128
}
141129
}

Sources/AblyChat/RoomFeature.swift

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,7 @@ internal enum RoomFeature {
1515
case .messages:
1616
// (CHA-M1) Chat messages for a Room are sent on a corresponding realtime channel <roomId>::$chat::$chatMessages. For example, if your room id is my-room then the messages channel will be my-room::$chat::$chatMessages.
1717
"chatMessages"
18-
case .typing:
19-
"typingIndicators"
20-
case .reactions:
21-
"reactions"
22-
case .presence, .occupancy:
18+
case .typing, .reactions, .presence, .occupancy:
2319
// We’ll add these, with reference to the relevant spec points, as we implement these features
2420
fatalError("Don’t know channel name suffix for room feature \(self)")
2521
}

Sources/AblyChat/RoomLifecycleManager.swift

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,37 @@ internal protocol RoomLifecycleContributor: Identifiable, Sendable {
4040
func emitDiscontinuity(_ error: ARTErrorInfo) async
4141
}
4242

43-
internal protocol RoomLifecycleManager: Sendable {}
43+
internal protocol RoomLifecycleManager: Sendable {
44+
func performAttachOperation() async throws
45+
func performDetachOperation() async throws
46+
var roomStatus: RoomStatus { get async }
47+
func onChange(bufferingPolicy: BufferingPolicy) async -> Subscription<RoomStatusChange>
48+
}
49+
50+
internal protocol RoomLifecycleManagerFactory: Sendable {
51+
associatedtype Contributor: RoomLifecycleContributor
52+
associatedtype Manager: RoomLifecycleManager
53+
54+
func createManager(
55+
contributors: [Contributor],
56+
logger: InternalLogger
57+
) async -> Manager
58+
}
59+
60+
internal final class DefaultRoomLifecycleManagerFactory: RoomLifecycleManagerFactory {
61+
private let clock = DefaultSimpleClock()
62+
63+
internal func createManager(
64+
contributors: [DefaultRoomLifecycleContributor],
65+
logger: InternalLogger
66+
) async -> DefaultRoomLifecycleManager<DefaultRoomLifecycleContributor> {
67+
await .init(
68+
contributors: contributors,
69+
logger: logger,
70+
clock: clock
71+
)
72+
}
73+
}
4474

4575
internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor>: RoomLifecycleManager {
4676
// MARK: - Constant properties
@@ -615,11 +645,19 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
615645

616646
// MARK: - ATTACH operation
617647

648+
internal func performAttachOperation() async throws {
649+
try await _performAttachOperation(forcingOperationID: nil)
650+
}
651+
652+
internal func performAttachOperation(testsOnly_forcingOperationID forcedOperationID: UUID? = nil) async throws {
653+
try await _performAttachOperation(forcingOperationID: forcedOperationID)
654+
}
655+
618656
/// Implements CHA-RL1’s `ATTACH` operation.
619657
///
620658
/// - Parameters:
621659
/// - forcedOperationID: Allows tests to force the operation to have a given ID. In combination with the ``testsOnly_subscribeToOperationWaitEvents`` API, this allows tests to verify that one test-initiated operation is waiting for another test-initiated operation.
622-
internal func performAttachOperation(testsOnly_forcingOperationID forcedOperationID: UUID? = nil) async throws {
660+
private func _performAttachOperation(forcingOperationID forcedOperationID: UUID?) async throws {
623661
try await performAnOperation(forcingOperationID: forcedOperationID) { operationID in
624662
try await bodyOfAttachOperation(operationID: operationID)
625663
}
@@ -727,11 +765,19 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
727765

728766
// MARK: - DETACH operation
729767

768+
internal func performDetachOperation() async throws {
769+
try await _performDetachOperation(forcingOperationID: nil)
770+
}
771+
772+
internal func performDetachOperation(testsOnly_forcingOperationID forcedOperationID: UUID? = nil) async throws {
773+
try await _performDetachOperation(forcingOperationID: forcedOperationID)
774+
}
775+
730776
/// Implements CHA-RL2’s DETACH operation.
731777
///
732778
/// - Parameters:
733779
/// - forcedOperationID: Allows tests to force the operation to have a given ID. In combination with the ``testsOnly_subscribeToOperationWaitEvents`` API, this allows tests to verify that one test-initiated operation is waiting for another test-initiated operation.
734-
internal func performDetachOperation(testsOnly_forcingOperationID forcedOperationID: UUID? = nil) async throws {
780+
private func _performDetachOperation(forcingOperationID forcedOperationID: UUID?) async throws {
735781
try await performAnOperation(forcingOperationID: forcedOperationID) { operationID in
736782
try await bodyOfDetachOperation(operationID: operationID)
737783
}

Sources/AblyChat/Rooms.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ public protocol Rooms: AnyObject, Sendable {
66
var clientOptions: ClientOptions { get }
77
}
88

9-
internal actor DefaultRooms: Rooms {
9+
internal actor DefaultRooms<LifecycleManagerFactory: RoomLifecycleManagerFactory>: Rooms where LifecycleManagerFactory.Contributor == DefaultRoomLifecycleContributor {
1010
private nonisolated let realtime: RealtimeClient
1111
private let chatAPI: ChatAPI
1212

@@ -19,14 +19,16 @@ internal actor DefaultRooms: Rooms {
1919
internal nonisolated let clientOptions: ClientOptions
2020

2121
private let logger: InternalLogger
22+
private let lifecycleManagerFactory: LifecycleManagerFactory
2223

2324
/// The set of rooms, keyed by room ID.
24-
private var rooms: [String: DefaultRoom] = [:]
25+
private var rooms: [String: DefaultRoom<LifecycleManagerFactory>] = [:]
2526

26-
internal init(realtime: RealtimeClient, clientOptions: ClientOptions, logger: InternalLogger) {
27+
internal init(realtime: RealtimeClient, clientOptions: ClientOptions, logger: InternalLogger, lifecycleManagerFactory: LifecycleManagerFactory) {
2728
self.realtime = realtime
2829
self.clientOptions = clientOptions
2930
self.logger = logger
31+
self.lifecycleManagerFactory = lifecycleManagerFactory
3032
chatAPI = ChatAPI(realtime: realtime)
3133
}
3234

@@ -41,7 +43,7 @@ internal actor DefaultRooms: Rooms {
4143

4244
return existingRoom
4345
} else {
44-
let room = try await DefaultRoom(realtime: realtime, chatAPI: chatAPI, roomID: roomID, options: options, logger: logger)
46+
let room = try await DefaultRoom(realtime: realtime, chatAPI: chatAPI, roomID: roomID, options: options, logger: logger, lifecycleManagerFactory: lifecycleManagerFactory)
4547
rooms[roomID] = room
4648
return room
4749
}

Sources/AblyChat/SimpleClock.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,9 @@ internal protocol SimpleClock: Sendable {
77
/// Behaves like `Task.sleep(nanoseconds:)`. Uses seconds instead of nanoseconds for readability at call site (we have no need for that level of precision).
88
func sleep(timeInterval: TimeInterval) async throws
99
}
10+
11+
internal final class DefaultSimpleClock: SimpleClock {
12+
internal func sleep(timeInterval: TimeInterval) async throws {
13+
try await Task.sleep(nanoseconds: UInt64(timeInterval * Double(NSEC_PER_SEC)))
14+
}
15+
}

Tests/AblyChatTests/DefaultChatClientTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ struct DefaultChatClientTests {
2222
// Then: Its `rooms` property returns an instance of DefaultRooms with the same realtime client and client options
2323
let rooms = client.rooms
2424

25-
let defaultRooms = try #require(rooms as? DefaultRooms)
25+
let defaultRooms = try #require(rooms as? DefaultRooms<DefaultRoomLifecycleManagerFactory>)
2626
#expect(defaultRooms.testsOnly_realtime === realtime)
2727
#expect(defaultRooms.clientOptions.isEqualForTestPurposes(options))
2828
}

0 commit comments

Comments
 (0)