Skip to content

Commit 35bd56a

Browse files
committed
Spec complete for Connection in line with [1] @ commit bf536c8
[1] - ably/specification#227 Note: CHA-CS5a3 has a typo in the spec. Omitted should be emitted. The typo has been fixed in the in-line spec comment in this commit.
1 parent 5344d7f commit 35bd56a

File tree

10 files changed

+352
-11
lines changed

10 files changed

+352
-11
lines changed

Example/AblyChatExample/ContentView.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ struct ContentView: View {
140140
.tryTask { try await showReactions() }
141141
.tryTask { try await showPresence() }
142142
.tryTask { try await showOccupancy() }
143+
.tryTask { await printConnectionStatusChange() }
143144
.tryTask {
144145
// NOTE: As we implement more features, move them out of the `if Environment.current == .mock` block and into the main block just above.
145146
if Environment.current == .mock {
@@ -149,6 +150,14 @@ struct ContentView: View {
149150
}
150151
}
151152

153+
func printConnectionStatusChange() async {
154+
let connectionSubsciption = chatClient.connection.onStatusChange(bufferingPolicy: .unbounded)
155+
156+
for await status in connectionSubsciption {
157+
print("Connection status changed to: \(status.current)")
158+
}
159+
}
160+
152161
func sendButtonAction() {
153162
if newMessage.isEmpty {
154163
Task {

Example/AblyChatExample/Mocks/MockClients.swift

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,15 @@ actor MockChatClient: ChatClient {
55
let realtime: RealtimeClient
66
nonisolated let clientOptions: ClientOptions
77
nonisolated let rooms: Rooms
8+
nonisolated let connection: Connection
89

910
init(realtime: RealtimeClient, clientOptions: ClientOptions?) {
1011
self.realtime = realtime
1112
self.clientOptions = clientOptions ?? .init()
13+
connection = MockConnection(status: .connected, error: nil)
1214
rooms = MockRooms(clientOptions: self.clientOptions)
1315
}
1416

15-
nonisolated var connection: any Connection {
16-
fatalError("Not yet implemented")
17-
}
18-
1917
nonisolated var clientID: String {
2018
fatalError("Not yet implemented")
2119
}
@@ -387,3 +385,21 @@ actor MockOccupancy: Occupancy {
387385
fatalError("Not yet implemented")
388386
}
389387
}
388+
389+
actor MockConnection: Connection {
390+
let status: AblyChat.ConnectionStatus
391+
let error: ARTErrorInfo?
392+
393+
nonisolated func onStatusChange(bufferingPolicy _: BufferingPolicy) -> Subscription<ConnectionStatusChange> {
394+
let mockSub = MockSubscription<ConnectionStatusChange>(randomElement: {
395+
ConnectionStatusChange(current: .connecting, previous: .connected, retryIn: 1)
396+
}, interval: 5)
397+
398+
return Subscription(mockAsyncSequence: mockSub)
399+
}
400+
401+
init(status: AblyChat.ConnectionStatus, error: ARTErrorInfo?) {
402+
self.status = status
403+
self.error = error
404+
}
405+
}

Example/AblyChatExample/Mocks/MockRealtime.swift

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import AblyChat
33

44
/// A mock implementation of `RealtimeClientProtocol`. It only exists so that we can construct an instance of `DefaultChatClient` without needing to create a proper `ARTRealtime` instance (which we can’t yet do because we don’t have a method for inserting an API key into the example app). TODO remove this once we start building the example app
55
final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable {
6+
let connection = Connection()
7+
68
var device: ARTLocalDevice {
79
fatalError("Not implemented")
810
}
@@ -13,6 +15,73 @@ final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable {
1315

1416
let channels = Channels()
1517

18+
final class Connection: NSObject, ConnectionProtocol {
19+
init(id: String? = nil, key: String? = nil, maxMessageSize: Int = 0, state: ARTRealtimeConnectionState = .closed, errorReason: ARTErrorInfo? = nil, recoveryKey: String? = nil) {
20+
self.id = id
21+
self.key = key
22+
self.maxMessageSize = maxMessageSize
23+
self.state = state
24+
self.errorReason = errorReason
25+
self.recoveryKey = recoveryKey
26+
}
27+
28+
let id: String?
29+
30+
let key: String?
31+
32+
let maxMessageSize: Int
33+
34+
let state: ARTRealtimeConnectionState
35+
36+
let errorReason: ARTErrorInfo?
37+
38+
let recoveryKey: String?
39+
40+
func createRecoveryKey() -> String? {
41+
fatalError("Not implemented")
42+
}
43+
44+
func connect() {
45+
fatalError("Not implemented")
46+
}
47+
48+
func close() {
49+
fatalError("Not implemented")
50+
}
51+
52+
func ping(_: @escaping ARTCallback) {
53+
fatalError("Not implemented")
54+
}
55+
56+
func on(_: ARTRealtimeConnectionEvent, callback _: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener {
57+
fatalError("Not implemented")
58+
}
59+
60+
func on(_: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener {
61+
fatalError("Not implemented")
62+
}
63+
64+
func once(_: ARTRealtimeConnectionEvent, callback _: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener {
65+
fatalError("Not implemented")
66+
}
67+
68+
func once(_: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener {
69+
fatalError("Not implemented")
70+
}
71+
72+
func off(_: ARTRealtimeConnectionEvent, listener _: ARTEventListener) {
73+
fatalError("Not implemented")
74+
}
75+
76+
func off(_: ARTEventListener) {
77+
fatalError("Not implemented")
78+
}
79+
80+
func off() {
81+
fatalError("Not implemented")
82+
}
83+
}
84+
1685
final class Channels: RealtimeChannelsProtocol {
1786
func get(_: String, options _: ARTRealtimeChannelOptions) -> MockRealtime.Channel {
1887
fatalError("Not implemented")

Sources/AblyChat/AblyCocoaExtensions/Ably+Dependencies.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ extension ARTRealtime: RealtimeClientProtocol {}
55
extension ARTRealtimeChannels: RealtimeChannelsProtocol {}
66

77
extension ARTRealtimeChannel: RealtimeChannelProtocol {}
8+
9+
extension ARTConnection: ConnectionProtocol {}

Sources/AblyChat/ChatClient.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,17 @@ public actor DefaultChatClient: ChatClient {
1616
public nonisolated let rooms: Rooms
1717
private let logger: InternalLogger
1818

19+
// (CHA-CS1) Every chat client has a status, which describes the current status of the connection.
20+
// (CHA-CS4) The chat client must allow its connection status to be observed by clients.
21+
public nonisolated let connection: any Connection
22+
1923
public init(realtime: RealtimeClient, clientOptions: ClientOptions?) {
2024
self.realtime = realtime
2125
self.clientOptions = clientOptions ?? .init()
2226
logger = DefaultInternalLogger(logHandler: self.clientOptions.logHandler, logLevel: self.clientOptions.logLevel)
2327
let roomFactory = DefaultRoomFactory()
2428
rooms = DefaultRooms(realtime: realtime, clientOptions: self.clientOptions, logger: logger, roomFactory: roomFactory)
25-
}
26-
27-
public nonisolated var connection: any Connection {
28-
fatalError("Not yet implemented")
29+
connection = DefaultConnection(realtime: realtime)
2930
}
3031

3132
public nonisolated var clientID: String {

Sources/AblyChat/Connection.swift

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,44 @@
11
import Ably
22

33
public protocol Connection: AnyObject, Sendable {
4-
var status: ConnectionStatus { get }
4+
var status: ConnectionStatus { get async }
55
// TODO: (https://github.com/ably-labs/ably-chat-swift/issues/12): consider how to avoid the need for an unwrap
6-
var error: ARTErrorInfo? { get }
6+
var error: ARTErrorInfo? { get async }
77
func onStatusChange(bufferingPolicy: BufferingPolicy) -> Subscription<ConnectionStatusChange>
88
}
99

1010
public enum ConnectionStatus: Sendable {
11+
// (CHA-CS1a) The INITIALIZED status is a default status when the realtime client is first initialized. This value will only (likely) be seen if the realtime client doesn’t have autoconnect turned on.
1112
case initialized
13+
// (CHA-CS1b) The CONNECTING status is used when the client is in the process of connecting to Ably servers.
1214
case connecting
15+
// (CHA-CS1c) The CONNECTED status is used when the client connected to Ably servers.
1316
case connected
17+
// (CHA-CS1d) The DISCONNECTED status is used when the client is not currently connected to Ably servers. This state may be temporary as the underlying Realtime SDK seeks to reconnect.
1418
case disconnected
19+
// (CHA-CS1e) The SUSPENDED status is used when the client is in an extended state of disconnection, but will attempt to reconnect.
1520
case suspended
21+
// (CHA-CS1f) The FAILED status is used when the client is disconnected from the Ably servers due to some non-retriable failure such as authentication failure. It will not attempt to reconnect.
1622
case failed
23+
24+
internal init(from realtimeConnectionState: ARTRealtimeConnectionState) {
25+
switch realtimeConnectionState {
26+
case .initialized:
27+
self = .initialized
28+
case .connecting:
29+
self = .connecting
30+
case .connected:
31+
self = .connected
32+
case .disconnected:
33+
self = .disconnected
34+
case .suspended:
35+
self = .suspended
36+
case .failed, .closing, .closed:
37+
self = .failed
38+
@unknown default:
39+
self = .failed
40+
}
41+
}
1742
}
1843

1944
public struct ConnectionStatusChange: Sendable {
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import Ably
2+
3+
// I'm not too keen on this class in the way it is... I had a few difficulties keeping a mutable status and error on the class (due to Sendable conformance). I had to resort to using an actor to manage the status and error. This then meant needing to change the `Connection` protocol so `state` and `error` have async getters. To make things more complicated, Swift supports async getters but not async setters (and it doesn't allow you to mix a sync setter with an async getter). You call the relevant methods on the actor to update the status and error. We should revisit this as part of wider concurrency concerns here: https://github.com/ably-labs/ably-chat-swift/issues/49
4+
internal final class DefaultConnection: Connection {
5+
// (CHA-CS2a) The chat client must expose its current connection status.
6+
internal var status: ConnectionStatus {
7+
get async { await connectionStatusManager.status }
8+
}
9+
10+
// (CHA-CS2b) The chat client must expose the latest error, if any, associated with its current status.
11+
internal var error: ARTErrorInfo? {
12+
get async { await connectionStatusManager.error }
13+
}
14+
15+
private let realtime: any RealtimeClientProtocol
16+
private let timerManager = TimerManager()
17+
private let connectionStatusManager = ConnectionStatusManager()
18+
19+
internal init(realtime: any RealtimeClientProtocol) {
20+
// (CHA-CS3) The initial status and error of the connection will be whatever status the realtime client returns whilst the connection status object is constructed.
21+
self.realtime = realtime
22+
Task {
23+
await connectionStatusManager.updateStatus(to: .init(from: realtime.connection.state))
24+
await connectionStatusManager.updateError(to: realtime.connection.errorReason)
25+
}
26+
}
27+
28+
// (CHA-CS4d) Clients must be able to register a listener for connection status events and receive such events.
29+
internal func onStatusChange(bufferingPolicy: BufferingPolicy) -> Subscription<ConnectionStatusChange> {
30+
let subscription = Subscription<ConnectionStatusChange>(bufferingPolicy: bufferingPolicy)
31+
32+
// (CHA-CS5) The chat client must monitor the underlying realtime connection for connection status changes.
33+
realtime.connection.on { [weak self] stateChange in
34+
guard let self else {
35+
return
36+
}
37+
let currentState = ConnectionStatus(from: stateChange.current)
38+
let previousState = ConnectionStatus(from: stateChange.previous)
39+
40+
// (CHA-CS4a) Connection status update events must contain the newly entered connection status.
41+
// (CHA-CS4b) Connection status update events must contain the previous connection status.
42+
// (CHA-CS4c) Connection status update events must contain the connection error (if any) that pertains to the newly entered connection status.
43+
let statusChange = ConnectionStatusChange(
44+
current: currentState,
45+
previous: previousState,
46+
error: stateChange.reason,
47+
retryIn: stateChange.retryIn
48+
)
49+
50+
Task {
51+
let isTimerRunning = await timerManager.hasRunningTask()
52+
// (CHA-CS5a) The chat client must suppress transient disconnection events. It is not uncommon for Ably servers to perform connection shedding to balance load, or due to retiring. Clients should not need to concern themselves with transient events.
53+
54+
// (CHA-CS5a2) If a transient disconnect timer is active and the realtime connection status changes to `DISCONNECTED` or `CONNECTING`, the library must not emit a status change.
55+
if isTimerRunning, currentState == .disconnected || currentState == .connecting {
56+
return
57+
}
58+
59+
// (CHA-CS5a3) If a transient disconnect timer is active and the realtime connections status changes to `CONNECTED`, `SUSPENDED` or `FAILED`, the library shall cancel the transient disconnect timer. The superseding status change shall be emitted.
60+
if isTimerRunning, currentState == .connected || currentState == .suspended || currentState == .failed {
61+
await timerManager.cancelTimer()
62+
subscription.emit(statusChange)
63+
// update local state and error
64+
await connectionStatusManager.updateError(to: stateChange.reason)
65+
await connectionStatusManager.updateStatus(to: currentState)
66+
}
67+
68+
// (CHA-CS5a1) If the realtime connection status transitions from `CONNECTED` to `DISCONNECTED`, the chat client connection status must not change. A 5 second transient disconnect timer shall be started.
69+
if previousState == .connected, currentState == .disconnected, !isTimerRunning {
70+
await timerManager.setTimer(interval: 5.0) { [timerManager, connectionStatusManager] in
71+
Task {
72+
// (CHA-CS5a4) If a transient disconnect timer expires the library shall emit a connection status change event. This event must contain the current status of of timer expiry, along with the original error that initiated the transient disconnect timer.
73+
await timerManager.cancelTimer()
74+
subscription.emit(statusChange)
75+
76+
// update local state and error
77+
await connectionStatusManager.updateError(to: stateChange.reason)
78+
await connectionStatusManager.updateStatus(to: currentState)
79+
}
80+
}
81+
return
82+
}
83+
84+
if isTimerRunning {
85+
await timerManager.cancelTimer()
86+
}
87+
}
88+
89+
// (CHA-CS5b) Not withstanding CHA-CS5a. If a connection state event is observed from the underlying realtime library, the client must emit a status change event. The current status of that event shall reflect the status change in the underlying realtime library, along with the accompanying error.
90+
subscription.emit(statusChange)
91+
Task {
92+
// update local state and error
93+
await connectionStatusManager.updateError(to: stateChange.reason)
94+
await connectionStatusManager.updateStatus(to: currentState)
95+
}
96+
}
97+
98+
return subscription
99+
}
100+
}
101+
102+
private final actor TimerManager {
103+
private var currentTask: Task<Void, Never>?
104+
105+
internal func setTimer(interval: TimeInterval, handler: @escaping @Sendable () -> Void) {
106+
cancelTimer()
107+
108+
currentTask = Task {
109+
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
110+
guard !Task.isCancelled else {
111+
return
112+
}
113+
handler()
114+
}
115+
}
116+
117+
internal func cancelTimer() {
118+
currentTask?.cancel()
119+
currentTask = nil
120+
}
121+
122+
internal func hasRunningTask() -> Bool {
123+
currentTask != nil
124+
}
125+
}
126+
127+
private final actor ConnectionStatusManager {
128+
private(set) var status: ConnectionStatus = .disconnected
129+
private(set) var error: ARTErrorInfo?
130+
131+
internal func updateStatus(to newStatus: ConnectionStatus) {
132+
status = newStatus
133+
}
134+
135+
internal func updateError(to newError: ARTErrorInfo?) {
136+
error = newError
137+
}
138+
}

Sources/AblyChat/Dependencies.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@ import Ably
55
/// The `ARTRealtime` class from the ably-cocoa SDK implements this protocol.
66
public protocol RealtimeClientProtocol: ARTRealtimeProtocol, Sendable {
77
associatedtype Channels: RealtimeChannelsProtocol
8+
associatedtype Connection: ConnectionProtocol
89

910
// It’s not clear to me why ARTRealtimeProtocol doesn’t include this property. I briefly tried adding it but ran into compilation failures that it wasn’t immediately obvious how to fix.
1011
var channels: Channels { get }
12+
13+
// TODO: Expose `Connection` on ARTRealtimeProtocol so it can be used from RealtimeClientProtocol - https://github.com/ably-labs/ably-chat-swift/issues/123
14+
var connection: Connection { get }
1115
}
1216

1317
/// Expresses the requirements of the object returned by ``RealtimeClientProtocol.channels``.
@@ -21,6 +25,8 @@ public protocol RealtimeChannelsProtocol: ARTRealtimeChannelsProtocol, Sendable
2125
/// Expresses the requirements of the object returned by ``RealtimeChannelsProtocol.get(_:)``.
2226
public protocol RealtimeChannelProtocol: ARTRealtimeChannelProtocol, Sendable {}
2327

28+
public protocol ConnectionProtocol: ARTConnectionProtocol, Sendable {}
29+
2430
/// Like (a subset of) `ARTRealtimeChannelOptions` but with value semantics. (It’s unfortunate that `ARTRealtimeChannelOptions` doesn’t have a `-copy` method.)
2531
internal struct RealtimeChannelOptions {
2632
internal var modes: ARTChannelMode

0 commit comments

Comments
 (0)