sync: three corrections from the review of this branch
Re-authentication duplicated every list. attach() looked up rows with account_id IS NULL, which is right for a re-add but wrong for a re-auth: the account's own lists never matched, so signing in again after a 401 inserted a second copy of each, with no unique index on href to catch it. The lookup now covers both, and a list already owned by this account keeps its cursor instead of being sent through a full reconciliation for nothing. The poll's uncancellable window started one suspension point too late. pollLoginFlow is a blocking execute() inside withContext, and withContext throws on return if the job was cancelled meanwhile — so a back gesture in that window discarded a 200 the server had already answered, along with the only copy of a password it had already minted and deleted its flow row for. ead84c3's reclaim cannot save that one either: the row is gone, so a later poll can only report expiry. And the flow record is now cleared only when the token is actually spent. Clearing it on start over, the back arrow or a failure threw away the one thing that could collect a password the user goes on to approve in the browser tab we abandoned but they did not. While proving the second of those: a server without sync-collection reconciles in full on every run, so recording each one kept the cadence permanently fresh and fullReconciliationDue permanently false. Harmless for the path that reads it — the cursor is null there anyway — but it meant ddcffa6's quarantine probe would never have fired for exactly those servers. The mark records scheduled reconciliations now.
This commit is contained in:
@@ -190,16 +190,22 @@ class AccountRepository @Inject constructor(
|
|||||||
* loses only its cursor, because the account it was reconciled against is
|
* loses only its cursor, because the account it was reconciled against is
|
||||||
* gone.
|
* gone.
|
||||||
*
|
*
|
||||||
|
* ⚠️ On a re-authentication this also has to see the account's *own* lists,
|
||||||
|
* not just detached ones — the re-auth path walks the same picker, so a
|
||||||
|
* lookup that missed them would insert a second copy of every list the
|
||||||
|
* account already syncs. On a create the id was minted a statement earlier,
|
||||||
|
* so nothing is attached to it yet and only detached rows can match.
|
||||||
|
*
|
||||||
* @return the ids of the lists this call *created*, which are the only ones
|
* @return the ids of the lists this call *created*, which are the only ones
|
||||||
* a rollback may delete.
|
* a rollback may delete.
|
||||||
*/
|
*/
|
||||||
private fun attach(accountId: Long, selected: Set<TaskCollection>): List<Long> {
|
private fun attach(accountId: Long, selected: Set<TaskCollection>): List<Long> {
|
||||||
val orphans = database.taskLists().orphaned().associateBy { it.href }
|
val known = database.taskLists().attachable(accountId).associateBy { it.href }
|
||||||
val inserted = mutableListOf<Long>()
|
val inserted = mutableListOf<Long>()
|
||||||
selected.forEach { collection ->
|
selected.forEach { collection ->
|
||||||
val href = collection.url.toString()
|
val href = collection.url.toString()
|
||||||
val orphan = orphans[href]
|
val current = known[href]
|
||||||
if (orphan == null) {
|
if (current == null) {
|
||||||
inserted += database.taskLists().insert(
|
inserted += database.taskLists().insert(
|
||||||
TaskListEntity(
|
TaskListEntity(
|
||||||
name = collection.displayName ?: collection.url.pathSegments
|
name = collection.displayName ?: collection.url.pathSegments
|
||||||
@@ -212,14 +218,17 @@ class AccountRepository @Inject constructor(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
// Changing hands, as opposed to re-authenticating the account
|
||||||
|
// that already owns it: a cursor from the previous owner says
|
||||||
|
// nothing about this one, while the current owner's is still
|
||||||
|
// good and throwing it away costs a full reconciliation.
|
||||||
|
val changingHands = current.accountId != accountId
|
||||||
database.taskLists().update(
|
database.taskLists().update(
|
||||||
orphan.copy(
|
current.copy(
|
||||||
accountId = accountId,
|
accountId = accountId,
|
||||||
isReadOnly = collection.readOnly,
|
isReadOnly = collection.readOnly,
|
||||||
// A cursor from the account that used to own this list
|
syncToken = current.syncToken.takeUnless { changingHands },
|
||||||
// says nothing about the one that owns it now.
|
ctag = current.ctag.takeUnless { changingHands },
|
||||||
syncToken = null,
|
|
||||||
ctag = null,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,14 @@ class SyncCadenceStore @Inject constructor(
|
|||||||
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
|
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** Last full reconciliation per list id. */
|
/**
|
||||||
|
* Last *scheduled* full reconciliation per list id.
|
||||||
|
*
|
||||||
|
* ⚠️ Not "the last time a full listing was read". A collection whose server
|
||||||
|
* has no `sync-collection` support reads one on every run, and recording
|
||||||
|
* each would keep this permanently fresh — so nothing hung off the periodic
|
||||||
|
* mark would ever come due again.
|
||||||
|
*/
|
||||||
suspend fun lastFullSync(): Map<Long, Instant> =
|
suspend fun lastFullSync(): Map<Long, Instant> =
|
||||||
dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
|
dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
|
||||||
val separator = entry.lastIndexOf(SEPARATOR)
|
val separator = entry.lastIndexOf(SEPARATOR)
|
||||||
|
|||||||
@@ -147,22 +147,32 @@ class SyncEngine @Inject constructor(
|
|||||||
val now = kotlin.time.Clock.System.now()
|
val now = kotlin.time.Clock.System.now()
|
||||||
val lastFull = cadence.lastFullSync()
|
val lastFull = cadence.lastFullSync()
|
||||||
|
|
||||||
|
// Never reconciled, or the token has been trusted long enough.
|
||||||
|
val due = lists.associate { list ->
|
||||||
|
val since = lastFull[list.id]
|
||||||
|
list.id to (since == null || now - since >= SyncCadenceStore.FULL_RECONCILIATION_INTERVAL)
|
||||||
|
}
|
||||||
|
|
||||||
val reports = lists.map { list ->
|
val reports = lists.map { list ->
|
||||||
val url = list.href?.toHttpUrlOrNull()
|
val url = list.href?.toHttpUrlOrNull()
|
||||||
?: return@map SyncReport(list.id, list.name, failure = "list has no collection URL")
|
?: return@map SyncReport(list.id, list.name, failure = "list has no collection URL")
|
||||||
val since = lastFull[list.id]
|
|
||||||
syncer.sync(
|
syncer.sync(
|
||||||
list = list,
|
list = list,
|
||||||
remote = remoteFor(url),
|
remote = remoteFor(url),
|
||||||
quarantine = counts,
|
quarantine = counts,
|
||||||
// Never reconciled, or the token has been trusted long enough.
|
fullReconciliationDue = due[list.id] == true,
|
||||||
fullReconciliationDue = since == null ||
|
|
||||||
now - since >= SyncCadenceStore.FULL_RECONCILIATION_INTERVAL,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⚠️ Only the runs that were *due*. A server without `sync-collection`
|
||||||
|
// reconciles in full every single time, so recording each one kept the
|
||||||
|
// clock permanently fresh and `fullReconciliationDue` permanently false
|
||||||
|
// — which costs nothing on that path, since the cursor is null anyway,
|
||||||
|
// but silently disables everything else hung off the periodic mark. The
|
||||||
|
// download-side quarantine probe is the one that matters: for exactly
|
||||||
|
// those servers it would never have fired.
|
||||||
cadence.record(
|
cadence.record(
|
||||||
reports.filter { it.reconciledInFull && it.failure == null }
|
reports.filter { it.reconciledInFull && it.failure == null && due[it.listId] == true }
|
||||||
.associate { it.listId to now },
|
.associate { it.listId to now },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -84,16 +84,25 @@ interface TaskListDao {
|
|||||||
fun setSyncToken(listId: Long, token: String?)
|
fun setSyncToken(listId: Long, token: String?)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists an account left behind: detached, but still naming a collection.
|
* Lists a collection URL may be pointed at instead of inserting a new row:
|
||||||
|
* the ones [accountId] already holds, and the ones a previous removal left
|
||||||
|
* detached.
|
||||||
*
|
*
|
||||||
* ⚠️ `remove()` deliberately leaves the lists as device-only ones, so
|
* ⚠️ Both halves matter, and each is a duplicate bug on its own.
|
||||||
* re-adding the same account must *re-attach* these rather than insert a
|
* `remove()` deliberately leaves lists as device-only ones, so re-adding the
|
||||||
* second copy of every collection — the user would otherwise get their old
|
* account must re-attach them or the user gets their old "Personal" full of
|
||||||
* "Personal" full of tasks beside a freshly synced "Personal" holding the
|
* tasks beside a freshly synced "Personal" holding the same tasks from the
|
||||||
* same tasks from the server.
|
* server. And re-*authenticating* walks the same picker, so a lookup that
|
||||||
|
* only saw detached rows would insert a second copy of every list the
|
||||||
|
* account already syncs — there is no unique index on `href` to catch it.
|
||||||
*/
|
*/
|
||||||
@Query("SELECT * FROM task_lists WHERE account_id IS NULL AND href IS NOT NULL")
|
@Query(
|
||||||
fun orphaned(): List<TaskListEntity>
|
"""
|
||||||
|
SELECT * FROM task_lists
|
||||||
|
WHERE href IS NOT NULL AND (account_id IS NULL OR account_id = :accountId)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun attachable(accountId: Long): List<TaskListEntity>
|
||||||
|
|
||||||
/** The synced collections of one account, in the order sync walks them. */
|
/** The synced collections of one account, in the order sync walks them. */
|
||||||
@Query("SELECT * FROM task_lists WHERE account_id = :accountId AND is_synced = 1 ORDER BY id")
|
@Query("SELECT * FROM task_lists WHERE account_id = :accountId AND is_synced = 1 ORDER BY id")
|
||||||
|
|||||||
@@ -293,7 +293,18 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
// a gateway that keeps saying "pending" would have it poll forever.
|
// a gateway that keeps saying "pending" would have it poll forever.
|
||||||
repeat(MAX_POLL_ATTEMPTS) {
|
repeat(MAX_POLL_ATTEMPTS) {
|
||||||
delay(POLL_INTERVAL_MILLIS)
|
delay(POLL_INTERVAL_MILLIS)
|
||||||
when (val result = gateway.pollLoginFlow(flow)) {
|
// ⚠️ The *call*, not just what follows it. `pollLoginFlow` is a
|
||||||
|
// blocking execute() inside withContext, and withContext throws
|
||||||
|
// on return if the job was cancelled meanwhile — so a back
|
||||||
|
// gesture landing in that window discarded a 200 the server had
|
||||||
|
// already answered, and with it the only copy of a password it
|
||||||
|
// had already minted and deleted its flow row for. The persisted
|
||||||
|
// flow cannot reclaim that one either: the row is gone, so a
|
||||||
|
// later poll can only report expiry. Bounded by the gateway's
|
||||||
|
// own per-call budget, and the delay above stays cancellable, so
|
||||||
|
// the loop still stops promptly.
|
||||||
|
val result = withContext(NonCancellable) { gateway.pollLoginFlow(flow) }
|
||||||
|
when (result) {
|
||||||
is NextcloudLoginFlow.PollResult.Approved -> {
|
is NextcloudLoginFlow.PollResult.Approved -> {
|
||||||
// ⚠️ Uncancellable. The 200 that carried this password is
|
// ⚠️ Uncancellable. The 200 that carried this password is
|
||||||
// the only one there will ever be — the server deletes the
|
// the only one there will ever be — the server deletes the
|
||||||
@@ -302,10 +313,18 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
// credential with nothing left holding it. Recording it is
|
// credential with nothing left holding it. Recording it is
|
||||||
// what makes handing it back possible.
|
// what makes handing it back possible.
|
||||||
withContext(NonCancellable) {
|
withContext(NonCancellable) {
|
||||||
// Spent: the server deleted the row before answering,
|
// ⚠️ Forgotten *here*, and nowhere else. This is the
|
||||||
// so a persisted token is now a thing that would be
|
// one point where the token stops being worth
|
||||||
// polled again for nothing.
|
// anything: the server deleted the flow row before
|
||||||
pendingFlow.forget()
|
// answering, so there is nothing left to collect.
|
||||||
|
// Every other way out of this step — start over, the
|
||||||
|
// back arrow, a failure, leaving the screen —
|
||||||
|
// abandons a flow the user may still go on to
|
||||||
|
// approve in the browser that is still open, and the
|
||||||
|
// record is what lets the next launch collect that
|
||||||
|
// password and hand it back. A poll past the
|
||||||
|
// deadline reports expiry and clears it.
|
||||||
|
runCatching { pendingFlow.forget() }
|
||||||
username = result.credentials.loginName
|
username = result.credentials.loginName
|
||||||
appPassword = result.credentials.appPassword
|
appPassword = result.credentials.appPassword
|
||||||
serverRoot = result.credentials.server
|
serverRoot = result.credentials.server
|
||||||
@@ -400,7 +419,6 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
// The one exit that never reaches onStartOver: if approval already
|
// The one exit that never reaches onStartOver: if approval already
|
||||||
// landed, that password is about to be replaced by a typed one.
|
// landed, that password is about to be replaced by a typed one.
|
||||||
discardMintedPassword()
|
discardMintedPassword()
|
||||||
appScope.launch { runCatching { pendingFlow.forget() } }
|
|
||||||
pollJob?.cancel()
|
pollJob?.cancel()
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(step = AddAccountStep.EnterCredentials(error = null), openInBrowser = null)
|
it.copy(step = AddAccountStep.EnterCredentials(error = null), openInBrowser = null)
|
||||||
@@ -502,7 +520,6 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
fun onStartOver() {
|
fun onStartOver() {
|
||||||
discardMintedPassword()
|
discardMintedPassword()
|
||||||
appScope.launch { runCatching { pendingFlow.forget() } }
|
|
||||||
pollJob?.cancel()
|
pollJob?.cancel()
|
||||||
pollJob = null
|
pollJob = null
|
||||||
found = null
|
found = null
|
||||||
@@ -587,13 +604,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
it.copy(step = AddAccountStep.EnterServer(input = typedInput, error = cause))
|
it.copy(step = AddAccountStep.EnterServer(input = typedInput, error = cause))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun browserFailed(reason: AddAccountMessage) {
|
private fun browserFailed(reason: AddAccountMessage) = _state.update {
|
||||||
// Whatever ended it, nothing is coming back through this token.
|
|
||||||
appScope.launch { runCatching { pendingFlow.forget() } }
|
|
||||||
failedInBrowser(reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun failedInBrowser(reason: AddAccountMessage) = _state.update {
|
|
||||||
// Keeps whatever the step was carrying. The host-mismatch note is often
|
// Keeps whatever the step was carrying. The host-mismatch note is often
|
||||||
// the *explanation* for the failure, so dropping it removes the warning
|
// the *explanation* for the failure, so dropping it removes the warning
|
||||||
// exactly when it becomes worth reading.
|
// exactly when it becomes worth reading.
|
||||||
|
|||||||
+18
-10
@@ -311,18 +311,26 @@ class AddAccountViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a flow that ends badly is forgotten too`() = runTest(dispatcher) {
|
fun `an abandoned flow is kept, because the user may still approve it`() =
|
||||||
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList())
|
runTest(dispatcher) {
|
||||||
gateway.loginFlow = flow()
|
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList())
|
||||||
gateway.pollResults += NextcloudLoginFlow.PollResult.Expired("window closed")
|
gateway.loginFlow = flow()
|
||||||
|
gateway.pollResults += NextcloudLoginFlow.PollResult.Expired("window closed")
|
||||||
|
|
||||||
val vm = viewModel()
|
val vm = viewModel()
|
||||||
vm.onServerInputChanged("https://cloud.example.com/")
|
vm.onServerInputChanged("https://cloud.example.com/")
|
||||||
vm.onServerSubmitted()
|
vm.onServerSubmitted()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
vm.onStartOver()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
assertThat(record.remembered).isNull()
|
// ⚠️ We gave up; the browser tab did not. Approving in it still
|
||||||
}
|
// mints a password, and this record is the only thing that can
|
||||||
|
// collect it and hand it back — a poll past the deadline is what
|
||||||
|
// retires it, not our giving up.
|
||||||
|
assertThat(record.remembered).isNotNull()
|
||||||
|
assertThat(record.log).containsExactly("remember")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
|
|||||||
Reference in New Issue
Block a user