-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
This is based on [1] at aa7455d. It’s generated some questions, which I’ve asked on that PR. I started implementing this as part of #19, before realising that implementing the spec is not the aim of that task. So, putting this work on hold until we pick it up again in #28. So far, only the ATTACH operation is implemented. [1] ably/specification#200 I have since updated with error names @ bcb7390 other stuff done since first version of this commit: - start implementing the low-hanging fruit of DETACH
- Loading branch information
1 parent
9014b0c
commit b54200a
Showing
6 changed files
with
758 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,175 @@ | ||
import Ably | ||
|
||
// TODO: integrate with the rest of the SDK | ||
internal actor RoomLifecycleManager { | ||
/// The interface that the lifecycle manager expects its contributing realtime channels to conform to. | ||
/// | ||
/// We use this instead of the ``RealtimeChannel`` interface as its ``attach`` and ``detach`` methods are `async` instead of using callbacks. This makes it easier to write mocks for (since ``RealtimeChannel`` doesn’t express to the type system that the callbacks it receives need to be `Sendable`, it’s hard to, for example, create a mock that creates a `Task` and then calls the callback from inside this task). | ||
/// | ||
/// We choose to also mark the channel’s mutable state as `async`. This is a way of highlighting that since `ARTRealtimeChannel` mutates this state on a separate thread, it’s possible for this state to have changed since the last time you checked it, or since the last time you performed an operation that might have mutated it, or since the last time you recieved an event informing you that it changed. | ||
internal protocol Contributor: Sendable { | ||
func attach() async throws | ||
func detach() async throws | ||
|
||
var state: ARTRealtimeChannelState { get async } | ||
var errorReason: ARTErrorInfo? { get async } | ||
} | ||
|
||
internal private(set) var current: RoomLifecycle = .initialized | ||
internal private(set) var error: ARTErrorInfo? | ||
|
||
private let logger: InternalLogger | ||
private let contributors: [Contributor] | ||
|
||
internal init(contributors: [Contributor] = [], logger: InternalLogger) { | ||
self.contributors = contributors | ||
self.logger = logger | ||
} | ||
|
||
internal init(forTestingWhatHappensWhenCurrentlyIn current: RoomLifecycle, contributors: [Contributor] = [], logger: InternalLogger) { | ||
self.current = current | ||
self.contributors = contributors | ||
self.logger = logger | ||
} | ||
|
||
// TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36) | ||
private var subscriptions: [Subscription<RoomStatusChange>] = [] | ||
|
||
internal func onChange(bufferingPolicy: BufferingPolicy) -> Subscription<RoomStatusChange> { | ||
let subscription: Subscription<RoomStatusChange> = .init(bufferingPolicy: bufferingPolicy) | ||
subscriptions.append(subscription) | ||
return subscription | ||
} | ||
|
||
/// Updates ``current`` and ``error`` and emits a status change event. | ||
private func changeStatus(to new: RoomLifecycle, error: ARTErrorInfo? = nil) { | ||
logger.log(message: "Transitioning from \(current) to \(new), error \(String(describing: error))", level: .info) | ||
let previous = current | ||
current = new | ||
self.error = error | ||
let statusChange = RoomStatusChange(current: current, previous: previous, error: error) | ||
emitStatusChange(statusChange) | ||
} | ||
|
||
private func emitStatusChange(_ change: RoomStatusChange) { | ||
for subscription in subscriptions { | ||
subscription.emit(change) | ||
} | ||
} | ||
|
||
/// Implements CHA-RL1’s `ATTACH` operation. | ||
internal func performAttachOperation() async throws { | ||
switch current { | ||
case .attached: | ||
// CHA-RL1a | ||
return | ||
case .releasing: | ||
// CHA-RL1b | ||
throw ARTErrorInfo(chatError: .roomIsReleasing) | ||
case .released: | ||
// CHA-RL1c | ||
throw ARTErrorInfo(chatError: .roomIsReleased) | ||
case .initialized, .suspended, .attaching, .detached, .detaching, .failed: | ||
break | ||
} | ||
|
||
// CHA-RL1e | ||
changeStatus(to: .attaching) | ||
|
||
// CHA-RL1f | ||
for contributor in contributors { | ||
do { | ||
logger.log(message: "Attaching contributor \(contributor)", level: .info) | ||
try await contributor.attach() | ||
} catch { | ||
let contributorState = await contributor.state | ||
logger.log(message: "Failed to attach contributor \(contributor), which is now in state \(contributorState), error \(error)", level: .info) | ||
|
||
switch contributorState { | ||
case .suspended: | ||
// CHA-RL1h2 | ||
guard let contributorError = await contributor.errorReason else { | ||
// TODO: something about this | ||
preconditionFailure("Contributor entered SUSPENDED but its errorReason is not set") | ||
} | ||
|
||
let error = ARTErrorInfo(chatError: .channelAttachResultedInSuspended(underlyingError: contributorError)) | ||
changeStatus(to: .suspended, error: error) | ||
|
||
// CHA-RL1h3 | ||
throw contributorError | ||
case .failed: | ||
// CHA-RL1h4 | ||
guard let contributorError = await contributor.errorReason else { | ||
// TODO: something about this | ||
preconditionFailure("Contributor entered FAILED but its errorReason is not set") | ||
} | ||
|
||
let error = ARTErrorInfo(chatError: .channelAttachResultedInFailed(underlyingError: contributorError)) | ||
changeStatus(to: .failed, error: error) | ||
|
||
// CHA-RL1h5 | ||
await detachNonFailedContributors() | ||
|
||
// CHA-RL1h1 | ||
throw contributorError | ||
default: | ||
// TODO: something about this; quite possible due to thread timing stuff | ||
preconditionFailure("Attach failure left contributor in unexpected state \(contributorState)") | ||
} | ||
} | ||
} | ||
|
||
// CHA-RL1g | ||
changeStatus(to: .attached) | ||
} | ||
|
||
/// Implements CHA-RL1h5’s "detach all channels that are not in the FAILED state". | ||
private func detachNonFailedContributors() async { | ||
for contributor in contributors where await (contributor.state) != .failed { | ||
// CHA-RL1h6: Retry until detach succeeds | ||
while true { | ||
do { | ||
logger.log(message: "Detaching non-failed contributor \(contributor)", level: .info) | ||
try await contributor.detach() | ||
break | ||
} catch { | ||
logger.log(message: "Failed to detach non-failed contributor \(contributor), error \(error). Retrying.", level: .info) | ||
// Loop repeats | ||
} | ||
} | ||
} | ||
} | ||
|
||
/// Implements CHA-RL2’s DETACH operation. | ||
internal func performDetachOperation() async throws { | ||
switch current { | ||
case .detached: | ||
// CHA-RL2a | ||
return | ||
case .releasing: | ||
// CHA-RL2b | ||
throw ARTErrorInfo(chatError: .roomIsReleasing) | ||
case .released: | ||
// CHA-RL2c | ||
throw ARTErrorInfo(chatError: .roomIsReleased) | ||
case .failed: | ||
// CHA-RL2d | ||
throw ARTErrorInfo(chatError: .roomInFailedState) | ||
case .initialized, .suspended, .attaching, .attached, .detaching: | ||
break | ||
} | ||
|
||
// CHA-RL2e | ||
changeStatus(to: .detaching) | ||
|
||
// CHA-RL2f | ||
for contributor in contributors { | ||
logger.log(message: "Detaching contributor \(contributor)", level: .info) | ||
try await contributor.detach() | ||
} | ||
|
||
// CHA-RL2g | ||
changeStatus(to: .detached) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
import Ably | ||
@testable import AblyChat | ||
|
||
final actor MockContributor: RoomLifecycleManager.Contributor { | ||
private let attachBehavior: AttachOrDetachBehavior? | ||
private let detachBehavior: AttachOrDetachBehavior? | ||
|
||
private(set) var attachCallCount = 0 | ||
private(set) var detachCallCount = 0 | ||
|
||
init( | ||
attachBehavior: AttachOrDetachBehavior? = nil, | ||
detachBehavior: AttachOrDetachBehavior? = nil | ||
) { | ||
self.attachBehavior = attachBehavior | ||
self.detachBehavior = detachBehavior | ||
} | ||
|
||
var state: ARTRealtimeChannelState = .initialized | ||
var errorReason: ARTErrorInfo? | ||
|
||
enum AttachOrDetachResult { | ||
case success | ||
case failure(ARTErrorInfo) | ||
|
||
func performCallback(_ callback: ARTCallback?) { | ||
switch self { | ||
case .success: | ||
callback?(nil) | ||
case let .failure(error): | ||
callback?(error) | ||
} | ||
} | ||
} | ||
|
||
enum AttachOrDetachBehavior { | ||
/// Receives an argument indicating how many times (including the current call) the method for which this is providing a mock implementation has been called. | ||
case fromFunction(@Sendable (Int) async -> AttachOrDetachResult) | ||
case complete(AttachOrDetachResult) | ||
case completeAndChangeState(AttachOrDetachResult, newState: ARTRealtimeChannelState) | ||
|
||
static var success: Self { | ||
.complete(.success) | ||
} | ||
|
||
static func failure(_ error: ARTErrorInfo) -> Self { | ||
.complete(.failure(error)) | ||
} | ||
} | ||
|
||
func attach() async throws { | ||
attachCallCount += 1 | ||
|
||
guard let attachBehavior else { | ||
fatalError("attachBehavior must be set before attach is called") | ||
} | ||
|
||
try await performBehavior(attachBehavior, callCount: attachCallCount) | ||
} | ||
|
||
func detach() async throws { | ||
detachCallCount += 1 | ||
|
||
guard let detachBehavior else { | ||
fatalError("detachBehavior must be set before detach is called") | ||
} | ||
|
||
try await performBehavior(detachBehavior, callCount: detachCallCount) | ||
} | ||
|
||
private func performBehavior(_ behavior: AttachOrDetachBehavior, callCount: Int) async throws { | ||
let result: AttachOrDetachResult | ||
switch behavior { | ||
case let .fromFunction(function): | ||
result = await function(callCount) | ||
case let .complete(completeResult): | ||
result = completeResult | ||
case let .completeAndChangeState(completeResult, newState): | ||
state = newState | ||
if case let .failure(error) = completeResult { | ||
errorReason = error | ||
} | ||
result = completeResult | ||
} | ||
|
||
switch result { | ||
case .success: | ||
return | ||
case let .failure(error): | ||
throw error | ||
} | ||
} | ||
} |
Oops, something went wrong.