Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ECO-5109] Spec complete for Connection #125

Merged
merged 1 commit into from
Nov 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Example/AblyChatExample/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ struct ContentView: View {
try await showReactions()
try await showPresence()
try await showOccupancy()
await printConnectionStatusChange()
}
.tryTask {
// NOTE: As we implement more features, move them out of the `if Environment.current == .mock` block and into the main block just above.
Expand All @@ -151,6 +152,17 @@ struct ContentView: View {
}
}

func printConnectionStatusChange() async {
let connectionSubsciption = chatClient.connection.onStatusChange(bufferingPolicy: .unbounded)

// Continue listening for connection status change on a background task so this function can return
Task {
for await status in connectionSubsciption {
print("Connection status changed to: \(status.current)")
}
}
}

func sendButtonAction() {
if newMessage.isEmpty {
Task {
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
}
umair-ably marked this conversation as resolved.
Show resolved Hide resolved
}
69 changes: 69 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,73 @@ final class MockRealtime: NSObject, RealtimeClientProtocol, Sendable {

let channels = Channels()

final class Connection: NSObject, 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
}

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")
}
}
umair-ably marked this conversation as resolved.
Show resolved Hide resolved

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
29 changes: 27 additions & 2 deletions Sources/AblyChat/Connection.swift
Original file line number Diff line number Diff line change
@@ -1,19 +1,44 @@
import Ably

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

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
}
}
umair-ably marked this conversation as resolved.
Show resolved Hide resolved
}

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

// TODO: 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
internal final class DefaultConnection: Connection {
// (CHA-CS2a) The chat client must expose its current connection status.
internal var status: ConnectionStatus {
get async { await connectionStatusManager.status }
}

// (CHA-CS2b) The chat client must expose the latest error, if any, associated with its current status.
internal var error: ARTErrorInfo? {
get async { await connectionStatusManager.error }
}

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

internal init(realtime: any RealtimeClientProtocol) {
// (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.
self.realtime = realtime
Task {
await connectionStatusManager.updateStatus(to: .init(from: realtime.connection.state))
await connectionStatusManager.updateError(to: realtime.connection.errorReason)
}
}
umair-ably marked this conversation as resolved.
Show resolved Hide resolved

// (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)

// (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.

umair-ably marked this conversation as resolved.
Show resolved Hide resolved
// (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)
// update local state and error
await connectionStatusManager.updateError(to: stateChange.reason)
await connectionStatusManager.updateStatus(to: currentState)
}

// (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, connectionStatusManager] 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)

// update local state and error
await connectionStatusManager.updateError(to: stateChange.reason)
await connectionStatusManager.updateStatus(to: currentState)
}
}
return
}
umair-ably marked this conversation as resolved.
Show resolved Hide resolved

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)
Task {
// update local state and error
await connectionStatusManager.updateError(to: stateChange.reason)
await connectionStatusManager.updateStatus(to: currentState)
}
}

return subscription
}
}

private final 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))
guard !Task.isCancelled else {
return
}
handler()
}
}

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

internal func hasRunningTask() -> Bool {
currentTask != nil
}
}

private final actor ConnectionStatusManager {
private(set) var status: ConnectionStatus = .disconnected
private(set) var error: ARTErrorInfo?

internal func updateStatus(to newStatus: ConnectionStatus) {
status = newStatus
}

internal func updateError(to newError: ARTErrorInfo?) {
error = newError
}
}
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 {}

/// Like (a subset of) `ARTRealtimeChannelOptions` but with value semantics. (It’s unfortunate that `ARTRealtimeChannelOptions` doesn’t have a `-copy` method.)
internal struct RealtimeChannelOptions {
internal var modes: ARTChannelMode
Expand Down
Loading