Skip to content

Commit

Permalink
Spec complete for Connection in line with [1] @ commit bf536c8
Browse files Browse the repository at this point in the history
[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.
  • Loading branch information
umair-ably committed Nov 19, 2024
1 parent 82cb9ff commit 7509486
Show file tree
Hide file tree
Showing 10 changed files with 372 additions and 9 deletions.
16 changes: 16 additions & 0 deletions Example/AblyChatExample/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ struct ContentView: View {
.tryTask { try await showReactions() }
.tryTask { try await showPresence() }
.tryTask { try await showOccupancy() }
.tryTask { try await printConnectionStatusChange() }
.tryTask {
// NOTE: As we implement more features, move them out of the `if mode == .mock` block and into the main block just above.
if mode == .mock {
Expand All @@ -144,6 +145,21 @@ struct ContentView: View {
}
}
}

func printConnectionStatusChange() async throws {
Task {
var sub: Subscription<ConnectionStatusChange>
if mode == .mock {
sub = mockChatClient.connection.onStatusChange(bufferingPolicy: .unbounded)
} else {
sub = liveChatClient.connection.onStatusChange(bufferingPolicy: .unbounded)
}

for await status in sub {
print("Connection status changed to: \(status.current)")
}
}
}

func sendButtonAction() {
if newMessage.isEmpty {
Expand Down
24 changes: 20 additions & 4 deletions Example/AblyChatExample/Mocks/MockClients.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,15 @@ actor MockChatClient: ChatClient {
let realtime: RealtimeClient
nonisolated let clientOptions: ClientOptions
nonisolated let rooms: Rooms
nonisolated let connection: Connection

init(realtime: RealtimeClient, clientOptions: ClientOptions?) {
self.realtime = realtime
self.clientOptions = clientOptions ?? .init()
connection = MockConnection(status: .connected, error: nil)
rooms = MockRooms(clientOptions: self.clientOptions)
}

nonisolated var connection: any Connection {
fatalError("Not yet implemented")
}

nonisolated var clientID: String {
fatalError("Not yet implemented")
}
Expand Down Expand Up @@ -387,3 +385,21 @@ actor MockOccupancy: Occupancy {
fatalError("Not yet implemented")
}
}

actor MockConnection: Connection {
let status: AblyChat.ConnectionStatus
let error: ARTErrorInfo?

nonisolated func onStatusChange(bufferingPolicy _: BufferingPolicy) -> Subscription<ConnectionStatusChange> {
let mockSub = MockSubscription<ConnectionStatusChange>(randomElement: {
ConnectionStatusChange(current: .connecting, previous: .connected, retryIn: 1)
}, interval: 5)

return Subscription(mockAsyncSequence: mockSub)
}

init(status: AblyChat.ConnectionStatus, error: ARTErrorInfo?) {
self.status = status
self.error = error
}
}
118 changes: 118 additions & 0 deletions Example/AblyChatExample/Mocks/MockRealtime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import AblyChat

/// 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
final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable {
let connection = Connection()

var device: ARTLocalDevice {
fatalError("Not implemented")
}
Expand All @@ -13,6 +15,122 @@ final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable {

let channels = Channels()

final class Connection: ConnectionProtocol {
init(id: String? = nil, key: String? = nil, maxMessageSize: Int = 0, state: ARTRealtimeConnectionState = .closed, errorReason: ARTErrorInfo? = nil, recoveryKey: String? = nil) {
self.id = id
self.key = key
self.maxMessageSize = maxMessageSize
self.state = state
self.errorReason = errorReason
self.recoveryKey = recoveryKey
hash = 0
superclass = nil
description = ""
}

let id: String?

let key: String?

let maxMessageSize: Int

let state: ARTRealtimeConnectionState

let errorReason: ARTErrorInfo?

let recoveryKey: String?

func createRecoveryKey() -> String? {
fatalError("Not implemented")
}

func connect() {
fatalError("Not implemented")
}

func close() {
fatalError("Not implemented")
}

func ping(_: @escaping ARTCallback) {
fatalError("Not implemented")
}

func on(_: ARTRealtimeConnectionEvent, callback _: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener {
fatalError("Not implemented")
}

func on(_: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener {
fatalError("Not implemented")
}

func once(_: ARTRealtimeConnectionEvent, callback _: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener {
fatalError("Not implemented")
}

func once(_: @escaping (ARTConnectionStateChange) -> Void) -> ARTEventListener {
fatalError("Not implemented")
}

func off(_: ARTRealtimeConnectionEvent, listener _: ARTEventListener) {
fatalError("Not implemented")
}

func off(_: ARTEventListener) {
fatalError("Not implemented")
}

func off() {
fatalError("Not implemented")
}

func isEqual(_: Any?) -> Bool {
fatalError("Not implemented")
}

let hash: Int

let superclass: AnyClass?

func `self`() -> Self {
fatalError("Not implemented")
}

func perform(_: Selector!) -> Unmanaged<AnyObject>! {
fatalError("Not implemented")
}

func perform(_: Selector!, with _: Any!) -> Unmanaged<AnyObject>! {
fatalError("Not implemented")
}

func perform(_: Selector!, with _: Any!, with _: Any!) -> Unmanaged<AnyObject>! {
fatalError("Not implemented")
}

func isProxy() -> Bool {
fatalError("Not implemented")
}

func isKind(of _: AnyClass) -> Bool {
fatalError("Not implemented")
}

func isMember(of _: AnyClass) -> Bool {
fatalError("Not implemented")
}

func conforms(to _: Protocol) -> Bool {
fatalError("Not implemented")
}

func responds(to _: Selector!) -> Bool {
fatalError("Not implemented")
}

let description: String
}

final class Channels: RealtimeChannelsProtocol {
func get(_: String, options _: ARTRealtimeChannelOptions) -> MockRealtime.Channel {
fatalError("Not implemented")
Expand Down
2 changes: 2 additions & 0 deletions Sources/AblyChat/AblyCocoaExtensions/Ably+Dependencies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ extension ARTRealtime: RealtimeClientProtocol {}
extension ARTRealtimeChannels: RealtimeChannelsProtocol {}

extension ARTRealtimeChannel: RealtimeChannelProtocol {}

extension ARTConnection: ConnectionProtocol {}
9 changes: 5 additions & 4 deletions Sources/AblyChat/ChatClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,17 @@ public actor DefaultChatClient: ChatClient {
public nonisolated let rooms: Rooms
private let logger: InternalLogger

// (CHA-CS1) Every chat client has a status, which describes the current status of the connection.
// (CHA-CS4) The chat client must allow its connection status to be observed by clients.
public nonisolated let connection: any Connection

public init(realtime: RealtimeClient, clientOptions: ClientOptions?) {
self.realtime = realtime
self.clientOptions = clientOptions ?? .init()
logger = DefaultInternalLogger(logHandler: self.clientOptions.logHandler, logLevel: self.clientOptions.logLevel)
let roomFactory = DefaultRoomFactory()
rooms = DefaultRooms(realtime: realtime, clientOptions: self.clientOptions, logger: logger, roomFactory: roomFactory)
}

public nonisolated var connection: any Connection {
fatalError("Not yet implemented")
connection = DefaultConnection(realtime: realtime)
}

public nonisolated var clientID: String {
Expand Down
25 changes: 25 additions & 0 deletions Sources/AblyChat/Connection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,37 @@ public protocol Connection: AnyObject, Sendable {
}

public enum ConnectionStatus: Sendable {
// (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.
case initialized
// (CHA-CS1b) The CONNECTING status is used when the client is in the process of connecting to Ably servers.
case connecting
// (CHA-CS1c) The CONNECTED status is used when the client connected to Ably servers.
case connected
// (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.
case disconnected
// (CHA-CS1e) The SUSPENDED status is used when the client is in an extended state of disconnection, but will attempt to reconnect.
case suspended
// (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.
case failed

internal init(from realtimeConnectionState: ARTRealtimeConnectionState) {
switch realtimeConnectionState {
case .initialized:
self = .initialized
case .connecting:
self = .connecting
case .connected:
self = .connected
case .disconnected:
self = .disconnected
case .suspended:
self = .suspended
case .failed, .closing, .closed:
self = .failed
@unknown default:
self = .failed
}
}
}

public struct ConnectionStatusChange: Sendable {
Expand Down
104 changes: 104 additions & 0 deletions Sources/AblyChat/DefaultConnection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import Ably

internal final class DefaultConnection: Connection {
// (CHA-CS2a) The chat client must expose its current connection status, a single value from the list in CHA-CS1.
internal let status: ConnectionStatus
// (CHA-CS2b) The chat client must expose the latest error, if any, associated with its current status.
internal let error: ARTErrorInfo?

private let realtime: any RealtimeClientProtocol
private let timerManager = TimerManager()

internal init(realtime: any RealtimeClientProtocol) {
self.realtime = realtime
// (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.
status = .init(from: realtime.connection.state)
error = realtime.connection.errorReason
}

// (CHA-CS4d) Clients must be able to register a listener for connection status events and receive such events.
internal func onStatusChange(bufferingPolicy: BufferingPolicy) -> Subscription<ConnectionStatusChange> {
let subscription = Subscription<ConnectionStatusChange>(bufferingPolicy: bufferingPolicy)

// (CHA-CS5) The chat client must monitor the underlying realtime connection for connection status changes.
realtime.connection.on { [weak self] stateChange in
guard let self else {
return
}
let currentState = ConnectionStatus(from: stateChange.current)
let previousState = ConnectionStatus(from: stateChange.previous)
if currentState == status {
return
}

// (CHA-CS4a) Connection status update events must contain the newly entered connection status.
// (CHA-CS4b) Connection status update events must contain the previous connection status.
// (CHA-CS4c) Connection status update events must contain the connection error (if any) that pertains to the newly entered connection status.
let statusChange = ConnectionStatusChange(
current: currentState,
previous: previousState,
error: stateChange.reason,
retryIn: stateChange.retryIn
)

Task {
let isTimerRunning = await timerManager.hasRunningTask()
// (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.

// (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.
if isTimerRunning, currentState == .disconnected || currentState == .connecting {
return
}

// (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.
if isTimerRunning, currentState == .connected || currentState == .suspended || currentState == .failed {
await timerManager.cancelTimer()
subscription.emit(statusChange)
}

// (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.
if previousState == .connected, currentState == .disconnected, !isTimerRunning {
await timerManager.setTimer(interval: 5.0) { [timerManager] in
Task {
// (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.
await timerManager.cancelTimer()
subscription.emit(statusChange)
}
}
return
}

if isTimerRunning {
await timerManager.cancelTimer()
}
}

// (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.
subscription.emit(statusChange)
}

return subscription
}
}

internal actor TimerManager {
private var currentTask: Task<Void, Never>?

internal func setTimer(interval: TimeInterval, handler: @escaping @Sendable () -> Void) {
cancelTimer()

currentTask = Task {
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
handler()
}
}

internal func cancelTimer() {
currentTask?.cancel()
currentTask = nil
}

internal func hasRunningTask() -> Bool {
currentTask != nil
}
}
6 changes: 6 additions & 0 deletions Sources/AblyChat/Dependencies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ import Ably
/// The `ARTRealtime` class from the ably-cocoa SDK implements this protocol.
public protocol RealtimeClientProtocol: ARTRealtimeProtocol, Sendable {
associatedtype Channels: RealtimeChannelsProtocol
associatedtype Connection: ConnectionProtocol

// 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.
var channels: Channels { get }

// TODO: Expose `Connection` on ARTRealtimeProtocol so it can be used from RealtimeClientProtocol - https://github.com/ably-labs/ably-chat-swift/issues/123
var connection: Connection { get }
}

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

public protocol ConnectionProtocol: ARTConnectionProtocol, Sendable {}

internal extension RealtimeClientProtocol {
// Function to get the channel with merged options
func getChannel(_ name: String, opts: ARTRealtimeChannelOptions? = nil) -> any RealtimeChannelProtocol {
Expand Down
Loading

0 comments on commit 7509486

Please sign in to comment.