Skip to content

Commit e2591cf

Browse files
Convert public API and internals to @mainactor
In 7d6acde we made the decision to use `actor` types to manage the mutable state of the SDK. Whilst we've been able to build the SDK using this approach, it has proved cumbersome. The public API is littered with `async` methods and getters for things that really should be synchronous, such as adding a subscription or fetching a room's state. This deviates from the JavaScript API and makes call sites look complicated. The use of `async` for fetching room state also means that it's impossible for a user to, for example, check a room's state and then reason about what operations they should be able to attempt to perform on the room (i.e. without the SDK immediately rejecting the attempt), because the room state can change under their feet. This is one of a class of threading-related problems that I describe in #260. Internally, we find ourselves having to write `await` to do basic things like fetching the room lifecycle manager's state or to find out the arguments with which a mock method was called. And similarly, one internal component can't rely on the state that's isolated to a different internal isolation domain in order to make a decision, because this state might change under its feet. To address this, here I have decided to isolate all of the SDK's mutable state to the main actor. This simplifies the experience of developing the SDK, and it simplifies the experience of interacting with it from the main thread, which is where most of our users interact with it from. (Note that this does not prevent us from using background tasks internally for moving heavy processing off the main thread; I'm specifically talking about _state_.) This also moves us closer to being able to address #49 (keeping our internal state in sync with that of the underlying ably-cocoa objects). Note that this change does decrease the parallelisability of our test suite. But it still runs very fast. Have removed: - all `actor` types (both in SDK and mocks) - nearly all locking (there are a couple of non-MainActor types for which we still have it) There might be some simplifications that can be made for the lifecycle manager tests now, but I'm not going to look at them now because that class will change a lot for the single-channel work in #242. I'll update the ably.com documentation in #254. Even after this change, Messages.subscribe is still `async` because its implementation requires it to be. I believe that this is a bug; have created #257 for it. Resolves #232.
1 parent 180ea12 commit e2591cf

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+727
-730
lines changed

CONTRIBUTING.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ To check formatting and code quality, run `swift run BuildTool lint`. Run with `
3939
- Describe the SDK’s functionality via protocols (when doing so would still be sufficiently idiomatic to Swift).
4040
- 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.)
4141
- 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.
42+
- The SDK isolates all of its mutable state to the main actor. Stateful objects should be marked as `@MainActor`.
4243

4344
### Throwing errors
4445

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

68+
### Swift concurrency rough edges
69+
70+
#### `AsyncSequence` operator compiler errors
71+
72+
Consider the following code:
73+
74+
```swift
75+
@MainActor
76+
func myThing() async {
77+
let streamComponents = AsyncStream<Void>.makeStream()
78+
await streamComponents.stream.first { _ in true }
79+
}
80+
```
81+
82+
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").
83+
84+
So the following code compiles, and you'll notice lots of `@Sendable` closures dotted around the codebase for this reason.
85+
86+
```swift
87+
@MainActor
88+
func myThing() async {
89+
let streamComponents = AsyncStream<Void>.makeStream()
90+
await streamComponents.stream.first { @Sendable _ in true }
91+
}
92+
```
93+
94+
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.
95+
6796
### Testing guidelines
6897

6998
#### Exposing test-only APIs

Example/AblyChatExample/ContentView.swift

Lines changed: 27 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ private enum Environment: Equatable {
1212
/// - clientId: A string that identifies this client.
1313
case live(key: String, clientId: String)
1414

15+
@MainActor
1516
func createChatClient() -> ChatClient {
1617
switch self {
1718
case .mock:
@@ -200,14 +201,18 @@ struct ContentView: View {
200201
}
201202
.task {
202203
do {
203-
try await attachRoom()
204-
try await showMessages()
205-
try await showReactions()
206-
try await showPresence()
207-
try await showOccupancy()
208-
try await showTypings()
209-
try await showRoomStatus()
210-
await printConnectionStatusChange()
204+
let room = try await room()
205+
206+
try await room.attach()
207+
try await room.presence.enter(data: ["status": "📱 Online"])
208+
209+
try await showMessages(room: room)
210+
showReactions(room: room)
211+
showPresence(room: room)
212+
try await showOccupancy(room: room)
213+
showTypings(room: room)
214+
showRoomStatus(room: room)
215+
printConnectionStatusChange()
211216
} catch {
212217
print("Failed to initialize room: \(error)") // TODO: replace with logger (+ message to the user?)
213218
}
@@ -231,12 +236,8 @@ struct ContentView: View {
231236
}
232237
}
233238

234-
func attachRoom() async throws {
235-
try await room().attach()
236-
}
237-
238-
func showMessages() async throws {
239-
let messagesSubscription = try await room().messages.subscribe()
239+
func showMessages(room: Room) async throws {
240+
let messagesSubscription = try await room.messages.subscribe()
240241
let previousMessages = try await messagesSubscription.getPreviousMessages(params: .init())
241242

242243
for message in previousMessages.items {
@@ -278,8 +279,8 @@ struct ContentView: View {
278279
}
279280
}
280281

281-
func showReactions() async throws {
282-
let reactionSubscription = try await room().reactions.subscribe()
282+
func showReactions(room: Room) {
283+
let reactionSubscription = room.reactions.subscribe()
283284

284285
// Continue listening for reactions on a background task so this function can return
285286
Task {
@@ -291,12 +292,10 @@ struct ContentView: View {
291292
}
292293
}
293294

294-
func showPresence() async throws {
295-
try await room().presence.enter(data: ["status": "📱 Online"])
296-
295+
func showPresence(room: Room) {
297296
// Continue listening for new presence events on a background task so this function can return
298297
Task {
299-
for await event in try await room().presence.subscribe(events: [.enter, .leave, .update]) {
298+
for await event in room.presence.subscribe(events: [.enter, .leave, .update]) {
300299
withAnimation {
301300
listItems.insert(
302301
.presence(
@@ -311,8 +310,8 @@ struct ContentView: View {
311310
}
312311
}
313312

314-
func showTypings() async throws {
315-
let typingSubscription = try await room().typing.subscribe()
313+
func showTypings(room: Room) {
314+
let typingSubscription = room.typing.subscribe()
316315
// Continue listening for typing events on a background task so this function can return
317316
Task {
318317
for await typing in typingSubscription {
@@ -326,23 +325,23 @@ struct ContentView: View {
326325
}
327326
}
328327

329-
func showOccupancy() async throws {
328+
func showOccupancy(room: Room) async throws {
330329
// Continue listening for occupancy events on a background task so this function can return
331-
let currentOccupancy = try await room().occupancy.get()
330+
let currentOccupancy = try await room.occupancy.get()
332331
withAnimation {
333332
occupancyInfo = "Connections: \(currentOccupancy.presenceMembers) (\(currentOccupancy.connections))"
334333
}
335334

336335
Task {
337-
for await event in try await room().occupancy.subscribe() {
336+
for await event in room.occupancy.subscribe() {
338337
withAnimation {
339338
occupancyInfo = "Connections: \(event.presenceMembers) (\(event.connections))"
340339
}
341340
}
342341
}
343342
}
344343

345-
func printConnectionStatusChange() async {
344+
func printConnectionStatusChange() {
346345
let connectionSubsciption = chatClient.connection.onStatusChange()
347346

348347
// Continue listening for connection status change on a background task so this function can return
@@ -353,10 +352,10 @@ struct ContentView: View {
353352
}
354353
}
355354

356-
func showRoomStatus() async throws {
355+
func showRoomStatus(room: Room) {
357356
// Continue listening for status change events on a background task so this function can return
358357
Task {
359-
for await status in try await room().onStatusChange() {
358+
for await status in room.onStatusChange() {
360359
withAnimation {
361360
if status.current.isAttaching {
362361
statusInfo = "\(status.current)...".capitalized

Example/AblyChatExample/Mocks/MockClients.swift

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import Ably
22
import AblyChat
33

4-
actor MockChatClient: ChatClient {
4+
class MockChatClient: ChatClient {
55
let realtime: RealtimeClient
66
nonisolated let clientOptions: ChatClientOptions
77
nonisolated let rooms: Rooms
@@ -19,7 +19,7 @@ actor MockChatClient: ChatClient {
1919
}
2020
}
2121

22-
actor MockRooms: Rooms {
22+
class MockRooms: Rooms {
2323
let clientOptions: ChatClientOptions
2424
private var rooms = [String: MockRoom]()
2525

@@ -41,27 +41,27 @@ actor MockRooms: Rooms {
4141
}
4242
}
4343

44-
actor MockRoom: Room {
44+
class MockRoom: Room {
4545
private let clientID = "AblyTest"
4646

4747
nonisolated let roomID: String
4848
nonisolated let options: RoomOptions
49+
nonisolated let messages: any Messages
50+
nonisolated let presence: any Presence
51+
nonisolated let reactions: any RoomReactions
52+
nonisolated let typing: any Typing
53+
nonisolated let occupancy: any Occupancy
4954

5055
init(roomID: String, options: RoomOptions) {
5156
self.roomID = roomID
5257
self.options = options
58+
messages = MockMessages(clientID: clientID, roomID: roomID)
59+
presence = MockPresence(clientID: clientID, roomID: roomID)
60+
reactions = MockRoomReactions(clientID: clientID, roomID: roomID)
61+
typing = MockTyping(clientID: clientID, roomID: roomID)
62+
occupancy = MockOccupancy(clientID: clientID, roomID: roomID)
5363
}
5464

55-
nonisolated lazy var messages: any Messages = MockMessages(clientID: clientID, roomID: roomID)
56-
57-
nonisolated lazy var presence: any Presence = MockPresence(clientID: clientID, roomID: roomID)
58-
59-
nonisolated lazy var reactions: any RoomReactions = MockRoomReactions(clientID: clientID, roomID: roomID)
60-
61-
nonisolated lazy var typing: any Typing = MockTyping(clientID: clientID, roomID: roomID)
62-
63-
nonisolated lazy var occupancy: any Occupancy = MockOccupancy(clientID: clientID, roomID: roomID)
64-
6565
var status: RoomStatus = .initialized
6666

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

83-
func onStatusChange(bufferingPolicy _: BufferingPolicy) async -> Subscription<RoomStatusChange> {
83+
func onStatusChange(bufferingPolicy _: BufferingPolicy) -> Subscription<RoomStatusChange> {
8484
.init(mockAsyncSequence: createSubscription())
8585
}
8686
}
8787

88-
actor MockMessages: Messages {
88+
class MockMessages: Messages {
8989
let clientID: String
9090
let roomID: String
9191
let channel: any RealtimeChannelProtocol
@@ -116,7 +116,7 @@ actor MockMessages: Messages {
116116
}, interval: 3)
117117
}
118118

119-
func subscribe(bufferingPolicy _: BufferingPolicy) async -> MessageSubscription {
119+
func subscribe(bufferingPolicy _: BufferingPolicy) -> MessageSubscription {
120120
MessageSubscription(mockAsyncSequence: createSubscription()) { _ in
121121
MockMessagesPaginatedResult(clientID: self.clientID, roomID: self.roomID)
122122
}
@@ -185,7 +185,7 @@ actor MockMessages: Messages {
185185
}
186186
}
187187

188-
actor MockRoomReactions: RoomReactions {
188+
class MockRoomReactions: RoomReactions {
189189
let clientID: String
190190
let roomID: String
191191
let channel: any RealtimeChannelProtocol
@@ -232,7 +232,7 @@ actor MockRoomReactions: RoomReactions {
232232
}
233233
}
234234

235-
actor MockTyping: Typing {
235+
class MockTyping: Typing {
236236
let clientID: String
237237
let roomID: String
238238
let channel: any RealtimeChannelProtocol
@@ -275,7 +275,7 @@ actor MockTyping: Typing {
275275
}
276276
}
277277

278-
actor MockPresence: Presence {
278+
class MockPresence: Presence {
279279
let clientID: String
280280
let roomID: String
281281

@@ -395,7 +395,7 @@ actor MockPresence: Presence {
395395
}
396396
}
397397

398-
actor MockOccupancy: Occupancy {
398+
class MockOccupancy: Occupancy {
399399
let clientID: String
400400
let roomID: String
401401
let channel: any RealtimeChannelProtocol
@@ -415,7 +415,7 @@ actor MockOccupancy: Occupancy {
415415
}, interval: 1)
416416
}
417417

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

@@ -428,7 +428,7 @@ actor MockOccupancy: Occupancy {
428428
}
429429
}
430430

431-
actor MockConnection: Connection {
431+
class MockConnection: Connection {
432432
let status: AblyChat.ConnectionStatus
433433
let error: ARTErrorInfo?
434434

Example/AblyChatExample/Mocks/MockSubscriptionStorage.swift

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,32 @@
11
import Foundation
22

33
// This is copied from ably-chat’s internal class `SubscriptionStorage`.
4-
class MockSubscriptionStorage<Element: Sendable>: @unchecked Sendable {
4+
@MainActor
5+
class MockSubscriptionStorage<Element: Sendable> {
56
// We hold a weak reference to the subscriptions that we create, so that the subscriptions’ termination handlers get called when the user releases their final reference to the subscription.
67
private struct WeaklyHeldSubscription {
78
weak var subscription: MockSubscription<Element>?
89
}
910

10-
/// Access must be synchronised via ``lock``.
1111
private var subscriptions: [UUID: WeaklyHeldSubscription] = [:]
12-
private let lock = NSLock()
1312

1413
// You must not call the `setOnTermination` method of a subscription returned by this function, as it will replace the termination handler set by this function.
1514
func create(randomElement: @escaping @Sendable () -> Element, interval: Double) -> MockSubscription<Element> {
1615
let subscription = MockSubscription<Element>(randomElement: randomElement, interval: interval)
1716
let id = UUID()
18-
19-
lock.withLock {
20-
subscriptions[id] = .init(subscription: subscription)
21-
}
17+
subscriptions[id] = .init(subscription: subscription)
2218

2319
subscription.setOnTermination { [weak self] in
24-
self?.subscriptionDidTerminate(id: id)
20+
Task { @MainActor in
21+
self?.subscriptionDidTerminate(id: id)
22+
}
2523
}
2624

2725
return subscription
2826
}
2927

3028
private func subscriptionDidTerminate(id: UUID) {
31-
lock.withLock {
32-
_ = subscriptions.removeValue(forKey: id)
33-
}
29+
_ = subscriptions.removeValue(forKey: id)
3430
}
3531

3632
func emit(_ element: Element) {

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ let room = try await chatClient.rooms.get(
114114
roomID: "readme-getting-started", options: RoomOptions.allFeaturesEnabled)
115115

116116
// Add a listener to observe changes to the chat rooms status
117-
let statusSubscription = await room.onStatusChange()
117+
let statusSubscription = room.onStatusChange()
118118
Task {
119119
for await status in statusSubscription {
120120
print("Room status changed: \(status.current)")

0 commit comments

Comments
 (0)