Skip to content

Commit fd10691

Browse files
Merge pull request #107 from ably-labs/47-implement-release
[ECO-4982] Implement room release
2 parents 069d3f8 + 8daa191 commit fd10691

12 files changed

+276
-34
lines changed

Sources/AblyChat/ChatClient.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +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-
let roomLifecycleManagerFactory = DefaultRoomLifecycleManagerFactory()
24-
rooms = DefaultRooms(realtime: realtime, clientOptions: self.clientOptions, logger: logger, lifecycleManagerFactory: roomLifecycleManagerFactory)
23+
let roomFactory = DefaultRoomFactory()
24+
rooms = DefaultRooms(realtime: realtime, clientOptions: self.clientOptions, logger: logger, roomFactory: roomFactory)
2525
}
2626

2727
public nonisolated var connection: any Connection {

Sources/AblyChat/Room.swift

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

22+
/// A ``Room`` that exposes additional functionality for use within the SDK.
23+
internal protocol InternalRoom: Room {
24+
func release() async
25+
}
26+
2227
public struct RoomStatusChange: Sendable, Equatable {
2328
public var current: RoomStatus
2429
public var previous: RoomStatus
@@ -29,7 +34,28 @@ public struct RoomStatusChange: Sendable, Equatable {
2934
}
3035
}
3136

32-
internal actor DefaultRoom<LifecycleManagerFactory: RoomLifecycleManagerFactory>: Room where LifecycleManagerFactory.Contributor == DefaultRoomLifecycleContributor {
37+
internal protocol RoomFactory: Sendable {
38+
associatedtype Room: AblyChat.InternalRoom
39+
40+
func createRoom(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger) async throws -> Room
41+
}
42+
43+
internal final class DefaultRoomFactory: Sendable, RoomFactory {
44+
private let lifecycleManagerFactory = DefaultRoomLifecycleManagerFactory()
45+
46+
internal func createRoom(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger) async throws -> DefaultRoom<DefaultRoomLifecycleManagerFactory> {
47+
try await DefaultRoom(
48+
realtime: realtime,
49+
chatAPI: chatAPI,
50+
roomID: roomID,
51+
options: options,
52+
logger: logger,
53+
lifecycleManagerFactory: lifecycleManagerFactory
54+
)
55+
}
56+
}
57+
58+
internal actor DefaultRoom<LifecycleManagerFactory: RoomLifecycleManagerFactory>: InternalRoom where LifecycleManagerFactory.Contributor == DefaultRoomLifecycleContributor {
3359
internal nonisolated let roomID: String
3460
internal nonisolated let options: RoomOptions
3561
private let chatAPI: ChatAPI
@@ -40,12 +66,7 @@ internal actor DefaultRoom<LifecycleManagerFactory: RoomLifecycleManagerFactory>
4066
private nonisolated let realtime: RealtimeClient
4167

4268
private let lifecycleManager: any RoomLifecycleManager
43-
44-
#if DEBUG
45-
internal nonisolated var testsOnly_realtime: RealtimeClient {
46-
realtime
47-
}
48-
#endif
69+
private let channels: [RoomFeature: any RealtimeChannelProtocol]
4970

5071
private let logger: InternalLogger
5172

@@ -60,7 +81,7 @@ internal actor DefaultRoom<LifecycleManagerFactory: RoomLifecycleManagerFactory>
6081
throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.")
6182
}
6283

63-
let channels = Self.createChannels(roomID: roomID, realtime: realtime)
84+
channels = Self.createChannels(roomID: roomID, realtime: realtime)
6485
let contributors = Self.createContributors(channels: channels)
6586

6687
lifecycleManager = await lifecycleManagerFactory.createManager(
@@ -115,6 +136,15 @@ internal actor DefaultRoom<LifecycleManagerFactory: RoomLifecycleManagerFactory>
115136
try await lifecycleManager.performDetachOperation()
116137
}
117138

139+
internal func release() async {
140+
await lifecycleManager.performReleaseOperation()
141+
142+
// CHA-RL3h
143+
for channel in channels.values {
144+
realtime.channels.release(channel.name)
145+
}
146+
}
147+
118148
// MARK: - Room status
119149

120150
internal func onStatusChange(bufferingPolicy: BufferingPolicy) async -> Subscription<RoomStatusChange> {

Sources/AblyChat/RoomLifecycleManager.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ internal protocol RoomLifecycleContributor: Identifiable, Sendable {
4343
internal protocol RoomLifecycleManager: Sendable {
4444
func performAttachOperation() async throws
4545
func performDetachOperation() async throws
46+
func performReleaseOperation() async
4647
var roomStatus: RoomStatus { get async }
4748
func onChange(bufferingPolicy: BufferingPolicy) async -> Subscription<RoomStatusChange>
4849
}
@@ -864,11 +865,19 @@ internal actor DefaultRoomLifecycleManager<Contributor: RoomLifecycleContributor
864865

865866
// MARK: - RELEASE operation
866867

868+
internal func performReleaseOperation() async {
869+
await _performReleaseOperation(forcingOperationID: nil)
870+
}
871+
872+
internal func performReleaseOperation(testsOnly_forcingOperationID forcedOperationID: UUID? = nil) async {
873+
await _performReleaseOperation(forcingOperationID: forcedOperationID)
874+
}
875+
867876
/// Implements CHA-RL3’s RELEASE operation.
868877
///
869878
/// - Parameters:
870879
/// - 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.
871-
internal func performReleaseOperation(testsOnly_forcingOperationID forcedOperationID: UUID? = nil) async {
880+
internal func _performReleaseOperation(forcingOperationID forcedOperationID: UUID? = nil) async {
872881
await performAnOperation(forcingOperationID: forcedOperationID) { operationID in
873882
await bodyOfReleaseOperation(operationID: operationID)
874883
}

Sources/AblyChat/Rooms.swift

Lines changed: 23 additions & 8 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<LifecycleManagerFactory: RoomLifecycleManagerFactory>: Rooms where LifecycleManagerFactory.Contributor == DefaultRoomLifecycleContributor {
9+
internal actor DefaultRooms<RoomFactory: AblyChat.RoomFactory>: Rooms {
1010
private nonisolated let realtime: RealtimeClient
1111
private let chatAPI: ChatAPI
1212

@@ -19,16 +19,16 @@ internal actor DefaultRooms<LifecycleManagerFactory: RoomLifecycleManagerFactory
1919
internal nonisolated let clientOptions: ClientOptions
2020

2121
private let logger: InternalLogger
22-
private let lifecycleManagerFactory: LifecycleManagerFactory
22+
private let roomFactory: RoomFactory
2323

2424
/// The set of rooms, keyed by room ID.
25-
private var rooms: [String: DefaultRoom<LifecycleManagerFactory>] = [:]
25+
private var rooms: [String: RoomFactory.Room] = [:]
2626

27-
internal init(realtime: RealtimeClient, clientOptions: ClientOptions, logger: InternalLogger, lifecycleManagerFactory: LifecycleManagerFactory) {
27+
internal init(realtime: RealtimeClient, clientOptions: ClientOptions, logger: InternalLogger, roomFactory: RoomFactory) {
2828
self.realtime = realtime
2929
self.clientOptions = clientOptions
3030
self.logger = logger
31-
self.lifecycleManagerFactory = lifecycleManagerFactory
31+
self.roomFactory = roomFactory
3232
chatAPI = ChatAPI(realtime: realtime)
3333
}
3434

@@ -43,13 +43,28 @@ internal actor DefaultRooms<LifecycleManagerFactory: RoomLifecycleManagerFactory
4343

4444
return existingRoom
4545
} else {
46-
let room = try await DefaultRoom(realtime: realtime, chatAPI: chatAPI, roomID: roomID, options: options, logger: logger, lifecycleManagerFactory: lifecycleManagerFactory)
46+
let room = try await roomFactory.createRoom(realtime: realtime, chatAPI: chatAPI, roomID: roomID, options: options, logger: logger)
4747
rooms[roomID] = room
4848
return room
4949
}
5050
}
5151

52-
internal func release(roomID _: String) async throws {
53-
fatalError("Not yet implemented")
52+
#if DEBUG
53+
internal func testsOnly_hasExistingRoomWithID(_ roomID: String) -> Bool {
54+
rooms[roomID] != nil
55+
}
56+
#endif
57+
58+
internal func release(roomID: String) async throws {
59+
guard let room = rooms[roomID] else {
60+
// TODO: what to do here? (https://github.com/ably/specification/pull/200/files#r1837154563) — Andy replied that it’s a no-op but that this is going to be specified in an upcoming PR when we make room-getting async
61+
return
62+
}
63+
64+
// CHA-RC1d
65+
rooms.removeValue(forKey: roomID)
66+
67+
// CHA-RL1e
68+
await room.release()
5469
}
5570
}

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

Tests/AblyChatTests/DefaultRoomTests.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,33 @@ struct DefaultRoomTests {
9292
#expect(await lifecycleManager.detachCallCount == 1)
9393
}
9494

95+
// MARK: - Release
96+
97+
// @spec CHA-RL3h - I haven’t explicitly tested that `performReleaseOperation()` happens _before_ releasing the channels (i.e. the “upon operation completion” part of the spec point), because it would require me to spend extra time on mock-writing which I can’t really afford to spend right now. I think we can live with it at least for the time being; I’m pretty sure there are other tests where the spec mentions or requires an order where I also haven’t tested the order.
98+
@Test
99+
func release() async throws {
100+
// Given: a DefaultRoom instance
101+
let channelsList = [
102+
MockRealtimeChannel(name: "basketball::$chat::$chatMessages"),
103+
]
104+
let channels = MockChannels(channels: channelsList)
105+
let realtime = MockRealtime.create(channels: channels)
106+
107+
let lifecycleManager = MockRoomLifecycleManager()
108+
let lifecycleManagerFactory = MockRoomLifecycleManagerFactory(manager: lifecycleManager)
109+
110+
let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger(), lifecycleManagerFactory: lifecycleManagerFactory)
111+
112+
// When: `release()` is called on the room
113+
await room.release()
114+
115+
// Then: It:
116+
// 1. calls `performReleaseOperation()` on the room lifecycle manager
117+
// 2. calls `channels.release()` with the name of each of the features’ channels
118+
#expect(await lifecycleManager.releaseCallCount == 1)
119+
#expect(Set(channels.releaseArguments) == Set(channelsList.map(\.name)))
120+
}
121+
95122
// MARK: - Room status
96123

97124
@Test

Tests/AblyChatTests/DefaultRoomsTests.swift

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,32 @@ import Testing
33

44
// The channel name of basketball::$chat::$chatMessages is passed in to these tests due to `DefaultRoom` kicking off the `DefaultMessages` initialization. This in turn needs a valid `roomId` or else the `MockChannels` class will throw an error as it would be expecting a channel with the name \(roomID)::$chat::$chatMessages to exist (where `roomId` is the property passed into `rooms.get`).
55
struct DefaultRoomsTests {
6+
// MARK: - Get a room
7+
68
// @spec CHA-RC1a
79
@Test
810
func get_returnsRoomWithGivenID() async throws {
911
// Given: an instance of DefaultRooms
1012
let realtime = MockRealtime.create(channels: .init(channels: [
1113
.init(name: "basketball::$chat::$chatMessages"),
1214
]))
13-
let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger(), lifecycleManagerFactory: MockRoomLifecycleManagerFactory())
15+
let options = RoomOptions()
16+
let roomToReturn = MockRoom(options: options)
17+
let roomFactory = MockRoomFactory(room: roomToReturn)
18+
let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger(), roomFactory: roomFactory)
1419

1520
// When: get(roomID:options:) is called
1621
let roomID = "basketball"
17-
let options = RoomOptions()
1822
let room = try await rooms.get(roomID: roomID, options: options)
1923

20-
// Then: It returns a DefaultRoom instance that uses the same Realtime instance, with the given ID and options
21-
let defaultRoom = try #require(room as? DefaultRoom<MockRoomLifecycleManagerFactory>)
22-
#expect(defaultRoom.testsOnly_realtime === realtime)
23-
#expect(defaultRoom.roomID == roomID)
24-
#expect(defaultRoom.options == options)
24+
// Then: It returns a room that uses the same Realtime instance, with the given ID and options
25+
let mockRoom = try #require(room as? MockRoom)
26+
#expect(mockRoom === roomToReturn)
27+
28+
let createRoomArguments = try #require(await roomFactory.createRoomArguments)
29+
#expect(createRoomArguments.realtime === realtime)
30+
#expect(createRoomArguments.roomID == roomID)
31+
#expect(createRoomArguments.options == options)
2532
}
2633

2734
// @spec CHA-RC1b
@@ -31,10 +38,11 @@ struct DefaultRoomsTests {
3138
let realtime = MockRealtime.create(channels: .init(channels: [
3239
.init(name: "basketball::$chat::$chatMessages"),
3340
]))
34-
let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger(), lifecycleManagerFactory: MockRoomLifecycleManagerFactory())
41+
let options = RoomOptions()
42+
let roomToReturn = MockRoom(options: options)
43+
let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger(), roomFactory: MockRoomFactory(room: roomToReturn))
3544

3645
let roomID = "basketball"
37-
let options = RoomOptions()
3846
let firstRoom = try await rooms.get(roomID: roomID, options: options)
3947

4048
// When: get(roomID:options:) is called with the same room ID
@@ -51,10 +59,11 @@ struct DefaultRoomsTests {
5159
let realtime = MockRealtime.create(channels: .init(channels: [
5260
.init(name: "basketball::$chat::$chatMessages"),
5361
]))
54-
let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger(), lifecycleManagerFactory: MockRoomLifecycleManagerFactory())
62+
let options = RoomOptions()
63+
let roomToReturn = MockRoom(options: options)
64+
let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger(), roomFactory: MockRoomFactory(room: roomToReturn))
5565

5666
let roomID = "basketball"
57-
let options = RoomOptions()
5867
_ = try await rooms.get(roomID: roomID, options: options)
5968

6069
// When: get(roomID:options:) is called with the same ID but different options
@@ -71,4 +80,43 @@ struct DefaultRoomsTests {
7180
// Then: It throws an inconsistentRoomOptions error
7281
#expect(isChatError(caughtError, withCode: .inconsistentRoomOptions))
7382
}
83+
84+
// MARK: - Release a room
85+
86+
// @spec CHA-RC1d
87+
// @spec CHA-RC1e
88+
@Test
89+
func release() async throws {
90+
// Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID
91+
let realtime = MockRealtime.create(channels: .init(channels: [
92+
.init(name: "basketball::$chat::$chatMessages"),
93+
]))
94+
let options = RoomOptions()
95+
let hasExistingRoomAtMomentRoomReleaseCalledStreamComponents = AsyncStream.makeStream(of: Bool.self)
96+
let roomFactory = MockRoomFactory()
97+
let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger(), roomFactory: roomFactory)
98+
99+
let roomID = "basketball"
100+
101+
let roomToReturn = MockRoom(options: options) {
102+
await hasExistingRoomAtMomentRoomReleaseCalledStreamComponents.continuation.yield(rooms.testsOnly_hasExistingRoomWithID(roomID))
103+
}
104+
await roomFactory.setRoom(roomToReturn)
105+
106+
_ = try await rooms.get(roomID: roomID, options: .init())
107+
try #require(await rooms.testsOnly_hasExistingRoomWithID(roomID))
108+
109+
// When: `release(roomID:)` is called with this room ID
110+
_ = try await rooms.release(roomID: roomID)
111+
112+
// Then:
113+
// 1. first, the room is removed from the room map
114+
// 2. next, `release` is called on the room
115+
116+
// These two lines are convoluted because the #require macro has a hard time with stuff of type Bool? and emits warnings about ambiguity unless you jump through the hoops it tells you to
117+
let hasExistingRoomAtMomentRoomReleaseCalled = await hasExistingRoomAtMomentRoomReleaseCalledStreamComponents.stream.first { _ in true }
118+
#expect(try !#require(hasExistingRoomAtMomentRoomReleaseCalled as Bool?))
119+
120+
#expect(await roomToReturn.releaseCallCount == 1)
121+
}
74122
}

Tests/AblyChatTests/IntegrationTests.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,5 +74,16 @@ struct IntegrationTests {
7474
// (11) Check that we received a DETACHED status change as a result of detaching the room
7575
_ = try #require(await rxRoomStatusSubscription.first { $0.current == .detached })
7676
#expect(await rxRoom.status == .detached)
77+
78+
// (12) Release the room
79+
try await rxClient.rooms.release(roomID: roomID)
80+
81+
// (13) Check that we received a RELEASED status change as a result of releasing the room
82+
_ = try #require(await rxRoomStatusSubscription.first { $0.current == .released })
83+
#expect(await rxRoom.status == .released)
84+
85+
// (14) Fetch the room we just released and check it’s a new object
86+
let postReleaseRxRoom = try await rxClient.rooms.get(roomID: roomID, options: .init())
87+
#expect(postReleaseRxRoom !== rxRoom)
7788
}
7889
}

Tests/AblyChatTests/Mocks/MockChannels.swift

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import Ably
22
import AblyChat
33

4-
final class MockChannels: RealtimeChannelsProtocol, Sendable {
4+
final class MockChannels: RealtimeChannelsProtocol, @unchecked Sendable {
55
private let channels: [MockRealtimeChannel]
6+
private let mutex = NSLock()
7+
/// Access must be synchronized via ``mutex``.
8+
private(set) var _releaseArguments: [String] = []
69

710
init(channels: [MockRealtimeChannel]) {
811
self.channels = channels
@@ -24,7 +27,17 @@ final class MockChannels: RealtimeChannelsProtocol, Sendable {
2427
fatalError("Not implemented")
2528
}
2629

27-
func release(_: String) {
28-
fatalError("Not implemented")
30+
func release(_ name: String) {
31+
mutex.lock()
32+
defer { mutex.unlock() }
33+
_releaseArguments.append(name)
34+
}
35+
36+
var releaseArguments: [String] {
37+
let result: [String]
38+
mutex.lock()
39+
result = _releaseArguments
40+
mutex.unlock()
41+
return result
2942
}
3043
}

0 commit comments

Comments
 (0)