withTimeoutOrNull around the revoke bounded nothing. The call parks on a
socket read that neither coroutine cancellation nor Thread.interrupt can
break, and withContext returns only when its block does -- so the
deadline passed and we waited anyway, for the shared client's own
budget: 30s per resolved address, doubled by the authenticator's retry,
plus up to 120s of read timeout. Removing a homelab account off the VPN
sat there for minutes with nothing visibly happening. Only closing the
socket ends it, which is what callTimeout does.
The budget moves to AppPassword.revoke, where the "best effort, must not
block the removal" contract is already written down, and where it can be
enforced. Not on the shared client: callTimeout covers the whole
exchange including the body, and a multiget of a large list over a slow
link legitimately runs long. CalDavHttpTest pins that decision.
The destructive tail is now uncancellable. It spans four stores that
cannot share a transaction, and the caller is a viewModelScope tied to
the Settings destination, so a couple of back gestures used to kill it
mid-sequence. Only the DataStore writes can observe cancellation -- every
Room DAO here is blocking -- so the landing point was cadence.forget:
the app password already revoked while the row survives holding it, and
the account asking the user to sign in again for a credential we
invalidated ourselves. Further in, the tasks are gone and the row stays.
The tail is bounded and sub-second, so finishing it always beats
stopping inside it.