Loading app/src/main/java/foundation/e/apps/feature/auth/session/SessionStateHolder.kt +26 −1 Original line number Diff line number Diff line Loading @@ -77,7 +77,9 @@ class SessionStateHolder @Inject constructor( } is RefreshRequest.WaitForDifferentRequest -> { awaitRefreshCompletion(request.completion) if (awaitDifferentRefreshCompletionOrRetry(request.completion)) { continue } } is RefreshRequest.StartNew -> { Loading @@ -98,6 +100,29 @@ class SessionStateHolder @Inject constructor( } } private suspend fun awaitDifferentRefreshCompletionOrRetry( completion: CompletableDeferred<Unit>, ): Boolean { return try { awaitRefreshCompletion(completion) false } catch (exception: CancellationException) { throw exception } catch (exception: IOException) { Timber.i( exception, "Different in-flight auth refresh finished unsuccessfully; retrying refresh acquisition", ) true } catch (exception: SessionRefreshException) { Timber.i( exception, "Different in-flight auth refresh finished unsuccessfully; retrying refresh acquisition", ) true } } private suspend fun acquireRefreshRequest(storesToReset: List<AuthStore>): RefreshRequest { return refreshMutex.withLock { val normalizedStoresToReset = storesToReset.toList() Loading app/src/test/java/foundation/e/apps/feature/auth/session/SessionStateHolderTest.kt +83 −6 Original line number Diff line number Diff line Loading @@ -400,7 +400,7 @@ class SessionStateHolderTest { } @Test fun `refreshSessions times out while waiting for a different in-flight refresh`() = runTest { fun `refreshSessions retries a different in-flight refresh after waiter timeout`() = runTest { val authRefreshRepository = BlockingAuthRefreshRepository( AuthRefreshOutcome(snapshot = AuthRefreshSnapshot(emptyList())), ) Loading @@ -424,14 +424,70 @@ class SessionStateHolderTest { advanceTimeBy(SessionStateHolder.REFRESH_WAIT_TIMEOUT_MS + 1) runCurrent() val failure = secondRefresh.await().exceptionOrNull() assertThat(failure).isInstanceOf(SessionRefreshTimeoutException::class.java) val timeoutFailure = failure as SessionRefreshTimeoutException assertThat(timeoutFailure).hasMessageThat() .contains("Timed out waiting for in-flight auth refresh to complete") assertThat(secondRefresh.isCompleted).isFalse() authRefreshRepository.release.complete(Unit) firstRefresh.await() val secondResult = secondRefresh.await() assertThat(secondResult.exceptionOrNull()).isNull() assertThat(authRefreshRepository.fetchCount).isEqualTo(2) } @Test fun `refreshSessions retries a different in-flight refresh after first refresh fails`() = runTest { val successfulOutcome = AuthRefreshOutcome( snapshot = AuthRefreshSnapshot( entries = listOf( AuthRefreshEntry( store = AuthStore.OPEN_SOURCE, result = AuthResult.Success(AuthSession.OpenSourceSession), ), ), ), ) val authRefreshRepository = BlockingSequentialAuthRefreshRepository( responses = ArrayDeque( listOf( RefreshResponse.Failure(IOException("network down")), RefreshResponse.Success(successfulOutcome), ), ), ) val sessionStateHolder = SessionStateHolder( authRefreshRepository = authRefreshRepository, faultyTokenReporter = FaultyTokenReporter { }, ) val firstRefresh = async { runCatching { sessionStateHolder.refreshSessions(listOf(AuthStore.PLAY_STORE)) }.exceptionOrNull() } authRefreshRepository.started.await() val secondRefresh = async { sessionStateHolder.refreshSessions(listOf(AuthStore.OPEN_SOURCE)) } runCurrent() authRefreshRepository.release.complete(Unit) runCurrent() val firstFailure = firstRefresh.await() secondRefresh.await() assertThat(firstFailure).isInstanceOf(IOException::class.java) assertThat(authRefreshRepository.receivedStoresToResetHistory) .containsExactly( listOf(AuthStore.PLAY_STORE), listOf(AuthStore.OPEN_SOURCE), ) .inOrder() assertThat(sessionStateHolder.authRefreshState.value) .isEqualTo(AuthRefreshState.Completed(successfulOutcome.snapshot)) assertThat(sessionStateHolder.authRefreshSnapshot.value) .isEqualTo(successfulOutcome.snapshot) } private class FakeAuthRefreshRepository( Loading Loading @@ -461,6 +517,27 @@ class SessionStateHolderTest { } } private class BlockingSequentialAuthRefreshRepository( private val responses: ArrayDeque<RefreshResponse>, ) : AuthRefreshRepository { val started = CompletableDeferred<Unit>() val release = CompletableDeferred<Unit>() val receivedStoresToResetHistory = mutableListOf<List<AuthStore>>() override suspend fun refreshSessions(storesToReset: List<AuthStore>): AuthRefreshOutcome { receivedStoresToResetHistory += storesToReset if (receivedStoresToResetHistory.size == 1) { started.complete(Unit) release.await() } return when (val response = responses.removeFirst()) { is RefreshResponse.Success -> response.authRefreshOutcome is RefreshResponse.Failure -> throw response.exception } } } private class ThrowingAuthRefreshRepository( private val exception: IOException, ) : AuthRefreshRepository { Loading Loading
app/src/main/java/foundation/e/apps/feature/auth/session/SessionStateHolder.kt +26 −1 Original line number Diff line number Diff line Loading @@ -77,7 +77,9 @@ class SessionStateHolder @Inject constructor( } is RefreshRequest.WaitForDifferentRequest -> { awaitRefreshCompletion(request.completion) if (awaitDifferentRefreshCompletionOrRetry(request.completion)) { continue } } is RefreshRequest.StartNew -> { Loading @@ -98,6 +100,29 @@ class SessionStateHolder @Inject constructor( } } private suspend fun awaitDifferentRefreshCompletionOrRetry( completion: CompletableDeferred<Unit>, ): Boolean { return try { awaitRefreshCompletion(completion) false } catch (exception: CancellationException) { throw exception } catch (exception: IOException) { Timber.i( exception, "Different in-flight auth refresh finished unsuccessfully; retrying refresh acquisition", ) true } catch (exception: SessionRefreshException) { Timber.i( exception, "Different in-flight auth refresh finished unsuccessfully; retrying refresh acquisition", ) true } } private suspend fun acquireRefreshRequest(storesToReset: List<AuthStore>): RefreshRequest { return refreshMutex.withLock { val normalizedStoresToReset = storesToReset.toList() Loading
app/src/test/java/foundation/e/apps/feature/auth/session/SessionStateHolderTest.kt +83 −6 Original line number Diff line number Diff line Loading @@ -400,7 +400,7 @@ class SessionStateHolderTest { } @Test fun `refreshSessions times out while waiting for a different in-flight refresh`() = runTest { fun `refreshSessions retries a different in-flight refresh after waiter timeout`() = runTest { val authRefreshRepository = BlockingAuthRefreshRepository( AuthRefreshOutcome(snapshot = AuthRefreshSnapshot(emptyList())), ) Loading @@ -424,14 +424,70 @@ class SessionStateHolderTest { advanceTimeBy(SessionStateHolder.REFRESH_WAIT_TIMEOUT_MS + 1) runCurrent() val failure = secondRefresh.await().exceptionOrNull() assertThat(failure).isInstanceOf(SessionRefreshTimeoutException::class.java) val timeoutFailure = failure as SessionRefreshTimeoutException assertThat(timeoutFailure).hasMessageThat() .contains("Timed out waiting for in-flight auth refresh to complete") assertThat(secondRefresh.isCompleted).isFalse() authRefreshRepository.release.complete(Unit) firstRefresh.await() val secondResult = secondRefresh.await() assertThat(secondResult.exceptionOrNull()).isNull() assertThat(authRefreshRepository.fetchCount).isEqualTo(2) } @Test fun `refreshSessions retries a different in-flight refresh after first refresh fails`() = runTest { val successfulOutcome = AuthRefreshOutcome( snapshot = AuthRefreshSnapshot( entries = listOf( AuthRefreshEntry( store = AuthStore.OPEN_SOURCE, result = AuthResult.Success(AuthSession.OpenSourceSession), ), ), ), ) val authRefreshRepository = BlockingSequentialAuthRefreshRepository( responses = ArrayDeque( listOf( RefreshResponse.Failure(IOException("network down")), RefreshResponse.Success(successfulOutcome), ), ), ) val sessionStateHolder = SessionStateHolder( authRefreshRepository = authRefreshRepository, faultyTokenReporter = FaultyTokenReporter { }, ) val firstRefresh = async { runCatching { sessionStateHolder.refreshSessions(listOf(AuthStore.PLAY_STORE)) }.exceptionOrNull() } authRefreshRepository.started.await() val secondRefresh = async { sessionStateHolder.refreshSessions(listOf(AuthStore.OPEN_SOURCE)) } runCurrent() authRefreshRepository.release.complete(Unit) runCurrent() val firstFailure = firstRefresh.await() secondRefresh.await() assertThat(firstFailure).isInstanceOf(IOException::class.java) assertThat(authRefreshRepository.receivedStoresToResetHistory) .containsExactly( listOf(AuthStore.PLAY_STORE), listOf(AuthStore.OPEN_SOURCE), ) .inOrder() assertThat(sessionStateHolder.authRefreshState.value) .isEqualTo(AuthRefreshState.Completed(successfulOutcome.snapshot)) assertThat(sessionStateHolder.authRefreshSnapshot.value) .isEqualTo(successfulOutcome.snapshot) } private class FakeAuthRefreshRepository( Loading Loading @@ -461,6 +517,27 @@ class SessionStateHolderTest { } } private class BlockingSequentialAuthRefreshRepository( private val responses: ArrayDeque<RefreshResponse>, ) : AuthRefreshRepository { val started = CompletableDeferred<Unit>() val release = CompletableDeferred<Unit>() val receivedStoresToResetHistory = mutableListOf<List<AuthStore>>() override suspend fun refreshSessions(storesToReset: List<AuthStore>): AuthRefreshOutcome { receivedStoresToResetHistory += storesToReset if (receivedStoresToResetHistory.size == 1) { started.complete(Unit) release.await() } return when (val response = responses.removeFirst()) { is RefreshResponse.Success -> response.authRefreshOutcome is RefreshResponse.Failure -> throw response.exception } } } private class ThrowingAuthRefreshRepository( private val exception: IOException, ) : AuthRefreshRepository { Loading