Loading app/src/main/java/foundation/e/apps/feature/auth/session/SessionRefreshException.kt +7 −0 Original line number Diff line number Diff line Loading @@ -35,3 +35,10 @@ class SessionRefreshExecutionException( message = cause.localizedMessage ?: "Auth refresh failed unexpectedly", cause = cause, ) class SessionRefreshContentionException( cause: Throwable, ) : SessionRefreshException( message = "Gave up waiting for different in-flight auth refreshes after repeated failures", cause = cause, ) app/src/main/java/foundation/e/apps/feature/auth/session/SessionStateHolder.kt +43 −16 Original line number Diff line number Diff line Loading @@ -49,6 +49,12 @@ class SessionStateHolder @Inject constructor( companion object { @VisibleForTesting internal const val REFRESH_WAIT_TIMEOUT_MS = 10_000L // Caps how many times a refreshSessions() call may loop back after observing a // different in-flight refresh fail. Without a bound, a steady stream of competing // refresh requests that all fail can keep one caller suspended indefinitely. @VisibleForTesting internal const val MAX_DIFFERENT_REFRESH_RETRIES = 3 } private val refreshMutex = Mutex() Loading @@ -69,6 +75,8 @@ class SessionStateHolder @Inject constructor( override val authRefreshSnapshot: StateFlow<AuthRefreshSnapshot?> = _authRefreshSnapshot.asStateFlow() override suspend fun refreshSessions(storesToReset: List<AuthStore>) { var differentRefreshRetries = 0 while (true) { when (val request = acquireRefreshRequest(storesToReset)) { is RefreshRequest.JoinExisting -> { Loading @@ -77,9 +85,7 @@ class SessionStateHolder @Inject constructor( } is RefreshRequest.WaitForDifferentRequest -> { if (awaitDifferentRefreshCompletionOrRetry(request.completion)) { continue } if (handleDifferentRequest(request, ++differentRefreshRetries)) continue } is RefreshRequest.StartNew -> { Loading @@ -90,6 +96,30 @@ class SessionStateHolder @Inject constructor( } } private suspend fun handleDifferentRequest( request: RefreshRequest.WaitForDifferentRequest, retryCount: Int ): Boolean { val outcome = awaitDifferentRefreshOutcome(request.completion) if (outcome is DifferentRefreshOutcome.Failed) { if (retryCount > MAX_DIFFERENT_REFRESH_RETRIES) { throw SessionRefreshContentionException(outcome.cause) } Timber.i( outcome.cause, "Different in-flight auth refresh failed (retry %d of %d)", retryCount, MAX_DIFFERENT_REFRESH_RETRIES ) return true } return false } private suspend fun awaitRefreshCompletion(completion: CompletableDeferred<Unit>) { try { withTimeout(REFRESH_WAIT_TIMEOUT_MS) { Loading @@ -100,26 +130,18 @@ class SessionStateHolder @Inject constructor( } } private suspend fun awaitDifferentRefreshCompletionOrRetry( private suspend fun awaitDifferentRefreshOutcome( completion: CompletableDeferred<Unit>, ): Boolean { ): DifferentRefreshOutcome { return try { awaitRefreshCompletion(completion) false DifferentRefreshOutcome.Completed } catch (exception: CancellationException) { throw exception } catch (exception: IOException) { Timber.i( exception, "Different in-flight auth refresh finished unsuccessfully; retrying refresh acquisition", ) true DifferentRefreshOutcome.Failed(exception) } catch (exception: SessionRefreshException) { Timber.i( exception, "Different in-flight auth refresh finished unsuccessfully; retrying refresh acquisition", ) true DifferentRefreshOutcome.Failed(exception) } } Loading Loading @@ -342,4 +364,9 @@ class SessionStateHolder @Inject constructor( data class JoinExisting(val completion: CompletableDeferred<Unit>) : RefreshRequest data class WaitForDifferentRequest(val completion: CompletableDeferred<Unit>) : RefreshRequest } private sealed interface DifferentRefreshOutcome { data object Completed : DifferentRefreshOutcome data class Failed(val cause: Throwable) : DifferentRefreshOutcome } } app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt +1 −1 Original line number Diff line number Diff line Loading @@ -24,7 +24,7 @@ import foundation.e.apps.domain.model.install.Status /* * Map raw application + contextual signals into a single button state. * Keep pure: no side effects; callers handle actions. * Keep pure: no side effects; callers handle acntions. */ fun mapAppToInstallState(input: InstallButtonStateInput): InstallButtonState { return when (val status = input.resolvedStatus) { Loading Loading
app/src/main/java/foundation/e/apps/feature/auth/session/SessionRefreshException.kt +7 −0 Original line number Diff line number Diff line Loading @@ -35,3 +35,10 @@ class SessionRefreshExecutionException( message = cause.localizedMessage ?: "Auth refresh failed unexpectedly", cause = cause, ) class SessionRefreshContentionException( cause: Throwable, ) : SessionRefreshException( message = "Gave up waiting for different in-flight auth refreshes after repeated failures", cause = cause, )
app/src/main/java/foundation/e/apps/feature/auth/session/SessionStateHolder.kt +43 −16 Original line number Diff line number Diff line Loading @@ -49,6 +49,12 @@ class SessionStateHolder @Inject constructor( companion object { @VisibleForTesting internal const val REFRESH_WAIT_TIMEOUT_MS = 10_000L // Caps how many times a refreshSessions() call may loop back after observing a // different in-flight refresh fail. Without a bound, a steady stream of competing // refresh requests that all fail can keep one caller suspended indefinitely. @VisibleForTesting internal const val MAX_DIFFERENT_REFRESH_RETRIES = 3 } private val refreshMutex = Mutex() Loading @@ -69,6 +75,8 @@ class SessionStateHolder @Inject constructor( override val authRefreshSnapshot: StateFlow<AuthRefreshSnapshot?> = _authRefreshSnapshot.asStateFlow() override suspend fun refreshSessions(storesToReset: List<AuthStore>) { var differentRefreshRetries = 0 while (true) { when (val request = acquireRefreshRequest(storesToReset)) { is RefreshRequest.JoinExisting -> { Loading @@ -77,9 +85,7 @@ class SessionStateHolder @Inject constructor( } is RefreshRequest.WaitForDifferentRequest -> { if (awaitDifferentRefreshCompletionOrRetry(request.completion)) { continue } if (handleDifferentRequest(request, ++differentRefreshRetries)) continue } is RefreshRequest.StartNew -> { Loading @@ -90,6 +96,30 @@ class SessionStateHolder @Inject constructor( } } private suspend fun handleDifferentRequest( request: RefreshRequest.WaitForDifferentRequest, retryCount: Int ): Boolean { val outcome = awaitDifferentRefreshOutcome(request.completion) if (outcome is DifferentRefreshOutcome.Failed) { if (retryCount > MAX_DIFFERENT_REFRESH_RETRIES) { throw SessionRefreshContentionException(outcome.cause) } Timber.i( outcome.cause, "Different in-flight auth refresh failed (retry %d of %d)", retryCount, MAX_DIFFERENT_REFRESH_RETRIES ) return true } return false } private suspend fun awaitRefreshCompletion(completion: CompletableDeferred<Unit>) { try { withTimeout(REFRESH_WAIT_TIMEOUT_MS) { Loading @@ -100,26 +130,18 @@ class SessionStateHolder @Inject constructor( } } private suspend fun awaitDifferentRefreshCompletionOrRetry( private suspend fun awaitDifferentRefreshOutcome( completion: CompletableDeferred<Unit>, ): Boolean { ): DifferentRefreshOutcome { return try { awaitRefreshCompletion(completion) false DifferentRefreshOutcome.Completed } catch (exception: CancellationException) { throw exception } catch (exception: IOException) { Timber.i( exception, "Different in-flight auth refresh finished unsuccessfully; retrying refresh acquisition", ) true DifferentRefreshOutcome.Failed(exception) } catch (exception: SessionRefreshException) { Timber.i( exception, "Different in-flight auth refresh finished unsuccessfully; retrying refresh acquisition", ) true DifferentRefreshOutcome.Failed(exception) } } Loading Loading @@ -342,4 +364,9 @@ class SessionStateHolder @Inject constructor( data class JoinExisting(val completion: CompletableDeferred<Unit>) : RefreshRequest data class WaitForDifferentRequest(val completion: CompletableDeferred<Unit>) : RefreshRequest } private sealed interface DifferentRefreshOutcome { data object Completed : DifferentRefreshOutcome data class Failed(val cause: Throwable) : DifferentRefreshOutcome } }
app/src/main/java/foundation/e/apps/ui/compose/state/InstallButtonStateMapper.kt +1 −1 Original line number Diff line number Diff line Loading @@ -24,7 +24,7 @@ import foundation.e.apps.domain.model.install.Status /* * Map raw application + contextual signals into a single button state. * Keep pure: no side effects; callers handle actions. * Keep pure: no side effects; callers handle acntions. */ fun mapAppToInstallState(input: InstallButtonStateInput): InstallButtonState { return when (val status = input.resolvedStatus) { Loading