Skip to content

Commit b72f62a

Browse files
committed
First Pass
1 parent 715df17 commit b72f62a

4 files changed

Lines changed: 54 additions & 38 deletions

File tree

sdks/swift/Sources/DittoChatCore/DittoChat.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,16 @@ public class DittoChat: DittoSwiftChat, ObservableObject {
128128
self.notificationManager = manager
129129

130130
// Whenever the public rooms list changes, reconcile which rooms are being observed.
131+
// Use Task { @MainActor in } rather than .receive(on: DispatchQueue.main) so that
132+
// syncRooms — an @MainActor method — is entered through Swift's structured concurrency
133+
// executor. In Xcode 26.4 / Swift 6.3, calling @MainActor methods via raw GCD from a
134+
// nonisolated Combine sink triggers a runtime actor-isolation crash even when on the
135+
// main thread.
131136
p2pStore.publicRoomsPublisher
132-
.receive(on: DispatchQueue.main)
133137
.sink { [weak manager] rooms in
134-
manager?.syncRooms(rooms)
138+
Task { @MainActor [weak manager] in
139+
manager?.syncRooms(rooms)
140+
}
135141
}
136142
.store(in: &cancellables)
137143

sdks/swift/Sources/DittoChatCore/PushNotifications/ChatNotificationManager.swift

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
// Copyright © 2025 DittoLive Incorporated. All rights reserved.
66
//
77

8-
import DittoSwift
8+
@preconcurrency import DittoSwift
99
import Foundation
1010
import UserNotifications
1111

@@ -84,42 +84,45 @@ final class ChatNotificationManager {
8484
private func startObserving(room: Room) {
8585
guard let ditto, roomObservers[room.id] == nil else { return }
8686

87-
// Query the most recent 50 messages; no attachment tokens needed for text preview.
8887
let query = """
8988
SELECT * FROM `\(room.messagesId)`
9089
WHERE roomId == :roomId
9190
ORDER BY createdOn DESC
9291
LIMIT 50
9392
"""
9493

95-
let roomId = room.id
96-
let roomName = room.name
97-
9894
do {
99-
// deliverOn: .global() — deliver on a background thread so the callback fires
100-
// immediately when Ditto's internal sync engine receives data, without needing
101-
// the main RunLoop to iterate first. This is critical when the app is backgrounded:
102-
// iOS keeps the main RunLoop alive for BLE-mode apps, but delivering directly on a
103-
// global queue means callbacks are not queued behind any pending main-thread work.
104-
//
105-
// Message parsing (compactMap) happens on the background thread; only the actor-
106-
// isolated state mutations hop to .main via DispatchQueue.main.async, which is
107-
// lightweight and safe while the app has any background execution time.
108-
let observer = try ditto.store.registerObserver(
109-
query: query,
110-
arguments: ["roomId": roomId],
111-
deliverOn: .global(qos: .utility)
112-
) { [weak self] result in
113-
// Parse off the main thread — no actor-isolated state touched here.
114-
let messages = result.items.compactMap { Message(value: $0.value) }
115-
// Hop to main for @MainActor state mutations and UNUserNotificationCenter.
116-
DispatchQueue.main.async { [weak self] in
117-
self?.handle(messages: messages, roomId: roomId, roomName: roomName)
118-
}
119-
}
95+
// registerObserver is called from a nonisolated helper so that the closure passed
96+
// to Ditto is created in a nonisolated context. Swift 6.3 injects
97+
// _swift_task_checkIsolatedSwift into the prologue of any closure created inside an
98+
// @MainActor method — even if all captures are nonisolated(unsafe) — crashing when
99+
// Ditto delivers the callback on utility-qos. Moving closure creation into a
100+
// nonisolated method removes that coloring entirely.
101+
let observer = try makeObserver(store: ditto.store, query: query,
102+
roomId: room.id, roomName: room.name)
120103
roomObservers[room.id] = observer
121104
} catch {
122-
print("ChatNotificationManager: failed to register observer for room \(roomId): \(error)")
105+
print("ChatNotificationManager: failed to register observer for room \(room.id): \(error)")
106+
}
107+
}
108+
109+
/// Creates and registers a Ditto store observer from a `nonisolated` context.
110+
/// Closures defined here carry no `@MainActor` coloring, so Swift 6.3 does not inject
111+
/// actor-isolation checks into their prologues.
112+
nonisolated private func makeObserver(
113+
store: DittoStore,
114+
query: String,
115+
roomId: String,
116+
roomName: String
117+
) throws -> DittoStoreObserver {
118+
nonisolated(unsafe) weak var weakSelf: ChatNotificationManager? = self
119+
return try store.registerObserver(query: query, arguments: ["roomId": roomId]) { result in
120+
let messages = result.items.compactMap { Message(value: $0.value) }
121+
DispatchQueue.main.async {
122+
MainActor.assumeIsolated {
123+
weakSelf?.handle(messages: messages, roomId: roomId, roomName: roomName)
124+
}
125+
}
123126
}
124127
}
125128

sdks/swift/Sources/DittoChatCore/Utilities/Concurrency.swift

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@ extension Publisher where Output: Sendable {
2121
) -> Publishers.FlatMap<Future<T, Error>, Self> {
2222
flatMap { value in
2323
Future { promise in
24-
let box = SendableBox(promise)
24+
let vBox = SendableBox(value)
25+
let pBox = SendableBox(promise)
2526
Task {
2627
do {
27-
let output = try await transform(value)
28-
box.value(.success(output))
28+
let output = try await transform(vBox.value)
29+
pBox.value(.success(output))
2930
} catch {
30-
box.value(.failure(error))
31+
pBox.value(.failure(error))
3132
}
3233
}
3334
}
@@ -39,10 +40,11 @@ extension Publisher where Output: Sendable {
3940
) -> Publishers.FlatMap<Future<T, Never>, Self> {
4041
flatMap { value in
4142
Future { promise in
42-
let box = SendableBox(promise)
43+
let vBox = SendableBox(value)
44+
let pBox = SendableBox(promise)
4345
Task {
44-
let output = await transform(value)
45-
box.value(.success(output))
46+
let output = await transform(vBox.value)
47+
pBox.value(.success(output))
4648
}
4749
}
4850
}

sdks/swift/Sources/DittoChatCore/Utilities/Publishers.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ extension DittoStore {
4747
extension DittoStore {
4848

4949
// Send mapped objects as an array
50+
//
51+
// NOTE: Despite `deliverOn: .main` being the default, the Ditto runtime delivers callbacks on
52+
// an internal utility-qos thread in Xcode 26 / Swift 6.3. All callers use the result for
53+
// @MainActor-isolated state (DittoService.allPublicRooms, ViewModel @Published properties),
54+
// so we force delivery onto DispatchQueue.main here rather than relying on the Ditto parameter.
5055
func observePublisher<T: DittoDecodable>(query: String, arguments: [String : Any?]? = nil, deliverOn queue: DispatchQueue = .main, mapTo: T.Type) -> AnyPublisher<[T], Error> {
5156
let subject = PassthroughSubject<[T], Error>()
5257

@@ -59,7 +64,7 @@ extension DittoStore {
5964
subject.send(completion: .failure(error))
6065
}
6166

62-
return subject.eraseToAnyPublisher()
67+
return subject.receive(on: DispatchQueue.main).eraseToAnyPublisher()
6368
}
6469

6570
// Send a mapped object as a single value instead of an array
@@ -76,6 +81,6 @@ extension DittoStore {
7681
subject.send(completion: .failure(error))
7782
}
7883

79-
return subject.eraseToAnyPublisher()
84+
return subject.receive(on: DispatchQueue.main).eraseToAnyPublisher()
8085
}
8186
}

0 commit comments

Comments
 (0)