From 4a95c4e11bc5bab0c65566a6a48cd06bffda4d7b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 5 Oct 2026 13:24:21 -0400 Subject: [PATCH] fix(chat): fetch the feed again on foreground when a load ran across a suspension loadFeed() joins a load already in flight, so start(), the launch foreground hook and a reconnect make one fetch. When the app was backgrounded while that load's requests were out, the foreground joined it too, and the feed stayed as the server had it before the app went away until the next trigger. The background hook now marks an in-flight load as interrupted. The next handleForeground() waits that load out and starts a fresh one instead of joining. A launch with no background in between still joins, so dedupesFeedLoads is unchanged. The owner of a load clears feedLoadTask only if it still holds its own task, so it can't drop the one the foreground started. MockConversations now reads the DM feed when the call arrives rather than after feedDelay, as a server would; the new test needs that to hold a stale answer in flight. --- Flipcash/Core/AppDelegate.swift | 1 + .../Controllers/ConversationController.swift | 29 +++++++++++++++++-- .../ConversationControllerTests.swift | 28 ++++++++++++++++++ .../TestSupport/MockConversations.swift | 2 ++ 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/Flipcash/Core/AppDelegate.swift b/Flipcash/Core/AppDelegate.swift index e733640cf..182616a7f 100644 --- a/Flipcash/Core/AppDelegate.swift +++ b/Flipcash/Core/AppDelegate.swift @@ -134,6 +134,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { case .background: logger.info("scenePhase → background") sessionContainer?.session.didEnterBackground() + sessionContainer?.conversationController.handleBackground() container.preferences.appDidEnterBackground() sessionContainer?.pushController.clearBadgeCount() shutDownForBackground() diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index cd73bdbd3..53ee0c450 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -265,6 +265,9 @@ final class ConversationController { @ObservationIgnored private var startTask: Task? /// The feed load in flight, joined by every caller that arrives before it finishes. @ObservationIgnored private var feedLoadTask: Task? + /// Whether the app was backgrounded while `feedLoadTask` was in flight. Its requests went out + /// before suspension, so it answers with what the server held then. + @ObservationIgnored private var feedLoadInterrupted = false /// How long a reconcile waits for the slowest feed, and for unread counts, before applying what landed. @ObservationIgnored private let reconcileTiming: FeedReconcileTiming /// The transcript fetches waiting to run, shared by every feed load. @@ -627,7 +630,25 @@ final class ConversationController { func handleForeground() { reloadFromDatabase() catchUpOpenChat() - Task { await loadFeed() } + // Joining a load that was in flight across a suspension would leave the feed as it was + // before the app went away, so wait that one out and fetch again. + let interrupted = feedLoadInterrupted ? feedLoadTask : nil + feedLoadInterrupted = false + Task { + if let interrupted { + await interrupted.value + if feedLoadTask == interrupted { + feedLoadTask = nil + } + } + await loadFeed() + } + } + + /// Background hook (`AppDelegate` `.background`): marks a feed load still in flight as stale, so + /// the next `handleForeground()` fetches again instead of joining it. + func handleBackground() { + feedLoadInterrupted = feedLoadTask != nil } /// Re-reads every known conversation's transcript and feed preview straight from disk, with no @@ -781,6 +802,7 @@ final class ConversationController { startTask = nil feedLoadTask?.cancel() feedLoadTask = nil + feedLoadInterrupted = false if let extensionStoreWriteToken { ChatStoreWriteNotification.stopObserving(extensionStoreWriteToken) self.extensionStoreWriteToken = nil @@ -879,7 +901,10 @@ final class ConversationController { } feedLoadTask = task await task.value - feedLoadTask = nil + // A foreground that outwaited an interrupted load may already have started the next one. + if feedLoadTask == task { + feedLoadTask = nil + } } private func loadFeeds() async -> [Conversation] { diff --git a/FlipcashTests/ConversationControllerTests.swift b/FlipcashTests/ConversationControllerTests.swift index 669f3fc94..e25d6e0d5 100644 --- a/FlipcashTests/ConversationControllerTests.swift +++ b/FlipcashTests/ConversationControllerTests.swift @@ -1016,6 +1016,34 @@ struct ConversationControllerTests { controller.stop() } + @Test("a foreground after the app was backgrounded mid-load fetches the feed again instead of joining the stale load") + func foregroundAfterBackgroundMidLoadRefetchesFeed() async throws { + let mock = MockConversations() + mock.feed = [Conversation(id: ConversationID.test(1), members: [], lastMessage: nil, lastActivity: Date(timeIntervalSince1970: 100))] + mock.messages = [ConversationMessage(id: MessageID(value: 1), senderID: nil, content: .text("one"), date: Date(timeIntervalSince1970: 10), unreadSeq: 1, eventSequence: 1)] + mock.feedDelay = .milliseconds(300) + let controller = makeController(mock) + controller.visibleConversationID = nil + + controller.start() + // start()'s feed requests are out, holding the feed as it was when they were sent. + try await waitUntil { mock.dmFeedCalls > 0 } + controller.handleBackground() + + mock.feed = [Conversation(id: ConversationID.test(1), members: [], lastMessage: nil, lastActivity: Date(timeIntervalSince1970: 200), latestEventSequence: 2)] + mock.deltaHead = 2 + mock.deltaBatches = [MockConversations.DeltaBatch( + messages: [ConversationMessage(id: MessageID(value: 2), senderID: nil, content: .text("missed while backgrounded"), date: Date(timeIntervalSince1970: 20), unreadSeq: 2, eventSequence: 2)], + checkpoint: 2 + )] + + controller.handleForeground() + + try await waitUntil { controller.messages(for: ConversationID.test(1)).map(\.id.value) == [1, 2] } + #expect(mock.deltaAfterSequences == [1]) + controller.stop() + } + @Test("foreground re-fetches via GetDelta even when the extension already persisted the message to the shared store") func foregroundRefetchesViaGetDeltaEvenWhenExtensionAlreadyPersistedMessage() async throws { let (database, _) = try Database.makeTemp() diff --git a/FlipcashTests/TestSupport/MockConversations.swift b/FlipcashTests/TestSupport/MockConversations.swift index fa1e3ec67..cb73629e9 100644 --- a/FlipcashTests/TestSupport/MockConversations.swift +++ b/FlipcashTests/TestSupport/MockConversations.swift @@ -288,6 +288,8 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con func getDmChatFeed(owner: KeyPair, type: ConversationType) async throws -> [Conversation] { lock.withLock { _dmFeedCalls += 1 } + // Read on arrival: like the server, the answer reflects the state when the request was made. + let feed = feed if feedDelay > .zero { try? await Task.sleep(for: feedDelay) } return feed }