Skip to content

Commit 8daa191

Browse files
Implement room release
We use the implementation of the RELEASE operation provided by the room lifecycle manager, and implement the spec points relating to room map bookkeeping and releasing the underlying realtime channels. Based on [1] at 6f0740a. I have not implemented the spec points that relate to making sure that a room fetch waits for any previous room with the same ID to finish releasing; this is a part of the spec which is in flux (currently implemented via the INITIALIZING status, which was added to the spec after we started implementing the room lifecycle manager and hasn’t been implemented in this SDK yet, and soon to be further changed in the spec by making room-getting async). We can look at the current state of things when we come to do #66. Part of #47. [1] ably/specification#200
1 parent c51fd92 commit 8daa191

File tree

10 files changed

+163
-12
lines changed

10 files changed

+163
-12
lines changed

Sources/AblyChat/Room.swift

Lines changed: 18 additions & 3 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
@@ -30,7 +35,7 @@ public struct RoomStatusChange: Sendable, Equatable {
3035
}
3136

3237
internal protocol RoomFactory: Sendable {
33-
associatedtype Room: AblyChat.Room
38+
associatedtype Room: AblyChat.InternalRoom
3439

3540
func createRoom(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger) async throws -> Room
3641
}
@@ -50,7 +55,7 @@ internal final class DefaultRoomFactory: Sendable, RoomFactory {
5055
}
5156
}
5257

53-
internal actor DefaultRoom<LifecycleManagerFactory: RoomLifecycleManagerFactory>: Room where LifecycleManagerFactory.Contributor == DefaultRoomLifecycleContributor {
58+
internal actor DefaultRoom<LifecycleManagerFactory: RoomLifecycleManagerFactory>: InternalRoom where LifecycleManagerFactory.Contributor == DefaultRoomLifecycleContributor {
5459
internal nonisolated let roomID: String
5560
internal nonisolated let options: RoomOptions
5661
private let chatAPI: ChatAPI
@@ -61,6 +66,7 @@ internal actor DefaultRoom<LifecycleManagerFactory: RoomLifecycleManagerFactory>
6166
private nonisolated let realtime: RealtimeClient
6267

6368
private let lifecycleManager: any RoomLifecycleManager
69+
private let channels: [RoomFeature: any RealtimeChannelProtocol]
6470

6571
private let logger: InternalLogger
6672

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

78-
let channels = Self.createChannels(roomID: roomID, realtime: realtime)
84+
channels = Self.createChannels(roomID: roomID, realtime: realtime)
7985
let contributors = Self.createContributors(channels: channels)
8086

8187
lifecycleManager = await lifecycleManagerFactory.createManager(
@@ -130,6 +136,15 @@ internal actor DefaultRoom<LifecycleManagerFactory: RoomLifecycleManagerFactory>
130136
try await lifecycleManager.performDetachOperation()
131137
}
132138

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+
133148
// MARK: - Room status
134149

135150
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: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,22 @@ internal actor DefaultRooms<RoomFactory: AblyChat.RoomFactory>: Rooms {
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/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: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ 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 {
@@ -78,4 +80,43 @@ struct DefaultRoomsTests {
7880
// Then: It throws an inconsistentRoomOptions error
7981
#expect(isChatError(caughtError, withCode: .inconsistentRoomOptions))
8082
}
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+
}
81122
}

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
}

Tests/AblyChatTests/Mocks/MockRoom.swift

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
@testable import AblyChat
22

3-
actor MockRoom: Room {
3+
actor MockRoom: InternalRoom {
44
let options: RoomOptions
5+
private(set) var releaseCallCount = 0
6+
let releaseImplementation: (@Sendable () async -> Void)?
57

6-
init(options: RoomOptions) {
8+
init(options: RoomOptions, releaseImplementation: (@Sendable () async -> Void)? = nil) {
79
self.options = options
10+
self.releaseImplementation = releaseImplementation
811
}
912

1013
nonisolated var roomID: String {
@@ -46,4 +49,12 @@ actor MockRoom: Room {
4649
func detach() async throws {
4750
fatalError("Not implemented")
4851
}
52+
53+
func release() async {
54+
releaseCallCount += 1
55+
guard let releaseImplementation else {
56+
fatalError("releaseImplementation must be set before calling `release`")
57+
}
58+
await releaseImplementation()
59+
}
4960
}

Tests/AblyChatTests/Mocks/MockRoomFactory.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
@testable import AblyChat
22

33
actor MockRoomFactory: RoomFactory {
4-
private let room: MockRoom?
4+
private var room: MockRoom?
55
private(set) var createRoomArguments: (realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: any InternalLogger)?
66

77
init(room: MockRoom? = nil) {
88
self.room = room
99
}
1010

11+
func setRoom(_ room: MockRoom) {
12+
self.room = room
13+
}
14+
1115
func createRoom(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: any InternalLogger) async throws -> MockRoom {
1216
createRoomArguments = (realtime: realtime, chatAPI: chatAPI, roomID: roomID, options: options, logger: logger)
1317

Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ actor MockRoomLifecycleManager: RoomLifecycleManager {
66
private(set) var attachCallCount = 0
77
private let detachResult: Result<Void, ARTErrorInfo>?
88
private(set) var detachCallCount = 0
9+
private(set) var releaseCallCount = 0
910
private let _roomStatus: RoomStatus?
1011
// TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36)
1112
private var subscriptions: [Subscription<RoomStatusChange>] = []
@@ -32,6 +33,10 @@ actor MockRoomLifecycleManager: RoomLifecycleManager {
3233
try detachResult.get()
3334
}
3435

36+
func performReleaseOperation() async {
37+
releaseCallCount += 1
38+
}
39+
3540
var roomStatus: RoomStatus {
3641
guard let roomStatus = _roomStatus else {
3742
fatalError("In order to call roomStatus, roomStatus must be passed to the initializer")

0 commit comments

Comments
 (0)