From b30017f3a7940a7fb2626f575d9cc6015a1ac31e Mon Sep 17 00:00:00 2001 From: hailin Date: Thu, 1 Jan 2026 19:05:13 -0800 Subject: [PATCH] fix(android): prevent memory leaks from detached coroutine scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical fixes to prevent app crashes when screens are kept open for extended periods: - Add repositoryScope with SupervisorJob for structured concurrency in TssRepository - Replace detached CoroutineScope(Dispatchers.IO).launch with repositoryScope.launch: - Session event subscription (line 206) - Session status polling (line 291) - Message routing (line 1508) - Add cleanup() method to properly cancel all jobs and repositoryScope - Update disconnect() to also cancel sessionStatusPollingJob - Update MainViewModel.onCleared() to call repository.cleanup() This ensures all background coroutines are properly cancelled when the ViewModel is cleared, preventing memory accumulation over time. šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude/settings.local.json | 3 ++- .../tssparty/data/repository/TssRepository.kt | 23 ++++++++++++++++--- .../presentation/viewmodel/MainViewModel.kt | 2 +- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a59c4252..76093dbf 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -553,7 +553,8 @@ "Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" install golang.org/x/mobile/cmd/gobind@c31d5b91ecc32c0d598b8fe8457d244ca0b4e815)", "Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" mod tidy)", "Bash(adb devices:*)", - "Bash(adb logcat:*)" + "Bash(adb logcat:*)", + "Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(android\\): add 5-minute polling timeout mechanism for keygen/sign\n\nImplements Electron''s checkAndTriggerKeygen\\(\\) polling fallback:\n- Adds polling every 2 seconds with 5-minute timeout\n- Triggers keygen/sign via synthetic session_started event on in_progress status\n- Handles gRPC stream disconnection when app goes to background\n- Shows timeout error in UI via existing error mechanism\n\nšŸ¤– Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n\\)\")" ], "deny": [], "ask": [] diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt index c44fc64a..faaa9ebf 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/data/repository/TssRepository.kt @@ -63,6 +63,10 @@ class TssRepository @Inject constructor( // Called when 5-minute polling timeout is reached without session starting private var keygenTimeoutCallback: ((String) -> Unit)? = null + // Repository-level CoroutineScope for background tasks + // Uses SupervisorJob so individual task failures don't cancel other tasks + private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + companion object { // Polling interval for session status check (matching Electron's 2-second interval) private const val POLL_INTERVAL_MS = 2000L @@ -142,6 +146,19 @@ class TssRepository @Inject constructor( fun disconnect() { messageCollectionJob?.cancel() sessionEventJob?.cancel() + sessionStatusPollingJob?.cancel() + grpcClient.disconnect() + } + + /** + * Cleanup all resources when the repository is destroyed. + * Call this when the app is being destroyed to prevent memory leaks. + */ + fun cleanup() { + messageCollectionJob?.cancel() + sessionEventJob?.cancel() + sessionStatusPollingJob?.cancel() + repositoryScope.cancel() grpcClient.disconnect() } @@ -199,7 +216,7 @@ class TssRepository @Inject constructor( private fun startSessionEventSubscription() { sessionEventJob?.cancel() android.util.Log.d("TssRepository", "Starting session event subscription for partyId: $partyId") - sessionEventJob = CoroutineScope(Dispatchers.IO).launch { + sessionEventJob = repositoryScope.launch { grpcClient.subscribeSessionEvents(partyId).collect { event -> android.util.Log.d("TssRepository", "=== Session event received ===") android.util.Log.d("TssRepository", " eventType: ${event.eventType}") @@ -284,7 +301,7 @@ class TssRepository @Inject constructor( android.util.Log.d("TssRepository", "[POLLING] Starting session status polling for $sessionType session: $sessionId") - sessionStatusPollingJob = CoroutineScope(Dispatchers.IO).launch { + sessionStatusPollingJob = repositoryScope.launch { val startTime = System.currentTimeMillis() var pollCount = 0 @@ -1501,7 +1518,7 @@ class TssRepository @Inject constructor( currentMessageRoutingPartyIndex = partyIndex messageCollectionJob?.cancel() - messageCollectionJob = CoroutineScope(Dispatchers.IO).launch { + messageCollectionJob = repositoryScope.launch { // Collect outgoing messages from TSS and route via gRPC launch { tssNativeBridge.outgoingMessages.collect { message -> diff --git a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt index 65c96121..6800fa59 100644 --- a/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt +++ b/backend/mpc-system/services/service-party-android/app/src/main/java/com/durian/tssparty/presentation/viewmodel/MainViewModel.kt @@ -1204,7 +1204,7 @@ class MainViewModel @Inject constructor( override fun onCleared() { super.onCleared() - repository.disconnect() + repository.cleanup() } }