Skip to content

Commit 321087d

Browse files
authored
Fix join flow: skip stale groups in discovery + add sync fallback (#517)
* Skip self-created single-member groups in discoverNewConversations When consumeInboxOnly() is used for the scanner flow, the pre-created 'New Convo' group exists in XMTP but may be cleaned up from the local DB. discoverNewConversations() would then re-discover it and write it back with isUnused: false, making it visible as 'New Convo' in the list. Skip groups where we are the creator and the only member — these are unused pre-created conversations that should not be re-introduced. * Add discovery loop fallback for join flow The XMTP conversation stream does not call sync_welcomes() at startup (unlike StreamAllMessages), so it cannot discover groups the client is added to after stream subscription. This causes the join flow to get stuck at 'Verifying' indefinitely when the stream fails to deliver. Add requestDiscovery() to SyncingManager that runs syncAllConversations + discoverNewConversations on demand. Thread it through InboxStateMachine and InboxStateManager protocols. In ConversationStateMachine.handleJoin, start a discovery loop alongside the DB observation that periodically syncs welcomes with backoff (3s, 6s, 12s, 15s cap). The DB observation fires as soon as the group is written by discoverNewConversations. The loop is cancelled on success, cancellation, or error. * Remove accidentally committed .mote-preview.html * Add .mote-preview.html to gitignore * Remove investigation and plan docs * Fix duplicate MessagingService creation in messagingServiceSync messagingServiceSync could create duplicate services for the same clientId when called concurrently. The nonisolated getAwakeService check and the async registerExternalService had a race window where two callers would both see nil and both create services. Add getOrSetAwakeService to InboxLifecycleManager that atomically checks the lock-based cache and stores the service if no entry exists. If a duplicate is detected, the redundant service is stopped immediately. * Revert "Fix duplicate MessagingService creation in messagingServiceSync" This reverts commit 630481d. * Stop orphaned duplicate services in registerExternalService When messagingServiceSync is called concurrently for the same clientId, duplicate MessagingService instances can be created before the first one is registered. The second call to registerExternalService finds the inbox already tracked and previously just silently returned, leaving the duplicate's SyncingManager running orphaned streams. Now stop the rejected service so its streams and auth flow are cleaned up. * Address PR feedback: stop duplicate at call site, add deinit cleanup Move duplicate service stop() from registerExternalService to the caller in SessionManager. registerExternalService now returns Bool indicating success — the caller stops the orphan if registration was rejected. This avoids stopping a service that another caller may already be using. Also add discoveryTask?.cancel() to ConversationStateMachine deinit for consistency with other task cleanup.
1 parent 9a0de6e commit 321087d

12 files changed

Lines changed: 163 additions & 7 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,4 @@ qa/cxdb/qa.sqlite
129129
qa/cxdb/qa.sqlite-wal
130130
qa/cxdb/qa.sqlite-shm
131131
.DS_Store
132+
.mote-preview.html

ConvosCore/Sources/ConvosCore/Inboxes/ConversationStateMachine.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ public actor ConversationStateMachine {
9595
// Database observation task for tracking conversation join
9696
private var observationTask: Task<String, Error>?
9797

98+
// Discovery loop task for syncing welcomes during join flow
99+
private var discoveryTask: Task<Void, Never>?
100+
98101
// MARK: - State Observation
99102

100103
private var stateContinuations: [AsyncStream<State>.Continuation] = []
@@ -169,6 +172,7 @@ public actor ConversationStateMachine {
169172
messageStreamContinuation?.finish()
170173
messageProcessingTask?.cancel()
171174
observationTask?.cancel()
175+
discoveryTask?.cancel()
172176
}
173177

174178
private func setupMessageStream() {
@@ -668,12 +672,35 @@ public actor ConversationStateMachine {
668672
inviteTag: invite.invitePayload.tag
669673
)
670674

675+
// The XMTP conversation stream does not call sync_welcomes() on startup,
676+
// so it cannot discover groups the client is added to after subscription.
677+
// Run a discovery loop that periodically syncs welcomes and processes any
678+
// newly discovered groups. The DB observation above fires as soon as the
679+
// group is written to the database by discoverNewConversations.
680+
discoveryTask = Task { [weak self] in
681+
var interval: Duration = .seconds(3)
682+
let maxInterval: Duration = .seconds(15)
683+
while !Task.isCancelled {
684+
do {
685+
try await Task.sleep(for: interval)
686+
try Task.checkCancellation()
687+
Log.info("Join flow: triggering discovery sync...")
688+
await self?.inboxStateManager.requestDiscovery()
689+
interval = min(interval * 2, maxInterval)
690+
} catch {
691+
break
692+
}
693+
}
694+
}
695+
671696
do {
672697
guard let task = observationTask else {
673698
throw ConversationStateMachineError.timedOut
674699
}
675700
let conversationId = try await task.value
676701
observationTask = nil
702+
discoveryTask?.cancel()
703+
discoveryTask = nil
677704

678705
Log.info("Conversation joined successfully: \(conversationId)")
679706
QAEvent.emit(.conversation, "joined", ["id": conversationId])
@@ -691,6 +718,8 @@ public actor ConversationStateMachine {
691718
)))
692719
} catch is CancellationError {
693720
observationTask = nil
721+
discoveryTask?.cancel()
722+
discoveryTask = nil
694723
await clearInviteJoinErrorHandler()
695724
Log.debug("Conversation join observation cancelled")
696725
guard case .joinFailed = _state else {
@@ -699,6 +728,8 @@ public actor ConversationStateMachine {
699728
Log.debug("Already in joinFailed state, not propagating cancellation")
700729
} catch {
701730
observationTask = nil
731+
discoveryTask?.cancel()
732+
discoveryTask = nil
702733
await clearInviteJoinErrorHandler()
703734
Log.error("Error waiting for conversation to join: \(error)")
704735
guard case .joinFailed = _state else {
@@ -757,6 +788,8 @@ extension ConversationStateMachine {
757788
messageProcessingTask?.cancel()
758789
observationTask?.cancel()
759790
observationTask = nil
791+
discoveryTask?.cancel()
792+
discoveryTask = nil
760793

761794
if let conversationId {
762795
let inboxReady = try await inboxStateManager.waitForInboxReadyResult()
@@ -863,6 +896,8 @@ extension ConversationStateMachine {
863896
messageProcessingTask?.cancel()
864897
observationTask?.cancel()
865898
observationTask = nil
899+
discoveryTask?.cancel()
900+
discoveryTask = nil
866901
await clearInviteJoinErrorHandler()
867902
emitStateChange(.uninitialized)
868903
}

ConvosCore/Sources/ConvosCore/Inboxes/InboxLifecycleManager.swift

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ public protocol InboxLifecycleManagerProtocol: Actor {
6060
func getOrCreateService(clientId: String, inboxId: String) -> any MessagingServiceProtocol
6161

6262
func getOrWake(clientId: String, inboxId: String) async throws -> any MessagingServiceProtocol
63-
func registerExternalService(_ service: any MessagingServiceProtocol, clientId: String) async
63+
@discardableResult
64+
func registerExternalService(_ service: any MessagingServiceProtocol, clientId: String) async -> Bool
6465
func isAwake(clientId: String) -> Bool
6566
func isSleeping(clientId: String) -> Bool
6667
func rebalance() async
@@ -352,10 +353,11 @@ public actor InboxLifecycleManager: InboxLifecycleManagerProtocol {
352353
return try await wake(clientId: clientId, inboxId: inboxId, reason: .userInteraction)
353354
}
354355

355-
public func registerExternalService(_ service: any MessagingServiceProtocol, clientId: String) async {
356+
@discardableResult
357+
public func registerExternalService(_ service: any MessagingServiceProtocol, clientId: String) async -> Bool {
356358
guard awakeInboxes[clientId] == nil else {
357359
Log.debug("Inbox already tracked, skipping external registration: \(clientId)")
358-
return
360+
return false
359361
}
360362
if awakeInboxes.count >= maxAwakeInboxes {
361363
let freed = await sleepLeastRecentlyUsed(excluding: [clientId])
@@ -365,12 +367,13 @@ public actor InboxLifecycleManager: InboxLifecycleManagerProtocol {
365367
}
366368
guard awakeInboxes[clientId] == nil else {
367369
Log.debug("Inbox registered by another task during eviction, skipping: \(clientId)")
368-
return
370+
return false
369371
}
370372
awakeInboxes[clientId] = service
371373
_sleepingClientIds.remove(clientId)
372374
_sleepTimes.removeValue(forKey: clientId)
373375
Log.debug("Registered external service: \(clientId), total awake: \(awakeInboxes.count)")
376+
return true
374377
}
375378

376379
public func isAwake(clientId: String) -> Bool {

ConvosCore/Sources/ConvosCore/Inboxes/InboxStateMachine.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,11 @@ public actor InboxStateMachine {
309309
await syncingManager.setInviteJoinErrorHandler(handler)
310310
}
311311

312+
func requestDiscovery() async {
313+
guard let syncingManager else { return }
314+
await syncingManager.requestDiscovery()
315+
}
316+
312317
// MARK: - Private
313318

314319
private func enqueueAction(_ action: Action) {

ConvosCore/Sources/ConvosCore/Inboxes/InboxStateManager.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ public protocol InboxStateManagerProtocol: AnyObject, Sendable {
1515
func waitForDeletionComplete() async
1616
func setInviteJoinErrorHandler(_ handler: (any InviteJoinErrorHandler)?) async throws
1717

18+
func requestDiscovery() async
19+
1820
func addObserver(_ observer: InboxStateObserver)
1921
func removeObserver(_ observer: InboxStateObserver)
2022

@@ -163,6 +165,11 @@ public final class InboxStateManager: InboxStateManagerProtocol, @unchecked Send
163165
await stateMachine.setInviteJoinErrorHandler(handler)
164166
}
165167

168+
public func requestDiscovery() async {
169+
guard let stateMachine else { return }
170+
await stateMachine.requestDiscovery()
171+
}
172+
166173
public func reauthorize(inboxId: String, clientId: String) async throws -> InboxReadyResult {
167174
guard let stateMachine = stateMachine else {
168175
throw InboxStateError.inboxNotReady

ConvosCore/Sources/ConvosCore/Mocks/MockInboxLifecycleManager.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,12 @@ public actor MockInboxLifecycleManager: InboxLifecycleManagerProtocol {
9595
_sleepingClientIds.removeAll()
9696
}
9797

98-
public func registerExternalService(_ service: any MessagingServiceProtocol, clientId: String) async {
98+
@discardableResult
99+
public func registerExternalService(_ service: any MessagingServiceProtocol, clientId: String) async -> Bool {
99100
mockServices[clientId] = service
100101
_awakeClientIds.insert(clientId)
101102
_sleepingClientIds.remove(clientId)
103+
return true
102104
}
103105

104106
public func prepareUnusedConversationIfNeeded() async {}

ConvosCore/Sources/ConvosCore/Mocks/MockInboxStateManager.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ public final class MockInboxStateManager: InboxStateManagerProtocol, @unchecked
4545
public func setInviteJoinErrorHandler(_ handler: (any InviteJoinErrorHandler)?) async throws {
4646
}
4747

48+
public func requestDiscovery() async {
49+
}
50+
4851
public func addObserver(_ observer: any InboxStateObserver) {
4952
observers.removeAll { $0.observer == nil }
5053
observers.append(WeakStateObserver(observer: observer))

ConvosCore/Sources/ConvosCore/Sessions/SessionManager.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,11 @@ public final class SessionManager: SessionManagerProtocol, @unchecked Sendable {
284284
apiClient: apiClient
285285
)
286286
Task { [lifecycleManager] in
287-
await lifecycleManager.registerExternalService(service, clientId: clientId)
287+
let registered = await lifecycleManager.registerExternalService(service, clientId: clientId)
288+
if !registered {
289+
Log.warning("Stopping duplicate MessagingService for \(clientId)")
290+
await service.stop()
291+
}
288292
}
289293
return service
290294
}

ConvosCore/Sources/ConvosCore/Syncing/SyncingManager.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ public protocol SyncingManagerProtocol: Actor {
1010
func stop() async
1111
func pause() async
1212
func resume() async
13+
func requestDiscovery() async
1314
func setInviteJoinErrorHandler(_ handler: (any InviteJoinErrorHandler)?) async
1415
}
1516

@@ -440,6 +441,12 @@ actor SyncingManager: SyncingManagerProtocol {
440441
var discoveredCount: Int = 0
441442
for group in groups where !existingIds.contains(group.id) {
442443
do {
444+
let creatorInboxId = try await group.creatorInboxId()
445+
let memberCount = try await group.members.count
446+
if creatorInboxId == params.client.inboxId && memberCount <= 1 {
447+
Log.debug("Skipping self-created single-member group: \(group.id)")
448+
continue
449+
}
443450
try await streamProcessor.processConversation(group, params: params)
444451
discoveredCount += 1
445452
} catch {
@@ -610,6 +617,22 @@ actor SyncingManager: SyncingManagerProtocol {
610617
await processJoinRequestsAfterSync(params: params)
611618
}
612619

620+
func requestDiscovery() async {
621+
guard case .ready(let params) = _state else {
622+
Log.debug("requestDiscovery ignored - not in ready state (\(_state))")
623+
return
624+
}
625+
do {
626+
let syncStart = CFAbsoluteTimeGetCurrent()
627+
_ = try await params.client.conversationsProvider.syncAllConversations(consentStates: params.consentStates)
628+
let syncElapsed = String(format: "%.0f", (CFAbsoluteTimeGetCurrent() - syncStart) * 1000)
629+
Log.info("[PERF] sync.requestDiscovery: \(syncElapsed)ms")
630+
await discoverNewConversations(params: params)
631+
} catch {
632+
Log.error("requestDiscovery failed: \(error)")
633+
}
634+
}
635+
613636
private func handleStop() async throws {
614637
Log.info("Stopping sync...")
615638
emitStateChange(.stopping)

ConvosCore/Tests/ConvosCoreTests/SleepingInboxMessageCheckerTests.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -649,9 +649,11 @@ actor TestableInboxLifecycleManager: InboxLifecycleManagerProtocol {
649649
fatalError("Not implemented for tests")
650650
}
651651

652-
func registerExternalService(_ service: any MessagingServiceProtocol, clientId: String) async {
652+
@discardableResult
653+
func registerExternalService(_ service: any MessagingServiceProtocol, clientId: String) async -> Bool {
653654
_awakeClientIds.insert(clientId)
654655
_sleepingClientIds.remove(clientId)
656+
return true
655657
}
656658

657659
func prepareUnusedConversationIfNeeded() async {}

0 commit comments

Comments
 (0)