Skip to content

Commit 0cd4c09

Browse files
committed
Replace thread.sleep in sync exchange v2 with coroutine-based delay
1 parent 8343770 commit 0cd4c09

9 files changed

Lines changed: 72 additions & 59 deletions

File tree

sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/RealSyncCodeDispatcher.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ class RealSyncCodeDispatcher @Inject constructor(
125125
* or null for intermediate events the caller should ignore. Both Host and Joiner roles are
126126
* reachable here; mirrors [mapV2LinkingEventToOutcome] (login on Joiner.Done, preserve abort reason).
127127
*/
128-
private fun mapV2PresentEventToOutcome(event: ExchangeV2Event, peerKind: PeerKind?): DispatchOutcome? = when (event) {
128+
private suspend fun mapV2PresentEventToOutcome(event: ExchangeV2Event, peerKind: PeerKind?): DispatchOutcome? = when (event) {
129129
is ExchangeV2Event.SessionStarted -> event.linkingCode?.let { DispatchOutcome.LinkingCodeReady(it) }
130130
is ExchangeV2Event.Transition -> when (event.to) {
131131
ExchangeV2State.Joiner.Confirming ->
@@ -321,7 +321,7 @@ class RealSyncCodeDispatcher @Inject constructor(
321321
* Translate one runner event into a terminal [DispatchOutcome] for the v2 scanner flow,
322322
* or null for intermediate events the caller should ignore.
323323
*/
324-
private fun mapV2LinkingEventToOutcome(event: ExchangeV2Event, peerKind: PeerKind?): DispatchOutcome? = when (event) {
324+
private suspend fun mapV2LinkingEventToOutcome(event: ExchangeV2Event, peerKind: PeerKind?): DispatchOutcome? = when (event) {
325325
is ExchangeV2Event.Transition -> when (event.to) {
326326
ExchangeV2State.Joiner.Confirming ->
327327
DispatchOutcome.JoinerConfirmationRequested(peerName = runner.peerName, peerKind = peerKind)
@@ -376,7 +376,7 @@ class RealSyncCodeDispatcher @Inject constructor(
376376
* cid=ddg logs in directly; cid=3party upgrades the account via
377377
* [SyncAccountRepository.joinAccountFromThirdPartyRecoveryCode].
378378
*/
379-
private fun loginWithV2RecoveryCode(b64: String, peerKind: PeerKind?): DispatchOutcome {
379+
private suspend fun loginWithV2RecoveryCode(b64: String, peerKind: PeerKind?): DispatchOutcome {
380380
val parsed = decodeV2Recovery(b64) ?: return DispatchOutcome.Failed("Couldn't parse received recovery code as v2.0")
381381
return when (parsed.cid) {
382382
CID_DDG -> {

sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import com.squareup.anvil.annotations.*
5353
import com.squareup.moshi.*
5454
import dagger.*
5555
import kotlinx.coroutines.CoroutineScope
56+
import kotlinx.coroutines.delay
5657
import kotlinx.coroutines.launch
5758
import kotlinx.coroutines.runBlocking
5859
import logcat.LogPriority.ERROR
@@ -96,7 +97,7 @@ interface SyncAccountRepository {
9697
* adopted locally; otherwise a new one is created on the server. Either path leaves the
9798
* local store ready for [getThirdPartyRecoveryCode].
9899
*/
99-
fun createThirdPartyCredential(): Result<Boolean>
100+
suspend fun createThirdPartyCredential(): Result<Boolean>
100101

101102
/**
102103
* Fetches the 3party credential from the server, decrypts the SP using the account's secretKey,
@@ -127,7 +128,7 @@ interface SyncAccountRepository {
127128
* populated, credentialId=ddg, scopedPassword populated with SP. SyncStore is written only
128129
* after all three network calls succeed; observers never see an intermediate state.
129130
*/
130-
fun joinAccountFromThirdPartyRecoveryCode(pastedCode: String): Result<Boolean>
131+
suspend fun joinAccountFromThirdPartyRecoveryCode(pastedCode: String): Result<Boolean>
131132

132133
/**
133134
* Returns a recovery code that a 3rd-party browser can use to sign in and access this
@@ -625,7 +626,7 @@ class AppSyncAccountRepository @Inject constructor(
625626
}
626627
}
627628

628-
override fun createThirdPartyCredential(): Result<Boolean> = thirdPartyCredentialManager.create()
629+
override suspend fun createThirdPartyCredential(): Result<Boolean> = thirdPartyCredentialManager.create()
629630

630631
override fun refreshThirdPartyCredential(): Result<Boolean> = thirdPartyCredentialManager.refresh()
631632

@@ -758,7 +759,7 @@ class AppSyncAccountRepository @Inject constructor(
758759
)
759760
}
760761

761-
override fun joinAccountFromThirdPartyRecoveryCode(pastedCode: String): Result<Boolean> {
762+
override suspend fun joinAccountFromThirdPartyRecoveryCode(pastedCode: String): Result<Boolean> {
762763
if (!syncFeature.canUseV2ConnectFlow().isEnabled()) {
763764
return Error(reason = "JoinFrom3party: canUseV2ConnectFlow is disabled")
764765
}
@@ -1338,15 +1339,15 @@ class AppSyncAccountRepository @Inject constructor(
13381339
return this
13391340
}
13401341

1341-
private fun <T> retryingOnTransientError(block: () -> Result<T>): Result<T> {
1342+
private suspend fun <T> retryingOnTransientError(block: () -> Result<T>): Result<T> {
13421343
var attempt = 0
13431344
while (true) {
13441345
val result = block()
13451346
val code = (result as? Error)?.code
13461347
if (code != null && isRetryableTransient(code) && attempt < MAX_UPGRADE_RETRIES) {
13471348
attempt++
13481349
logcat { "Sync-ScopedToken: upgrade call transient error (code=$code); retry $attempt/$MAX_UPGRADE_RETRIES" }
1349-
runCatching { Thread.sleep(upgradeRetryDelayMillis * attempt) }
1350+
delay(upgradeRetryDelayMillis * attempt)
13501351
continue
13511352
}
13521353
return result

sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ThirdPartyCredentialManager.kt

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import com.duckduckgo.sync.store.SyncStore
3030
import com.squareup.anvil.annotations.ContributesBinding
3131
import com.squareup.moshi.Moshi
3232
import dagger.SingleInstanceIn
33+
import kotlinx.coroutines.delay
3334
import logcat.LogPriority.ERROR
3435
import logcat.logcat
3536
import javax.inject.Inject
@@ -46,7 +47,7 @@ interface ThirdPartyCredentialManager {
4647
* already created it, the existing credential is adopted locally; otherwise a new one is created
4748
* on the server. Either path leaves [SyncStore.scopedPassword] populated.
4849
*/
49-
fun create(): Result<Boolean>
50+
suspend fun create(): Result<Boolean>
5051

5152
/**
5253
* Fetches the 3party credential from the server, decrypts its SP using the account's primary
@@ -78,7 +79,7 @@ class RealThirdPartyCredentialManager @Inject constructor(
7879
@VisibleForTesting
7980
internal var rateLimitRetryDelayMillis: Long = RATE_LIMIT_RETRY_DELAY_MILLIS
8081

81-
override fun create(): Result<Boolean> {
82+
override suspend fun create(): Result<Boolean> {
8283
val inputs = when (val r = validateCreatePreconditions()) {
8384
is Success -> r.data
8485
is Error -> return r
@@ -150,7 +151,7 @@ class RealThirdPartyCredentialManager @Inject constructor(
150151
return AdoptResult.Adopted
151152
}
152153

153-
private fun mintAndPostNewCredential(inputs: CreateInputs): Result<Boolean> {
154+
private suspend fun mintAndPostNewCredential(inputs: CreateInputs): Result<Boolean> {
154155
logcat { "Sync-ScopedToken: generating new 3party credential" }
155156

156157
// Re-auth against the existing ddg credential's twice_hashed_password.
@@ -214,7 +215,7 @@ class RealThirdPartyCredentialManager @Inject constructor(
214215
* the credential POST means the server writes them atomically, removing the need for a
215216
* separate `POST /sync/keys/.../set-if-absent` call (and the race window that opened).
216217
*/
217-
private fun buildKeysForNewThirdPartyCredential(
218+
private suspend fun buildKeysForNewThirdPartyCredential(
218219
token: String,
219220
newSpBase64: String,
220221
hkdfSalt: ByteArray,
@@ -317,7 +318,7 @@ class RealThirdPartyCredentialManager @Inject constructor(
317318
)
318319
}
319320

320-
private fun postCreateAccessCredential(
321+
private suspend fun postCreateAccessCredential(
321322
inputs: CreateInputs,
322323
request: CreateAccessCredentialRequest,
323324
newSpBase64: String,
@@ -359,7 +360,7 @@ class RealThirdPartyCredentialManager @Inject constructor(
359360
* Other 409 variants (e.g. `credential_already_exists`) are NOT retried — they're returned
360361
* to the caller, which handles them via the adopt path.
361362
*/
362-
private fun postWithConcurrentModificationRetry(
363+
private suspend fun postWithConcurrentModificationRetry(
363364
token: String,
364365
request: CreateAccessCredentialRequest,
365366
): Result<Boolean> {
@@ -372,7 +373,7 @@ class RealThirdPartyCredentialManager @Inject constructor(
372373
if (isConcurrentMod && attempt < MAX_CONCURRENT_MODIFICATION_RETRIES) {
373374
attempt++
374375
logcat { "Sync-ScopedToken: 3party credential POST hit concurrent_modification; retry $attempt/$MAX_CONCURRENT_MODIFICATION_RETRIES" }
375-
runCatching { Thread.sleep(CONCURRENT_MODIFICATION_RETRY_DELAY_MILLIS * attempt) }
376+
delay(CONCURRENT_MODIFICATION_RETRY_DELAY_MILLIS * attempt)
376377
continue
377378
}
378379
return result
@@ -476,7 +477,7 @@ class RealThirdPartyCredentialManager @Inject constructor(
476477
* Retry an idempotent scoped-credential call on rate-limit.
477478
* A bounded linear backoff recovers since the limit is per-window and the call is idempotent.
478479
*/
479-
private fun <T> retryingOnRateLimit(block: () -> Result<T>): Result<T> {
480+
private suspend fun <T> retryingOnRateLimit(block: () -> Result<T>): Result<T> {
480481
var attempt = 0
481482
while (true) {
482483
val result = block()
@@ -485,7 +486,7 @@ class RealThirdPartyCredentialManager @Inject constructor(
485486
if (rateLimited && attempt < MAX_RATE_LIMIT_RETRIES) {
486487
attempt++
487488
logcat { "Sync-ScopedToken: rate-limited (code=$code); retry $attempt/$MAX_RATE_LIMIT_RETRIES" }
488-
runCatching { Thread.sleep(rateLimitRetryDelayMillis * attempt) }
489+
delay(rateLimitRetryDelayMillis * attempt)
489490
continue
490491
}
491492
return result

sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/exchange/v2/ExchangeV2Runner.kt

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -431,10 +431,18 @@ class RealExchangeV2Runner @Inject constructor(
431431
/**
432432
* Composite of [SideEffect.RequestRecoveryCodeShare]: fetch + send the recovery code
433433
* (or `recovery_code_unavailable` if it can't be produced), then advance the SM.
434+
*
435+
* The fetch+send runs inside the launched coroutine so the underlying retry backoff can use
436+
* `delay` rather than blocking the dispatcher thread. This means the SM mutex is NOT held
437+
* while the network work runs, so a concurrent [cancelLocked] (user back-out / 5-min timeout /
438+
* explicit [cancel]) can race with this body. The short-circuit below catches the common case;
439+
* any work that proceeds past it is best-effort, and any orphaned server-side credential is
440+
* cleaned up by the BE's 5-min TTL (Unified Algorithm, Backend-supported Alternative).
434441
*/
435442
private fun shareRecoveryCodeAndAdvanceLocked() {
436-
val responseOk = sendRecoveryCodeResponse()
437443
appScope.launch(dispatchers.io()) {
444+
if (session == null) return@launch
445+
val responseOk = sendRecoveryCodeResponse()
438446
mutex.withLock {
439447
val s = session ?: return@withLock
440448
if (s.currentState != ExchangeV2State.Host.Sending) return@withLock
@@ -657,7 +665,7 @@ class RealExchangeV2Runner @Inject constructor(
657665
* `recovery_code_unavailable` to the peer + emitted a SessionError event, and the SM
658666
* should be driven to [ExchangeV2State.Host.Aborted] rather than Done).
659667
*/
660-
private fun sendRecoveryCodeResponse(): Boolean {
668+
private suspend fun sendRecoveryCodeResponse(): Boolean {
661669
val peer = peerChannelId ?: return false
662670
val peerKey = peerPublicKey ?: return false
663671
// Recovery code per peer kind. Per spec §"Exchange Share Recovery Code": create the host
@@ -703,7 +711,7 @@ class RealExchangeV2Runner @Inject constructor(
703711
}
704712
}
705713

706-
private fun provisionForThirdPartyPeer(): Result<String> {
714+
private suspend fun provisionForThirdPartyPeer(): Result<String> {
707715
when (val ddg = recoveryCodeProvider.createDdgAccountIfNeeded()) {
708716
is Result.Success -> Unit
709717
is Result.Error -> {

sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/exchange/v2/RecoveryCodeProvider.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ interface RecoveryCodeProvider {
5656
* 3party, if needed, extend the account."* No-op if a 3party credential already exists locally
5757
* ([SyncStore.scopedPassword] set); otherwise creates one. Failure surfaces as [Result.Error].
5858
*/
59-
fun createThirdPartyCredentialIfNeeded(): Result<Unit>
59+
suspend fun createThirdPartyCredentialIfNeeded(): Result<Unit>
6060
}
6161

6262
@ContributesBinding(AppScope::class)
@@ -73,7 +73,7 @@ class RealRecoveryCodeProvider @Inject constructor(
7373
}
7474
}
7575

76-
override fun createThirdPartyCredentialIfNeeded(): Result<Unit> {
76+
override suspend fun createThirdPartyCredentialIfNeeded(): Result<Unit> {
7777
if (syncStore.scopedPassword != null) return Result.Success(Unit)
7878
return when (val r = syncAccountRepository.createThirdPartyCredential()) {
7979
is Result.Success -> Result.Success(Unit)

0 commit comments

Comments
 (0)