When a background SDK task fails, react in the client (#6166)
- For initialization issues or errors, we just print and report them. - For panics (unrecoverable errors) we also crash the app.
This commit is contained in:
parent
ee0da6b15c
commit
f6b8c189e1
4 changed files with 88 additions and 3 deletions
|
|
@ -10,11 +10,14 @@ package io.element.android.libraries.matrix.impl
|
||||||
|
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||||
|
import io.element.android.libraries.matrix.impl.core.SdkBackgroundTaskError
|
||||||
import io.element.android.libraries.matrix.impl.mapper.toSessionData
|
import io.element.android.libraries.matrix.impl.mapper.toSessionData
|
||||||
import io.element.android.libraries.matrix.impl.paths.getSessionPaths
|
import io.element.android.libraries.matrix.impl.paths.getSessionPaths
|
||||||
import io.element.android.libraries.matrix.impl.util.anonymizedTokens
|
import io.element.android.libraries.matrix.impl.util.anonymizedTokens
|
||||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.matrix.rustcomponents.sdk.ClientDelegate
|
import org.matrix.rustcomponents.sdk.ClientDelegate
|
||||||
import org.matrix.rustcomponents.sdk.ClientSessionDelegate
|
import org.matrix.rustcomponents.sdk.ClientSessionDelegate
|
||||||
|
|
@ -23,6 +26,7 @@ import timber.log.Timber
|
||||||
import uniffi.matrix_sdk_common.BackgroundTaskFailureReason
|
import uniffi.matrix_sdk_common.BackgroundTaskFailureReason
|
||||||
import java.lang.ref.WeakReference
|
import java.lang.ref.WeakReference
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
private val loggerTag = LoggerTag("RustClientSessionDelegate")
|
private val loggerTag = LoggerTag("RustClientSessionDelegate")
|
||||||
|
|
||||||
|
|
@ -36,6 +40,7 @@ private val loggerTag = LoggerTag("RustClientSessionDelegate")
|
||||||
class RustClientSessionDelegate(
|
class RustClientSessionDelegate(
|
||||||
private val sessionStore: SessionStore,
|
private val sessionStore: SessionStore,
|
||||||
private val appCoroutineScope: CoroutineScope,
|
private val appCoroutineScope: CoroutineScope,
|
||||||
|
private val analyticsService: AnalyticsService,
|
||||||
coroutineDispatchers: CoroutineDispatchers,
|
coroutineDispatchers: CoroutineDispatchers,
|
||||||
) : ClientSessionDelegate, ClientDelegate {
|
) : ClientSessionDelegate, ClientDelegate {
|
||||||
// Used to ensure several calls to `didReceiveAuthError` don't trigger multiple logouts
|
// Used to ensure several calls to `didReceiveAuthError` don't trigger multiple logouts
|
||||||
|
|
@ -122,8 +127,18 @@ class RustClientSessionDelegate(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBackgroundTaskErrorReport(taskName: String, error: BackgroundTaskFailureReason) {
|
override fun onBackgroundTaskErrorReport(taskName: String, error: BackgroundTaskFailureReason) {
|
||||||
// TODO actually implement the missing logic to report to sentry and crash the app
|
val backgroundTaskError = SdkBackgroundTaskError(taskName, error)
|
||||||
Timber.tag(loggerTag.value).e("onBackgroundTaskErrorReport(taskName=$taskName, error=$error)")
|
Timber.e(backgroundTaskError, "SDK background task failed")
|
||||||
|
analyticsService.trackError(backgroundTaskError)
|
||||||
|
|
||||||
|
if (error is BackgroundTaskFailureReason.Panic) {
|
||||||
|
appCoroutineScope.launch {
|
||||||
|
// The SDK failed in an unrecoverable way, so it will have indeterminate behaviour now.
|
||||||
|
// Crash the app instead after a small delay to send the error.
|
||||||
|
delay(500.milliseconds)
|
||||||
|
throw backgroundTaskError
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun retrieveSessionFromKeychain(userId: String): Session {
|
override fun retrieveSessionFromKeychain(userId: String): Session {
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,12 @@ class RustMatrixClientFactory(
|
||||||
private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider,
|
private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider,
|
||||||
private val workManagerScheduler: WorkManagerScheduler,
|
private val workManagerScheduler: WorkManagerScheduler,
|
||||||
) {
|
) {
|
||||||
private val sessionDelegate = RustClientSessionDelegate(sessionStore, appCoroutineScope, coroutineDispatchers)
|
private val sessionDelegate = RustClientSessionDelegate(
|
||||||
|
sessionStore = sessionStore,
|
||||||
|
appCoroutineScope = appCoroutineScope,
|
||||||
|
analyticsService = analyticsService,
|
||||||
|
coroutineDispatchers = coroutineDispatchers
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun create(sessionData: SessionData): RustMatrixClient = withContext(coroutineDispatchers.io) {
|
suspend fun create(sessionData: SessionData): RustMatrixClient = withContext(coroutineDispatchers.io) {
|
||||||
val client = getBaseClientBuilder(
|
val client = getBaseClientBuilder(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.impl.core
|
||||||
|
|
||||||
|
import uniffi.matrix_sdk_common.BackgroundTaskFailureReason
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error thrown when a background SDK task panics and can't recover.
|
||||||
|
* @param task The name of the task that failed.
|
||||||
|
* @param reason The cause of this error.
|
||||||
|
*/
|
||||||
|
class SdkBackgroundTaskError(
|
||||||
|
task: String,
|
||||||
|
reason: BackgroundTaskFailureReason,
|
||||||
|
) : Error() {
|
||||||
|
override val message: String = run {
|
||||||
|
val message = when (reason) {
|
||||||
|
is BackgroundTaskFailureReason.EarlyTermination -> "Early termination"
|
||||||
|
is BackgroundTaskFailureReason.Error -> "Error: ${reason.error}"
|
||||||
|
is BackgroundTaskFailureReason.Panic -> buildString {
|
||||||
|
append("Panic (unrecoverable): ")
|
||||||
|
reason.message?.let { append(it) }
|
||||||
|
reason.panicBacktrace?.let {
|
||||||
|
append("\n")
|
||||||
|
append(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"SDK background task '$task' failure: \n$message"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,16 +9,20 @@
|
||||||
package io.element.android.libraries.matrix.impl
|
package io.element.android.libraries.matrix.impl
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.matrix.impl.core.SdkBackgroundTaskError
|
||||||
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSession
|
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSession
|
||||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||||
import io.element.android.libraries.sessionstorage.test.aSessionData
|
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||||
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runCurrent
|
import kotlinx.coroutines.test.runCurrent
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import uniffi.matrix_sdk_common.BackgroundTaskFailureReason
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class RustClientSessionDelegateTest {
|
class RustClientSessionDelegateTest {
|
||||||
|
|
@ -44,12 +48,37 @@ class RustClientSessionDelegateTest {
|
||||||
assertThat(result!!.accessToken).isEqualTo("at")
|
assertThat(result!!.accessToken).isEqualTo("at")
|
||||||
assertThat(result.refreshToken).isEqualTo("rt")
|
assertThat(result.refreshToken).isEqualTo("rt")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `onBackgroundTaskErrorReport reports the error to analytics if recoverable`() = runTest {
|
||||||
|
val analyticsService = FakeAnalyticsService(isEnabled = true)
|
||||||
|
val sut = aRustClientSessionDelegate(analyticsService = analyticsService)
|
||||||
|
sut.onBackgroundTaskErrorReport("Crasher", BackgroundTaskFailureReason.EarlyTermination)
|
||||||
|
sut.onBackgroundTaskErrorReport("Crasher", BackgroundTaskFailureReason.Error("BOOM"))
|
||||||
|
|
||||||
|
assertThat(analyticsService.trackedErrors).hasSize(2)
|
||||||
|
assertThat(analyticsService.trackedErrors[0].message).isEqualTo("SDK background task 'Crasher' failure: \nEarly termination")
|
||||||
|
assertThat(analyticsService.trackedErrors[1].message).isEqualTo("SDK background task 'Crasher' failure: \nError: BOOM")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(expected = SdkBackgroundTaskError::class)
|
||||||
|
fun `onBackgroundTaskErrorReport reports the error to analytics and throws it if it's a panic`() = runTest {
|
||||||
|
val analyticsService = FakeAnalyticsService(isEnabled = true)
|
||||||
|
val sut = aRustClientSessionDelegate(analyticsService = analyticsService)
|
||||||
|
sut.onBackgroundTaskErrorReport("Crasher", BackgroundTaskFailureReason.Panic("BOOM", "Stacktrace"))
|
||||||
|
|
||||||
|
assertThat(analyticsService.trackedErrors).hasSize(1)
|
||||||
|
assertThat(analyticsService.trackedErrors[0].message)
|
||||||
|
.isEqualTo("SDK background task 'Crasher' failure: \nPanic (unrecoverable): BOOM\nStacktrace")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun TestScope.aRustClientSessionDelegate(
|
fun TestScope.aRustClientSessionDelegate(
|
||||||
sessionStore: SessionStore = InMemorySessionStore(),
|
sessionStore: SessionStore = InMemorySessionStore(),
|
||||||
|
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||||
) = RustClientSessionDelegate(
|
) = RustClientSessionDelegate(
|
||||||
sessionStore = sessionStore,
|
sessionStore = sessionStore,
|
||||||
appCoroutineScope = this,
|
appCoroutineScope = this,
|
||||||
|
analyticsService = analyticsService,
|
||||||
coroutineDispatchers = testCoroutineDispatchers(),
|
coroutineDispatchers = testCoroutineDispatchers(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue