Skip to content
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
29 changes: 29 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ To check formatting and code quality, run `swift run BuildTool lint`. Run with `
- Describe the SDK’s functionality via protocols (when doing so would still be sufficiently idiomatic to Swift).
- When defining a `struct` that is emitted by the public API of the library, make sure to define a public memberwise initializer so that users can create one to be emitted by their mocks. (There is no way to make Swift’s autogenerated memberwise initializer public, so you will need to write one yourself. In Xcode, you can do this by clicking at the start of the type declaration and doing Editor → Refactor → Generate Memberwise Initializer.)
- When writing code that implements behaviour specified by the Chat SDK features spec, add a comment that references the identifier of the relevant spec item.
- The SDK isolates all of its mutable state to the main actor. Stateful objects should be marked as `@MainActor`.

### Throwing errors

Expand All @@ -64,6 +65,34 @@ If you haven't worked with typed throws before, be aware of a few sharp edges:
}
```

### Swift concurrency rough edges

#### `AsyncSequence` operator compiler errors

Consider the following code:

```swift
@MainActor
func myThing() async {
let streamComponents = AsyncStream<Void>.makeStream()
await streamComponents.stream.first { _ in true }
}
```

This gives a compiler error "Sending main actor-isolated value of type '(Void) async -> Bool' with later accesses to nonisolated context risks causing data races". This is a minimal reproduction of a similar error that I have come across when trying to use operators on an `AsyncSequence`. I do not understand enough about Swift concurrency to be able to give a good explanation of what's going on here. However, I have noticed that this error goes away if you explicitly mark the operator body as `@Sendable` (my reasoning was "the closure mentions the fact that the closure is main actor-isolated, so what if I make it not be; I think writing `@Sendable` achieves that for reasons I'm not fully sure of").

So the following code compiles, and you'll notice lots of `@Sendable` closures dotted around the codebase for this reason.

```swift
@MainActor
func myThing() async {
let streamComponents = AsyncStream<Void>.makeStream()
await streamComponents.stream.first { @Sendable _ in true }
}
```

I hope that as we understand more about Swift concurrency, we'll have a better understanding of what's going on here and whether this is the right way to fix it.

### Testing guidelines

#### Exposing test-only APIs
Expand Down
80 changes: 33 additions & 47 deletions Example/AblyChatExample/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ private enum Environment: Equatable {
/// - clientId: A string that identifies this client.
case live(key: String, clientId: String)

@MainActor
func createChatClient() -> ChatClient {
switch self {
case .mock:
Expand All @@ -30,7 +31,6 @@ private enum Environment: Equatable {
}
}

@MainActor
struct ContentView: View {
#if os(macOS)
let screenWidth = NSScreen.main?.frame.width ?? 500
Expand All @@ -45,7 +45,6 @@ struct ContentView: View {

@State private var chatClient = Environment.current.createChatClient()

@State private var title = "Room"
@State private var reactions: [Reaction] = []
@State private var newMessage = ""
@State private var typingInfo = ""
Expand Down Expand Up @@ -89,7 +88,7 @@ struct ContentView: View {
var body: some View {
ZStack {
VStack {
Text(title)
Text(roomID)
.font(.headline)
.padding(5)
HStack {
Expand Down Expand Up @@ -200,16 +199,23 @@ struct ContentView: View {
}
}
}
.tryTask {
try await setDefaultTitle()
try await attachRoom()
try await showMessages()
try await showReactions()
try await showPresence()
try await showOccupancy()
try await showTypings()
try await showRoomStatus()
await printConnectionStatusChange()
.task {
do {
let room = try await room()

try await room.attach()
try await room.presence.enter(data: ["status": "📱 Online"])

try await showMessages(room: room)
showReactions(room: room)
showPresence(room: room)
try await showOccupancy(room: room)
showTypings(room: room)
showRoomStatus(room: room)
printConnectionStatusChange()
} catch {
print("Failed to initialize room: \(error)") // TODO: replace with logger (+ message to the user?)
}
}
}

Expand All @@ -230,16 +236,8 @@ struct ContentView: View {
}
}

func setDefaultTitle() async throws {
title = try await "\(room().roomID)"
}

func attachRoom() async throws {
try await room().attach()
}

func showMessages() async throws {
let messagesSubscription = try await room().messages.subscribe()
func showMessages(room: Room) async throws {
let messagesSubscription = try await room.messages.subscribe()
let previousMessages = try await messagesSubscription.getPreviousMessages(params: .init())

for message in previousMessages.items {
Expand Down Expand Up @@ -281,8 +279,8 @@ struct ContentView: View {
}
}

func showReactions() async throws {
let reactionSubscription = try await room().reactions.subscribe()
func showReactions(room: Room) {
let reactionSubscription = room.reactions.subscribe()

// Continue listening for reactions on a background task so this function can return
Task {
Expand All @@ -294,12 +292,10 @@ struct ContentView: View {
}
}

func showPresence() async throws {
try await room().presence.enter(data: ["status": "📱 Online"])

func showPresence(room: Room) {
// Continue listening for new presence events on a background task so this function can return
Task {
for await event in try await room().presence.subscribe(events: [.enter, .leave, .update]) {
for await event in room.presence.subscribe(events: [.enter, .leave, .update]) {
withAnimation {
listItems.insert(
.presence(
Expand All @@ -314,8 +310,8 @@ struct ContentView: View {
}
}

func showTypings() async throws {
let typingSubscription = try await room().typing.subscribe()
func showTypings(room: Room) {
let typingSubscription = room.typing.subscribe()
// Continue listening for typing events on a background task so this function can return
Task {
for await typing in typingSubscription {
Expand All @@ -329,23 +325,23 @@ struct ContentView: View {
}
}

func showOccupancy() async throws {
func showOccupancy(room: Room) async throws {
// Continue listening for occupancy events on a background task so this function can return
let currentOccupancy = try await room().occupancy.get()
let currentOccupancy = try await room.occupancy.get()
withAnimation {
occupancyInfo = "Connections: \(currentOccupancy.presenceMembers) (\(currentOccupancy.connections))"
}

Task {
for await event in try await room().occupancy.subscribe() {
for await event in room.occupancy.subscribe() {
withAnimation {
occupancyInfo = "Connections: \(event.presenceMembers) (\(event.connections))"
}
}
}
}

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

// Continue listening for connection status change on a background task so this function can return
Expand All @@ -356,10 +352,10 @@ struct ContentView: View {
}
}

func showRoomStatus() async throws {
func showRoomStatus(room: Room) {
// Continue listening for status change events on a background task so this function can return
Task {
for await status in try await room().onStatusChange() {
for await status in room.onStatusChange() {
withAnimation {
if status.current.isAttaching {
statusInfo = "\(status.current)...".capitalized
Expand Down Expand Up @@ -502,16 +498,6 @@ extension PresenceEventType {
}

extension View {
nonisolated func tryTask(priority: TaskPriority = .userInitiated, _ action: @escaping @Sendable () async throws -> Void) -> some View {
task(priority: priority) {
do {
try await action()
} catch {
print("Action can't be performed: \(error)") // TODO: replace with logger (+ message to the user?)
}
}
}

func flip() -> some View {
rotationEffect(.radians(.pi))
.scaleEffect(x: -1, y: 1, anchor: .center)
Expand Down
44 changes: 22 additions & 22 deletions Example/AblyChatExample/Mocks/MockClients.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Ably
import AblyChat

actor MockChatClient: ChatClient {
class MockChatClient: ChatClient {
let realtime: RealtimeClient
nonisolated let clientOptions: ChatClientOptions
nonisolated let rooms: Rooms
Expand All @@ -19,7 +19,7 @@ actor MockChatClient: ChatClient {
}
}

actor MockRooms: Rooms {
class MockRooms: Rooms {
let clientOptions: ChatClientOptions
private var rooms = [String: MockRoom]()

Expand All @@ -41,27 +41,27 @@ actor MockRooms: Rooms {
}
}

actor MockRoom: Room {
class MockRoom: Room {
private let clientID = "AblyTest"

nonisolated let roomID: String
nonisolated let options: RoomOptions
nonisolated let messages: any Messages
nonisolated let presence: any Presence
nonisolated let reactions: any RoomReactions
nonisolated let typing: any Typing
nonisolated let occupancy: any Occupancy

init(roomID: String, options: RoomOptions) {
self.roomID = roomID
self.options = options
messages = MockMessages(clientID: clientID, roomID: roomID)
presence = MockPresence(clientID: clientID, roomID: roomID)
reactions = MockRoomReactions(clientID: clientID, roomID: roomID)
typing = MockTyping(clientID: clientID, roomID: roomID)
occupancy = MockOccupancy(clientID: clientID, roomID: roomID)
}

nonisolated lazy var messages: any Messages = MockMessages(clientID: clientID, roomID: roomID)

nonisolated lazy var presence: any Presence = MockPresence(clientID: clientID, roomID: roomID)

nonisolated lazy var reactions: any RoomReactions = MockRoomReactions(clientID: clientID, roomID: roomID)

nonisolated lazy var typing: any Typing = MockTyping(clientID: clientID, roomID: roomID)

nonisolated lazy var occupancy: any Occupancy = MockOccupancy(clientID: clientID, roomID: roomID)

var status: RoomStatus = .initialized

private let mockSubscriptions = MockSubscriptionStorage<RoomStatusChange>()
Expand All @@ -80,12 +80,12 @@ actor MockRoom: Room {
}, interval: 8)
}

func onStatusChange(bufferingPolicy _: BufferingPolicy) async -> Subscription<RoomStatusChange> {
func onStatusChange(bufferingPolicy _: BufferingPolicy) -> Subscription<RoomStatusChange> {
.init(mockAsyncSequence: createSubscription())
}
}

actor MockMessages: Messages {
class MockMessages: Messages {
let clientID: String
let roomID: String
let channel: any RealtimeChannelProtocol
Expand Down Expand Up @@ -116,7 +116,7 @@ actor MockMessages: Messages {
}, interval: 3)
}

func subscribe(bufferingPolicy _: BufferingPolicy) async -> MessageSubscription {
func subscribe(bufferingPolicy _: BufferingPolicy) -> MessageSubscription {
MessageSubscription(mockAsyncSequence: createSubscription()) { _ in
MockMessagesPaginatedResult(clientID: self.clientID, roomID: self.roomID)
}
Expand Down Expand Up @@ -185,7 +185,7 @@ actor MockMessages: Messages {
}
}

actor MockRoomReactions: RoomReactions {
class MockRoomReactions: RoomReactions {
let clientID: String
let roomID: String
let channel: any RealtimeChannelProtocol
Expand Down Expand Up @@ -232,7 +232,7 @@ actor MockRoomReactions: RoomReactions {
}
}

actor MockTyping: Typing {
class MockTyping: Typing {
let clientID: String
let roomID: String
let channel: any RealtimeChannelProtocol
Expand Down Expand Up @@ -275,7 +275,7 @@ actor MockTyping: Typing {
}
}

actor MockPresence: Presence {
class MockPresence: Presence {
let clientID: String
let roomID: String

Expand Down Expand Up @@ -395,7 +395,7 @@ actor MockPresence: Presence {
}
}

actor MockOccupancy: Occupancy {
class MockOccupancy: Occupancy {
let clientID: String
let roomID: String
let channel: any RealtimeChannelProtocol
Expand All @@ -415,7 +415,7 @@ actor MockOccupancy: Occupancy {
}, interval: 1)
}

func subscribe(bufferingPolicy _: BufferingPolicy) async -> Subscription<OccupancyEvent> {
func subscribe(bufferingPolicy _: BufferingPolicy) -> Subscription<OccupancyEvent> {
.init(mockAsyncSequence: createSubscription())
}

Expand All @@ -428,7 +428,7 @@ actor MockOccupancy: Occupancy {
}
}

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

Expand Down
Loading