Fence the last in-flight paths across an account change

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.
This commit is contained in:
Arkadiusz Fal
2026-08-28 23:59:48 +02:00
parent 9a93b0347e
commit 360b1ec0cb
4 changed files with 49 additions and 1 deletions

View File

@@ -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, +)

View File

@@ -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)",

View File

@@ -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

View File

@@ -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)