From 360b1ec0cb0663910408c16d121d25a4f96ee8af Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Fri, 28 Aug 2026 23:59:48 +0200 Subject: [PATCH] Fence the last in-flight paths across an account change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A home-feed disk load that was already suspended when the context or Apple account moved no longer installs what it brings back: the in-memory cache is empty by then, so the purge finds nothing to drop and the previous login's private feeds would quietly reappear. Instance-browser pagination carries the login it was requested for, so a page still in flight cannot be appended to the next account's feed, and the pagination state is reset with the content it belongs to. The stateless-server refresh stops before touching anything when its result is stale, rather than only rejecting the videos and going on to set loading state and start polling for subscriptions nobody is showing. Signing in re-resolves its target first. The round trip leaves room for another device to delete the account, and the credential would have been filed under one that no longer exists — reported as success. A provisional account is rolled back when its backend cannot be resolved at all, which is what a vanished instance looks like. --- Yattee/Services/HomeInstanceCache.swift | 13 ++++++++++++ Yattee/Services/SubscriptionFeedCache.swift | 2 ++ .../Views/Instances/InstanceBrowseView.swift | 14 +++++++++++++ Yattee/Views/Settings/EditSourceView.swift | 21 ++++++++++++++++++- 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/Yattee/Services/HomeInstanceCache.swift b/Yattee/Services/HomeInstanceCache.swift index a07e6cfc..1dd753e9 100644 --- a/Yattee/Services/HomeInstanceCache.swift +++ b/Yattee/Services/HomeInstanceCache.swift @@ -69,8 +69,21 @@ final class HomeInstanceCache { return } diskCacheLoaded = true + let generation = accountGeneration if let cacheData = await HomeInstanceDiskCache.shared.load() { + // The context or the Apple account can move while this is + // suspended. The in-memory cache is empty by then, so the purge + // finds nothing to drop and this would quietly reinstate the + // previous login's private feeds. + guard generation == accountGeneration else { + LoggingService.shared.info( + "Discarding a disk cache that belongs to the previous login", + category: .general + ) + return + } + cache = cacheData.videos lastUpdated = cacheData.lastUpdated let totalVideos = cache.values.map { $0.count }.reduce(0, +) diff --git a/Yattee/Services/SubscriptionFeedCache.swift b/Yattee/Services/SubscriptionFeedCache.swift index 510ceff4..d6b1899a 100644 --- a/Yattee/Services/SubscriptionFeedCache.swift +++ b/Yattee/Services/SubscriptionFeedCache.swift @@ -615,6 +615,8 @@ final class SubscriptionFeedCache { instance: instance ) + guard isCurrentAccountsResult(generation) else { return } + let serverVideos = response.toVideos() LoggingService.shared.info( "refreshFromStatelessServer: Received \(serverVideos.count) videos from server, status: \(response.status), ready: \(response.isReady)", diff --git a/Yattee/Views/Instances/InstanceBrowseView.swift b/Yattee/Views/Instances/InstanceBrowseView.swift index f563a27a..b262c6f1 100644 --- a/Yattee/Views/Instances/InstanceBrowseView.swift +++ b/Yattee/Views/Instances/InstanceBrowseView.swift @@ -26,6 +26,9 @@ struct InstanceBrowseView: View { @State private var showFilterSheet = false // Feed state (for Invidious/Piped login) + /// Which login the account-scoped content belongs to, so a page still in + /// flight cannot be appended to the next one's feed. + @State private var accountContentGeneration = 0 @State private var feedVideos: [Video] = [] @State private var feedSubscriptions: [Channel] = [] @@ -1198,9 +1201,13 @@ struct InstanceBrowseView: View { /// /// Public browsing — popular and trending — is unauthenticated and stays. private func discardAccountScopedContent() { + accountContentGeneration += 1 feedVideos = [] feedSubscriptions = [] userPlaylists = [] + feedPage = 1 + hasMoreFeedResults = false + isLoadingMoreFeed = false } private func refreshLoginState() { @@ -1434,12 +1441,19 @@ struct InstanceBrowseView: View { feedLoadedVideoCount = feedVideos.count // Mark current count to prevent re-triggering feedPage += 1 + // The login can change while this page is in flight, and appending + // afterwards would mix one account's private videos into another's + // feed. + let generation = accountContentGeneration + Task { do { let api = InvidiousAPI(httpClient: appEnvironment.httpClient) let feedResponse = try await api.feed(instance: instance, sid: sid, page: feedPage) await MainActor.run { + guard generation == accountContentGeneration else { return } + feedVideos.append(contentsOf: feedResponse.videos) hasMoreFeedResults = feedResponse.hasMore isLoadingMoreFeed = false diff --git a/Yattee/Views/Settings/EditSourceView.swift b/Yattee/Views/Settings/EditSourceView.swift index 582011d2..e85b255a 100644 --- a/Yattee/Views/Settings/EditSourceView.swift +++ b/Yattee/Views/Settings/EditSourceView.swift @@ -411,10 +411,29 @@ private struct EditRemoteServerContent: View { return false } + // Re-resolved rather than trusted: signing in takes a round trip, and + // the account can be deleted on another device while it is happening. + // Writing the credential then would file a secret under an account that + // no longer exists, and report success for it. + if let existing = target.account, appEnvironment.accountsManager.account(id: existing.id) == nil { + appEnvironment.toastManager.showError( + String(localized: "accounts.signIn.storeFailed") + ) + return false + } + let account = target.account ?? appEnvironment.accountsManager.addAccount(instanceID: instance.id) - guard let credentials = appEnvironment.accountCredentialsManager(for: account) else { return false } + guard let credentials = appEnvironment.accountCredentialsManager(for: account) else { + // Its instance may have gone in the meantime, which is what makes + // the backend unresolvable. A provisional account created moments + // ago must not be left behind. + if target.account == nil { + appEnvironment.accountsManager.rollbackAccountCreation(account.id) + } + return false + } credentials.setCredential(credential, for: account)