From 8eab79676642b458d76c7d0d22bfbbf8f3bf2c5b Mon Sep 17 00:00:00 2001 From: Jarod Luebbert Date: Wed, 13 Aug 2025 17:06:00 -0700 Subject: [PATCH 1/4] Fix retain cycle in Client --- Sources/XMTPiOS/Client.swift | 12 ++++++------ Sources/XMTPiOS/Conversations.swift | 2 +- Sources/XMTPiOS/Libxmtp/XMTPDebugInformation.swift | 2 +- Sources/XMTPiOS/PrivatePreferences.swift | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Sources/XMTPiOS/Client.swift b/Sources/XMTPiOS/Client.swift index 8af6fd7b..90a563af 100644 --- a/Sources/XMTPiOS/Client.swift +++ b/Sources/XMTPiOS/Client.swift @@ -154,23 +154,23 @@ struct ApiCacheKey { } actor ApiClientCache { - private var apiClientCache: [String: XmtpApiClient] = [:] - private var syncApiClientCache: [String: XmtpApiClient] = [:] + private var apiClientCache: NSMapTable = .weakToWeakObjects() + private var syncApiClientCache: NSMapTable = .weakToWeakObjects() func getClient(forKey key: String) -> XmtpApiClient? { - apiClientCache[key] + apiClientCache.object(forKey: key as NSString) } func setClient(_ client: XmtpApiClient, forKey key: String) { - apiClientCache[key] = client + apiClientCache.setObject(client, forKey: key as NSString) } func getSyncClient(forKey key: String) -> XmtpApiClient? { - syncApiClientCache[key] + syncApiClientCache.object(forKey: key as NSString) } func setSyncClient(_ client: XmtpApiClient, forKey key: String) { - syncApiClientCache[key] = client + syncApiClientCache.setObject(client, forKey: key as NSString) } } diff --git a/Sources/XMTPiOS/Conversations.swift b/Sources/XMTPiOS/Conversations.swift index 20480d2b..12651132 100644 --- a/Sources/XMTPiOS/Conversations.swift +++ b/Sources/XMTPiOS/Conversations.swift @@ -123,7 +123,7 @@ actor FfiStreamActor { /// Handles listing and creating Conversations. public class Conversations { - var client: Client + unowned var client: Client var ffiConversations: FfiConversations var ffiClient: FfiXmtpClient diff --git a/Sources/XMTPiOS/Libxmtp/XMTPDebugInformation.swift b/Sources/XMTPiOS/Libxmtp/XMTPDebugInformation.swift index 9a1970f0..3e8648ac 100644 --- a/Sources/XMTPiOS/Libxmtp/XMTPDebugInformation.swift +++ b/Sources/XMTPiOS/Libxmtp/XMTPDebugInformation.swift @@ -8,7 +8,7 @@ import Foundation public class XMTPDebugInformation { - private let client: Client + private unowned let client: Client private let ffiClient: FfiXmtpClient public init(client: Client, ffiClient: FfiXmtpClient) { diff --git a/Sources/XMTPiOS/PrivatePreferences.swift b/Sources/XMTPiOS/PrivatePreferences.swift index 42f3ca8b..b55b0ae8 100644 --- a/Sources/XMTPiOS/PrivatePreferences.swift +++ b/Sources/XMTPiOS/PrivatePreferences.swift @@ -47,7 +47,7 @@ public struct ConsentRecord: Codable, Hashable { /// Provides access to contact bundles. public actor PrivatePreferences { - var client: Client + unowned var client: Client var ffiClient: FfiXmtpClient init(client: Client, ffiClient: FfiXmtpClient) { From 32dd5741ad4b56c4444889b55d716ddc0c148dd4 Mon Sep 17 00:00:00 2001 From: Jarod Luebbert Date: Wed, 13 Aug 2025 17:44:06 -0700 Subject: [PATCH 2/4] revert ApiClientCache change --- Sources/XMTPiOS/Client.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/XMTPiOS/Client.swift b/Sources/XMTPiOS/Client.swift index 90a563af..55c4ae01 100644 --- a/Sources/XMTPiOS/Client.swift +++ b/Sources/XMTPiOS/Client.swift @@ -154,23 +154,23 @@ struct ApiCacheKey { } actor ApiClientCache { - private var apiClientCache: NSMapTable = .weakToWeakObjects() - private var syncApiClientCache: NSMapTable = .weakToWeakObjects() + private var apiClientCache: [String: XmtpApiClient] = [:] + private var syncApiClientCache: [String: XmtpApiClient] = [:] func getClient(forKey key: String) -> XmtpApiClient? { - apiClientCache.object(forKey: key as NSString) + apiClientCache[key] } func setClient(_ client: XmtpApiClient, forKey key: String) { - apiClientCache.setObject(client, forKey: key as NSString) + apiClientCache[key] = client } func getSyncClient(forKey key: String) -> XmtpApiClient? { - syncApiClientCache.object(forKey: key as NSString) + syncApiClientCache[key] } func setSyncClient(_ client: XmtpApiClient, forKey key: String) { - syncApiClientCache.setObject(client, forKey: key as NSString) + syncApiClientCache[key] = client } } From 36bbbcf662da6e8945f03a3cf08c83e5a526c188 Mon Sep 17 00:00:00 2001 From: cameronvoell Date: Mon, 18 Aug 2025 15:56:45 -0700 Subject: [PATCH 3/4] remove client ref in debug information; make client private in conversations --- Sources/XMTPiOS/Client.swift | 2 +- Sources/XMTPiOS/Conversations.swift | 2 +- Sources/XMTPiOS/Libxmtp/XMTPDebugInformation.swift | 11 ++++++++--- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Sources/XMTPiOS/Client.swift b/Sources/XMTPiOS/Client.swift index 55c4ae01..94ff6148 100644 --- a/Sources/XMTPiOS/Client.swift +++ b/Sources/XMTPiOS/Client.swift @@ -196,7 +196,7 @@ public final class Client { ) public lazy var debugInformation: XMTPDebugInformation = .init( - client: self, ffiClient: ffiClient + historySyncUrl: environment.getHistorySyncUrl(), ffiClient: ffiClient ) static var codecRegistry = CodecRegistry() diff --git a/Sources/XMTPiOS/Conversations.swift b/Sources/XMTPiOS/Conversations.swift index 12651132..722c8894 100644 --- a/Sources/XMTPiOS/Conversations.swift +++ b/Sources/XMTPiOS/Conversations.swift @@ -123,7 +123,7 @@ actor FfiStreamActor { /// Handles listing and creating Conversations. public class Conversations { - unowned var client: Client + private unowned var client: Client var ffiConversations: FfiConversations var ffiClient: FfiXmtpClient diff --git a/Sources/XMTPiOS/Libxmtp/XMTPDebugInformation.swift b/Sources/XMTPiOS/Libxmtp/XMTPDebugInformation.swift index 3e8648ac..3ce6debb 100644 --- a/Sources/XMTPiOS/Libxmtp/XMTPDebugInformation.swift +++ b/Sources/XMTPiOS/Libxmtp/XMTPDebugInformation.swift @@ -8,11 +8,11 @@ import Foundation public class XMTPDebugInformation { - private unowned let client: Client + private let historySyncUrl: String private let ffiClient: FfiXmtpClient - public init(client: Client, ffiClient: FfiXmtpClient) { - self.client = client + public init(historySyncUrl: String, ffiClient: FfiXmtpClient) { + self.historySyncUrl = historySyncUrl self.ffiClient = ffiClient } @@ -31,6 +31,11 @@ public class XMTPDebugInformation { public func clearAllStatistics() { ffiClient.clearAllStatistics() } + + public func uploadDebugInformation(serverUrl: String? = nil) async throws -> String { + let url = serverUrl ?? historySyncUrl + return try await ffiClient.uploadDebugArchive(serverUrl: url) + } } public class ApiStats { From a0ee41bf1c3d711652dd35c0ec15a3d0c36cddc4 Mon Sep 17 00:00:00 2001 From: Jarod Luebbert Date: Tue, 16 Dec 2025 19:40:03 -0800 Subject: [PATCH 4/4] use weak optional for client and error when deallocated --- Sources/XMTPiOS/Client.swift | 5 +- Sources/XMTPiOS/Conversations.swift | 31 +++++++++- Sources/XMTPiOS/PrivatePreferences.swift | 74 ++++++++++++------------ 3 files changed, 68 insertions(+), 42 deletions(-) diff --git a/Sources/XMTPiOS/Client.swift b/Sources/XMTPiOS/Client.swift index 94ff6148..52787587 100644 --- a/Sources/XMTPiOS/Client.swift +++ b/Sources/XMTPiOS/Client.swift @@ -8,6 +8,7 @@ public enum ClientError: Error, CustomStringConvertible, LocalizedError { case creationError(String) case missingInboxId case invalidInboxId(String) + case clientDeallocated public var description: String { switch self { @@ -18,6 +19,8 @@ public enum ClientError: Error, CustomStringConvertible, LocalizedError { case let .invalidInboxId(inboxId): return "Invalid inboxId: \(inboxId). Inbox IDs cannot start with '0x'." + case .clientDeallocated: + return "ClientError.clientDeallocated: The Client has been deallocated." } } @@ -192,7 +195,7 @@ public final class Client { ) public lazy var preferences: PrivatePreferences = .init( - client: self, ffiClient: ffiClient + ffiClient: ffiClient ) public lazy var debugInformation: XMTPDebugInformation = .init( diff --git a/Sources/XMTPiOS/Conversations.swift b/Sources/XMTPiOS/Conversations.swift index 722c8894..f4b5d0fc 100644 --- a/Sources/XMTPiOS/Conversations.swift +++ b/Sources/XMTPiOS/Conversations.swift @@ -123,7 +123,7 @@ actor FfiStreamActor { /// Handles listing and creating Conversations. public class Conversations { - private unowned var client: Client + private weak var client: Client? var ffiConversations: FfiConversations var ffiClient: FfiXmtpClient @@ -136,6 +136,13 @@ public class Conversations { self.ffiClient = ffiClient } + private func requireClient() throws -> Client { + guard let client = client else { + throw ClientError.clientDeallocated + } + return client + } + /// Helper function to convert DisappearingMessageSettings to FfiMessageDisappearingSettings /// Returns nil if the input is nil, making it explicit that nil will be passed to FFI private func toFfiDisappearingMessageSettings(_ settings: DisappearingMessageSettings?) @@ -149,6 +156,7 @@ public class Conversations { } public func findGroup(groupId: String) throws -> Group? { + let client = try requireClient() do { return try Group( ffiGroup: ffiClient.conversation( @@ -164,6 +172,7 @@ public class Conversations { public func findConversation(conversationId: String) async throws -> Conversation? { + let client = try requireClient() do { let conversation = try ffiClient.conversation( conversationId: conversationId.hexToData @@ -177,6 +186,7 @@ public class Conversations { public func findConversationByTopic(topic: String) async throws -> Conversation? { + let client = try requireClient() do { let regexPattern = #"/xmtp/mls/1/g-(.*?)/proto"# if let regex = try? NSRegularExpression(pattern: regexPattern) { @@ -200,6 +210,7 @@ public class Conversations { } public func findDmByInboxId(inboxId: InboxId) throws -> Dm? { + let client = try requireClient() do { let conversation = try ffiClient.dmConversation( targetInboxId: inboxId @@ -215,6 +226,7 @@ public class Conversations { public func findDmByIdentity(publicIdentity: PublicIdentity) async throws -> Dm? { + let client = try requireClient() guard let inboxId = try await client.inboxIdFromIdentity( identity: publicIdentity @@ -288,6 +300,7 @@ public class Conversations { if let limit { options.limit = Int64(limit) } + let client = try requireClient() let conversations = try ffiConversations.listGroups( opts: options ) @@ -321,6 +334,7 @@ public class Conversations { options.limit = Int64(limit) } + let client = try requireClient() let conversations = try ffiConversations.listDms( opts: options ) @@ -353,6 +367,7 @@ public class Conversations { if let limit { options.limit = Int64(limit) } + let client = try requireClient() let ffiConversations = try ffiConversations.list( opts: options ) @@ -373,6 +388,10 @@ public class Conversations { Conversation, Error > { AsyncThrowingStream { continuation in + guard let client = self.client else { + continuation.finish(throwing: ClientError.clientDeallocated) + return + } let ffiStreamActor = FfiStreamActor() let conversationCallback = ConversationStreamCallback { conversation in @@ -387,14 +406,14 @@ public class Conversations { if conversationType == .dm { continuation.yield( Conversation.dm( - conversation.dmFromFFI(client: self.client) + conversation.dmFromFFI(client: client) ) ) } else if conversationType == .group { continuation.yield( Conversation.group( conversation.groupFromFFI( - client: self.client + client: client ) ) ) @@ -456,6 +475,7 @@ public class Conversations { with peerIdentity: PublicIdentity, disappearingMessageSettings: DisappearingMessageSettings? = nil ) async throws -> Dm { + let client = try requireClient() if try await client.inboxState(refreshFromNetwork: false).identities .map(\.identifier).contains(peerIdentity.identifier) { @@ -493,6 +513,7 @@ public class Conversations { ) async throws -> Dm { + let client = try requireClient() if peerInboxId == client.inboxID { throw ConversationError.memberCannotBeSelf } @@ -567,6 +588,7 @@ public class Conversations { disappearingMessageSettings: DisappearingMessageSettings? = nil, appData: String? ) async throws -> Group { + let client = try requireClient() let group = try await ffiConversations.createGroup( accountIdentities: identities.map(\.ffiPrivate), opts: FfiCreateGroupOptions( @@ -641,6 +663,7 @@ public class Conversations { disappearingMessageSettings: DisappearingMessageSettings? = nil, appData: String? ) async throws -> Group { + let client = try requireClient() try validateInboxIds(inboxIds) let group = try await ffiConversations.createGroupWithInboxIds( inboxIds: inboxIds, @@ -667,6 +690,7 @@ public class Conversations { disappearingMessageSettings: DisappearingMessageSettings? = nil, appData: String? = nil ) throws -> Group { + let client = try requireClient() let ffiOpts = FfiCreateGroupOptions( permissions: GroupPermissionPreconfiguration.toFfiGroupPermissionOptions( @@ -786,6 +810,7 @@ public class Conversations { public func fromWelcome(envelopeBytes: Data) async throws -> Conversation? { + let client = try requireClient() let conversations = try await ffiConversations .processStreamedWelcomeMessage(envelopeBytes: envelopeBytes) diff --git a/Sources/XMTPiOS/PrivatePreferences.swift b/Sources/XMTPiOS/PrivatePreferences.swift index b55b0ae8..5207d345 100644 --- a/Sources/XMTPiOS/PrivatePreferences.swift +++ b/Sources/XMTPiOS/PrivatePreferences.swift @@ -47,11 +47,9 @@ public struct ConsentRecord: Codable, Hashable { /// Provides access to contact bundles. public actor PrivatePreferences { - unowned var client: Client var ffiClient: FfiXmtpClient - init(client: Client, ffiClient: FfiXmtpClient) { - self.client = client + init(ffiClient: FfiXmtpClient) { self.ffiClient = ffiClient } @@ -93,22 +91,24 @@ public actor PrivatePreferences { AsyncThrowingStream { continuation in let ffiStreamActor = FfiStreamActor() - let consentCallback = ConsentCallback(client: self.client) { - records in - guard !Task.isCancelled else { - continuation.finish() - Task { - await ffiStreamActor.endStream() + let consentCallback = ConsentCallback( + { records in + guard !Task.isCancelled else { + continuation.finish() + Task { + await ffiStreamActor.endStream() + } + return } - return - } - for consent in records { - continuation.yield(consent.fromFfi) + for consent in records { + continuation.yield(consent.fromFfi) + } + }, + onClose: { + onClose?() + continuation.finish() } - } onClose: { - onClose?() - continuation.finish() - } + ) let task = Task { let stream = await ffiClient.conversations().streamConsent( @@ -132,24 +132,26 @@ public actor PrivatePreferences { AsyncThrowingStream { continuation in let ffiStreamActor = FfiStreamActor() - let preferenceCallback = PreferenceCallback(client: self.client) { - records in - guard !Task.isCancelled else { - continuation.finish() - Task { - await ffiStreamActor.endStream() + let preferenceCallback = PreferenceCallback( + { records in + guard !Task.isCancelled else { + continuation.finish() + Task { + await ffiStreamActor.endStream() + } + return } - return - } - for preference in records { - if case let .hmac(key) = preference { - continuation.yield(.hmac_keys) + for preference in records { + if case let .hmac(key) = preference { + continuation.yield(.hmac_keys) + } } + }, + onClose: { + onClose?() + continuation.finish() } - } onClose: { - onClose?() - continuation.finish() - } + ) let task = Task { let stream = await ffiClient.conversations().streamPreferences( @@ -169,15 +171,13 @@ public actor PrivatePreferences { } final class ConsentCallback: FfiConsentCallback { - let client: Client let callback: ([FfiConsent]) -> Void let onCloseCallback: () -> Void init( - client: Client, _ callback: @escaping ([FfiConsent]) -> Void, + _ callback: @escaping ([FfiConsent]) -> Void, onClose: @escaping () -> Void ) { - self.client = client self.callback = callback onCloseCallback = onClose } @@ -196,15 +196,13 @@ final class ConsentCallback: FfiConsentCallback { } final class PreferenceCallback: FfiPreferenceCallback { - let client: Client let callback: ([FfiPreferenceUpdate]) -> Void let onCloseCallback: () -> Void init( - client: Client, _ callback: @escaping ([FfiPreferenceUpdate]) -> Void, + _ callback: @escaping ([FfiPreferenceUpdate]) -> Void, onClose: @escaping () -> Void ) { - self.client = client self.callback = callback onCloseCallback = onClose }