Skip to content

Commit a7181fd

Browse files
committed
Replace thread.sleep in sync exchange v2 with coroutine-based delay
1 parent 20310fd commit a7181fd

9 files changed

Lines changed: 69 additions & 56 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: 8 additions & 7 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,
@@ -124,7 +125,7 @@ interface SyncAccountRepository {
124125
* populated, credentialId=ddg, scopedPassword populated with SP. SyncStore is written
125126
* atomically only after both network calls succeed; observers never see an intermediate state.
126127
*/
127-
fun joinAccountFromThirdPartyRecoveryCode(pastedCode: String): Result<Boolean>
128+
suspend fun joinAccountFromThirdPartyRecoveryCode(pastedCode: String): Result<Boolean>
128129

129130
/**
130131
* Returns a recovery code that a 3rd-party browser can use to sign in and access this
@@ -631,7 +632,7 @@ class AppSyncAccountRepository @Inject constructor(
631632
}
632633
}
633634

634-
override fun createThirdPartyCredential(): Result<Boolean> = thirdPartyCredentialManager.create()
635+
override suspend fun createThirdPartyCredential(): Result<Boolean> = thirdPartyCredentialManager.create()
635636

636637
override fun refreshThirdPartyCredential(): Result<Boolean> = thirdPartyCredentialManager.refresh()
637638

@@ -774,7 +775,7 @@ class AppSyncAccountRepository @Inject constructor(
774775
)
775776
}
776777

777-
override fun joinAccountFromThirdPartyRecoveryCode(pastedCode: String): Result<Boolean> {
778+
override suspend fun joinAccountFromThirdPartyRecoveryCode(pastedCode: String): Result<Boolean> {
778779
if (!syncFeature.canUseV2ConnectFlow().isEnabled()) {
779780
return Error(reason = "JoinFrom3party: canUseV2ConnectFlow is disabled")
780781
}
@@ -1239,7 +1240,7 @@ class AppSyncAccountRepository @Inject constructor(
12391240
* Returns the [LoginResponse] for the caller to commit alongside the new local key
12401241
* material.
12411242
*/
1242-
private fun performDdgLoginForUpgrade(
1243+
private suspend fun performDdgLoginForUpgrade(
12431244
userId: String,
12441245
deviceId: String,
12451246
deviceName: String,
@@ -1417,15 +1418,15 @@ class AppSyncAccountRepository @Inject constructor(
14171418
return this
14181419
}
14191420

1420-
private fun <T> retryingOnTransientError(block: () -> Result<T>): Result<T> {
1421+
private suspend fun <T> retryingOnTransientError(block: () -> Result<T>): Result<T> {
14211422
var attempt = 0
14221423
while (true) {
14231424
val result = block()
14241425
val code = (result as? Error)?.code
14251426
if (code != null && isRetryableTransient(code) && attempt < MAX_UPGRADE_RETRIES) {
14261427
attempt++
14271428
logcat { "Sync-ScopedToken: upgrade call transient error (code=$code); retry $attempt/$MAX_UPGRADE_RETRIES" }
1428-
runCatching { Thread.sleep(upgradeRetryDelayMillis * attempt) }
1429+
delay(upgradeRetryDelayMillis * attempt)
14291430
continue
14301431
}
14311432
return result

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

Lines changed: 7 additions & 6 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
@@ -79,7 +80,7 @@ class RealThirdPartyCredentialManager @Inject constructor(
7980
@VisibleForTesting
8081
internal var rateLimitRetryDelayMillis: Long = DEFAULT_RATE_LIMIT_RETRY_DELAY_MILLIS
8182

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

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

157158
// Re-auth against the existing ddg credential's twice_hashed_password.
@@ -214,7 +215,7 @@ class RealThirdPartyCredentialManager @Inject constructor(
214215
* - 3party-side: RFC 7516 JWE compact, kid="3party", AES-256-GCM,
215216
* key = HKDF(SP, salt=user_id, info="Main Key"). Cross-platform readable.
216217
*/
217-
private fun reEncryptExistingDdgKeysFor3party(
218+
private suspend fun reEncryptExistingDdgKeysFor3party(
218219
token: String,
219220
newSpBase64: String,
220221
hkdfSalt: ByteArray,
@@ -398,7 +399,7 @@ class RealThirdPartyCredentialManager @Inject constructor(
398399
* Retry an idempotent scoped-credential call on rate-limit.
399400
* A bounded linear backoff recovers since the limit is per-window and the call is idempotent.
400401
*/
401-
private fun <T> retryingOnRateLimit(block: () -> Result<T>): Result<T> {
402+
private suspend fun <T> retryingOnRateLimit(block: () -> Result<T>): Result<T> {
402403
var attempt = 0
403404
while (true) {
404405
val result = block()
@@ -407,7 +408,7 @@ class RealThirdPartyCredentialManager @Inject constructor(
407408
if (rateLimited && attempt < MAX_RATE_LIMIT_RETRIES) {
408409
attempt++
409410
logcat { "Sync-ScopedToken: rate-limited (code=$code); retry $attempt/$MAX_RATE_LIMIT_RETRIES" }
410-
runCatching { Thread.sleep(rateLimitRetryDelayMillis * attempt) }
411+
delay(rateLimitRetryDelayMillis * attempt)
411412
continue
412413
}
413414
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)

sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import com.duckduckgo.sync.store.ScopedPassword
8484
import com.duckduckgo.sync.store.SyncStore
8585
import com.squareup.moshi.Moshi
8686
import kotlinx.coroutines.test.TestScope
87+
import kotlinx.coroutines.test.runTest
8788
import org.junit.Assert.assertEquals
8889
import org.junit.Assert.assertFalse
8990
import org.junit.Assert.assertNull
@@ -1247,7 +1248,7 @@ class AppSyncAccountRepositoryTest {
12471248
// Per-manager logic is covered in ThirdPartyCredentialManagerTest and ProtectedKeyManagerTest.
12481249

12491250
@Test
1250-
fun whenCreateThirdPartyCredentialThenDelegatesToManager() {
1251+
fun whenCreateThirdPartyCredentialThenDelegatesToManager() = runTest {
12511252
whenever(thirdPartyCredentialManager.create()).thenReturn(Success(true))
12521253

12531254
val result = syncRepo.createThirdPartyCredential()
@@ -1307,7 +1308,7 @@ class AppSyncAccountRepositoryTest {
13071308
// joinAccountFromThirdPartyRecoveryCode
13081309

13091310
@Test
1310-
fun whenJoinAccountFromThirdPartyAndAllStepsSucceedThenAtomicStoreUsesDdgTokenFromSecondLogin() {
1311+
fun whenJoinAccountFromThirdPartyAndAllStepsSucceedThenAtomicStoreUsesDdgTokenFromSecondLogin() = runTest {
13111312
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode()
13121313

13131314
val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode)
@@ -1325,7 +1326,7 @@ class AppSyncAccountRepositoryTest {
13251326
}
13261327

13271328
@Test
1328-
fun whenJoinAccountFromThirdPartyThenStep7aPostUpgradeLoginUsesUnrestrictedScope() {
1329+
fun whenJoinAccountFromThirdPartyThenStep7aPostUpgradeLoginUsesUnrestrictedScope() = runTest {
13291330
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode()
13301331

13311332
syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode)
@@ -1350,7 +1351,7 @@ class AppSyncAccountRepositoryTest {
13501351
}
13511352

13521353
@Test
1353-
fun whenStep7aPostUpgradeLoginReturns401ThenAbortsWithoutAtomicStore() {
1354+
fun whenStep7aPostUpgradeLoginReturns401ThenAbortsWithoutAtomicStore() = runTest {
13541355
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(step7aResult = step7aLoginUnauthorized)
13551356

13561357
val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode)
@@ -1361,7 +1362,7 @@ class AppSyncAccountRepositoryTest {
13611362
}
13621363

13631364
@Test
1364-
fun whenStep7aPostUpgradeLoginFailsWithNon401ThenAbortsWithoutAtomicStore() {
1365+
fun whenStep7aPostUpgradeLoginFailsWithNon401ThenAbortsWithoutAtomicStore() = runTest {
13651366
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(step7aResult = step7aLoginServerError)
13661367

13671368
val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode)
@@ -1375,7 +1376,7 @@ class AppSyncAccountRepositoryTest {
13751376
}
13761377

13771378
@Test
1378-
fun whenJoinAccountFromThirdPartyAndAccountAlreadyHasDdgCredentialThenAbortsBeforePost() {
1379+
fun whenJoinAccountFromThirdPartyAndAccountAlreadyHasDdgCredentialThenAbortsBeforePost() = runTest {
13791380
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(accountAlreadyHasDdg = true)
13801381

13811382
val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode)
@@ -1388,7 +1389,7 @@ class AppSyncAccountRepositoryTest {
13881389
}
13891390

13901391
@Test
1391-
fun whenStep7PostRateLimitedThenRetriesAndSucceeds() {
1392+
fun whenStep7PostRateLimitedThenRetriesAndSucceeds() = runTest {
13921393
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode()
13931394
whenever(syncApi.createAccessCredential(anyString(), eq("ddg"), any()))
13941395
.thenReturn(Error(code = API_CODE.TOO_MANY_REQUESTS_2.code, reason = "rate limited"), Success(true))
@@ -1400,7 +1401,7 @@ class AppSyncAccountRepositoryTest {
14001401
}
14011402

14021403
@Test
1403-
fun whenStep7aLoginServerErrorThenRetriesAndSucceeds() {
1404+
fun whenStep7aLoginServerErrorThenRetriesAndSucceeds() = runTest {
14041405
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode()
14051406
whenever(syncApi.login(eq(userId), any(), eq(deviceId), any(), any(), eq<String?>(null)))
14061407
.thenReturn(step7aLoginServerError, step7aLoginSuccess)
@@ -1420,7 +1421,7 @@ class AppSyncAccountRepositoryTest {
14201421
}
14211422

14221423
@Test
1423-
fun whenStep7aLoginTransportErrorThenRetriesAndSucceeds() {
1424+
fun whenStep7aLoginTransportErrorThenRetriesAndSucceeds() = runTest {
14241425
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode()
14251426
whenever(syncApi.login(eq(userId), any(), eq(deviceId), any(), any(), eq<String?>(null)))
14261427
.thenReturn(step7aLoginTransportError, step7aLoginSuccess)
@@ -1432,7 +1433,7 @@ class AppSyncAccountRepositoryTest {
14321433
}
14331434

14341435
@Test
1435-
fun whenStep7aLogin401ThenAbortsWithoutRetry() {
1436+
fun whenStep7aLogin401ThenAbortsWithoutRetry() = runTest {
14361437
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(step7aResult = step7aLoginUnauthorized)
14371438

14381439
val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode)
@@ -1443,7 +1444,7 @@ class AppSyncAccountRepositoryTest {
14431444
}
14441445

14451446
@Test
1446-
fun whenStep7Post409AlreadyExistsThenAbortsWithoutRetry() {
1447+
fun whenStep7Post409AlreadyExistsThenAbortsWithoutRetry() = runTest {
14471448
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode()
14481449
whenever(syncApi.createAccessCredential(anyString(), eq("ddg"), any()))
14491450
.thenReturn(Error(code = API_CODE.COUNT_LIMIT.code, reason = "already exists"))
@@ -1456,7 +1457,7 @@ class AppSyncAccountRepositoryTest {
14561457
}
14571458

14581459
@Test
1459-
fun whenStep7PostRateLimitedBeyondRetryLimitThenAbortsWithoutStore() {
1460+
fun whenStep7PostRateLimitedBeyondRetryLimitThenAbortsWithoutStore() = runTest {
14601461
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode()
14611462
whenever(syncApi.createAccessCredential(anyString(), eq("ddg"), any()))
14621463
.thenReturn(Error(code = API_CODE.TOO_MANY_REQUESTS_1.code, reason = "rate limited"))
@@ -1470,7 +1471,7 @@ class AppSyncAccountRepositoryTest {
14701471
}
14711472

14721473
@Test
1473-
fun whenJoinAccountFromThirdPartyAndCodeIsLaterMinorVersionThenAccepted() {
1474+
fun whenJoinAccountFromThirdPartyAndCodeIsLaterMinorVersionThenAccepted() = runTest {
14741475
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(version = "2.5")
14751476

14761477
val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode)
@@ -1479,7 +1480,7 @@ class AppSyncAccountRepositoryTest {
14791480
}
14801481

14811482
@Test
1482-
fun whenJoinAccountFromThirdPartyAndCodeIsBareMajorVersionThenAccepted() {
1483+
fun whenJoinAccountFromThirdPartyAndCodeIsBareMajorVersionThenAccepted() = runTest {
14831484
// "2" is the spec's common shorthand for "2.0" (Transport TD 1214486492252757).
14841485
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(version = "2")
14851486

@@ -1489,7 +1490,7 @@ class AppSyncAccountRepositoryTest {
14891490
}
14901491

14911492
@Test
1492-
fun whenJoinAccountFromThirdPartyAndCodeIsMajorVersion3ThenRejectedWithoutNetwork() {
1493+
fun whenJoinAccountFromThirdPartyAndCodeIsMajorVersion3ThenRejectedWithoutNetwork() = runTest {
14931494
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(version = "3.0")
14941495

14951496
val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode)
@@ -1499,7 +1500,7 @@ class AppSyncAccountRepositoryTest {
14991500
}
15001501

15011502
@Test
1502-
fun whenJoinAccountFromThirdPartyAndCodeCidIsNotThirdPartyThenRejectedWithoutNetwork() {
1503+
fun whenJoinAccountFromThirdPartyAndCodeCidIsNotThirdPartyThenRejectedWithoutNetwork() = runTest {
15031504
val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(cid = "ddg")
15041505

15051506
val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode)

sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/RealSyncCodeDispatcherTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,7 @@ class RealSyncCodeDispatcherTest {
448448
assertTrue((outcome as DispatchOutcome.Failed).reason.contains("future-credential"))
449449
}
450450

451-
@Test fun `Legacy path - dispatcher never invokes processCode (caller owns that)`() {
451+
@Test fun `Legacy path - dispatcher never invokes processCode (caller owns that)`() = runTest {
452452
setV2(false)
453453
whenever(syncAccountRepository.parseSyncAuthCode(any())).thenReturn(SyncAuthCode.Recovery(mock()))
454454

0 commit comments

Comments
 (0)