Tie verdicts and context uploads to what asked for them

A server outcome is recorded only for the secret the request actually
used. Signing in again, or a switch of Apple account, replaces it under
the same key while a request is still out — so a late 401 could reject
the replacement, and a late success could clear a rejection it never
earned.

The context record is built only while an upload is actually owed.
Correcting away from a deleted account clears that flag deliberately;
without checking it, the engine could still materialize the automatic
fallback and publish it under the deleted choice's timestamp, over a
deliberate choice made elsewhere.

Confirming a context upload re-runs the outbox like an account
confirmation does. Choosing a different context while the previous one
was in flight leaves the new one dirty by design, and nothing was left
to send it.
This commit is contained in:
Arkadiusz Fal
2026-08-28 23:39:00 +02:00
parent 1c0a3e8e9a
commit 8ee0e38fbd
3 changed files with 38 additions and 19 deletions

View File

@@ -612,7 +612,11 @@ final class AccountSyncCoordinator {
return mapper.toCKRecord(accountDeletionID: accountID)
case .dataContext:
guard accountsManager.canUploadContext else { return nil }
// The pending flag, not just the ordering: correcting away from a
// deleted account clears it deliberately, and sending that
// automatic fallback would publish it under the deleted choice's
// timestamp — over a deliberate choice made elsewhere.
guard accountsManager.pendingContextUpload, accountsManager.canUploadContext else { return nil }
return mapper.toCKRecord(dataContext: accountsManager.storedContext)
case nil:
@@ -623,13 +627,13 @@ final class AccountSyncCoordinator {
/// Clears outbox entries the server has confirmed, and releases whatever was
/// waiting on them.
func didSave(records: [CKRecord]) {
var didConfirmAccount = false
var didSaveAccountRecord = false
for record in records {
switch AccountRecordName.parse(record.recordID.recordName) {
case let .account(id):
accountsManager.confirmUpload(id, updatedAt: record["updatedAt"] as? Date)
didConfirmAccount = true
didSaveAccountRecord = true
case let .legacyMigration(instanceID):
accountsManager.confirmMarkerUpload(instanceID: instanceID)
@@ -644,14 +648,17 @@ final class AccountSyncCoordinator {
if let changedAt = record["changedAt"] as? Date {
accountsManager.confirmContextUpload(changedAt: changedAt)
}
didSaveAccountRecord = true
case nil:
break
}
}
// A marker or a context held behind one of these accounts can go now.
if didConfirmAccount {
// Whatever was held behind these can go now — a marker waiting on its
// account, or a context the user changed while the previous one was
// still in flight, which confirmation deliberately left dirty.
if didSaveAccountRecord {
enqueueDirtyRecords()
}
}

View File

@@ -27,7 +27,13 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider {
/// Records the outcome of a server call against the account. The
/// classification lives in one place so the feed cache, which talks to the
/// same servers without going through a provider, reaches the same verdict.
private func recordOutcome(_ error: Error?) {
private func recordOutcome(_ error: Error?, credential: String) {
// Only for the secret the request actually used. Signing in again — or
// a switch of Apple account — replaces it under the same key while a
// request is still out, and a late 401 would otherwise reject the
// replacement, or a late success clear a rejection it never earned.
guard credentialsManager.credential(for: account) == credential else { return }
accountsManager.recordServerOutcome(error, for: account)
}
private let settingsManager: SettingsManager
@@ -63,7 +69,7 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider {
let (instance, sid) = try await getAuthenticatedInstance()
// Fetch subscriptions from Invidious
let subscriptions = try await tracking {
let subscriptions = try await tracking(credential: sid) {
try await invidiousAPI.subscriptions(instance: instance, sid: sid)
}
@@ -81,7 +87,7 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider {
let (instance, sid) = try await getAuthenticatedInstance()
// Subscribe on Invidious server
try await tracking {
try await tracking(credential: sid) {
try await invidiousAPI.subscribe(to: channel.id.channelID, instance: instance, sid: sid)
}
@@ -100,7 +106,7 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider {
// Unsubscribe on Invidious server. Tracked like every other
// authenticated call: a rejected credential noticed here must mark the
// account, or it keeps reporting itself as signed in.
try await tracking {
try await tracking(credential: sid) {
try await invidiousAPI.unsubscribe(from: channelID, instance: instance, sid: sid)
}
@@ -137,13 +143,13 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider {
/// Gets the authenticated Invidious instance and session ID from account settings.
/// Runs a server call and records what it implies about the credential.
private func tracking<T>(_ operation: () async throws -> T) async rethrows -> T {
private func tracking<T>(credential: String, _ operation: () async throws -> T) async rethrows -> T {
do {
let value = try await operation()
recordOutcome(nil)
recordOutcome(nil, credential: credential)
return value
} catch {
recordOutcome(error)
recordOutcome(error, credential: credential)
throw error
}
}

View File

@@ -27,7 +27,13 @@ final class PipedSubscriptionProvider: SubscriptionProvider {
/// Records the outcome of a server call against the account. The
/// classification lives in one place so the feed cache, which talks to the
/// same servers without going through a provider, reaches the same verdict.
private func recordOutcome(_ error: Error?) {
private func recordOutcome(_ error: Error?, credential: String) {
// Only for the secret the request actually used. Signing in again — or
// a switch of Apple account — replaces it under the same key while a
// request is still out, and a late 401 would otherwise reject the
// replacement, or a late success clear a rejection it never earned.
guard credentialsManager.credential(for: account) == credential else { return }
accountsManager.recordServerOutcome(error, for: account)
}
private let settingsManager: SettingsManager
@@ -63,7 +69,7 @@ final class PipedSubscriptionProvider: SubscriptionProvider {
let (instance, authToken) = try await getAuthenticatedInstance()
// Fetch subscriptions from Piped
let subscriptions = try await tracking {
let subscriptions = try await tracking(credential: authToken) {
try await pipedAPI.subscriptions(instance: instance, authToken: authToken)
}
@@ -83,7 +89,7 @@ final class PipedSubscriptionProvider: SubscriptionProvider {
// Subscribe on Piped server. Tracked like every other authenticated
// call: a rejected credential noticed here must mark the account, or it
// keeps reporting itself as signed in.
try await tracking {
try await tracking(credential: authToken) {
try await pipedAPI.subscribe(channelID: channel.id.channelID, instance: instance, authToken: authToken)
}
@@ -100,7 +106,7 @@ final class PipedSubscriptionProvider: SubscriptionProvider {
let (instance, authToken) = try await getAuthenticatedInstance()
// Unsubscribe on Piped server
try await tracking {
try await tracking(credential: authToken) {
try await pipedAPI.unsubscribe(channelID: channelID, instance: instance, authToken: authToken)
}
@@ -137,13 +143,13 @@ final class PipedSubscriptionProvider: SubscriptionProvider {
/// Gets the authenticated Piped instance and auth token from account settings.
/// Runs a server call and records what it implies about the credential.
private func tracking<T>(_ operation: () async throws -> T) async rethrows -> T {
private func tracking<T>(credential: String, _ operation: () async throws -> T) async rethrows -> T {
do {
let value = try await operation()
recordOutcome(nil)
recordOutcome(nil, credential: credential)
return value
} catch {
recordOutcome(error)
recordOutcome(error, credential: credential)
throw error
}
}