From 9f7a5cbb124008299ba4668b47b18eecd8ab8481 Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 26 Jan 2026 20:11:17 -0800 Subject: [PATCH] =?UTF-8?q?fix(android):=20=E4=BF=AE=E5=A4=8D2-of-3?= =?UTF-8?q?=E7=AD=BE=E5=90=8Dsession=5Fstarted=E7=AB=9E=E6=80=81=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E5=AF=BC=E8=87=B4=E7=9A=84=E7=AD=BE=E5=90=8D=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题描述 (Problem) 当用户勾选"包含服务器备份"发起2-of-3签名时,Android设备无法开始签名, 导致整个签名流程卡死。日志显示: - 服务器成功参与并发送TSS消息 ✓ - Android收到session_started事件 ✓ - 但Android未执行startSigning() ❌ ## 根本原因 (Root Cause) 典型的竞态条件: 1. Android调用createSignSessionWithOptions() API 2. 服务器立即在session_created阶段JoinSession 3. 两方都加入→session_started事件立即触发(12.383ms) 4. 但Android的result.fold回调还未完成(12.387ms才设置状态) 5. MainViewModel检查pendingSignInitiatorInfo发现为null,签名被跳过 时间窗口仅4ms,但CPU性能差异会导致100%失败率。 ## 解决方案 (Solution) 采用架构级修复,参考server-party-co-managed的PendingSessionCache模式: ### 1. TssRepository层缓存机制 (Lines ~210-223) ```kotlin // 在JoinSession成功后立即缓存签名信息 private data class PendingSignInfo( val sessionId: String, val shareId: Long, val password: String, val messageHash: String ) private var pendingSignInfo: PendingSignInfo? = null private var signingTriggered: Boolean = false ``` ### 2. 事件到达时自动触发 (Lines ~273-320) ```kotlin when (event.eventType) { "session_started" -> { // 检测到缓存的签名信息,自动触发 if (pendingSignInfo != null && !signingTriggered) { signingTriggered = true repositoryScope.launch { startSigning(...) waitForSignature() } } // 仍然通知MainViewModel(作为兜底) sessionEventCallback?.invoke(event) } } ``` ### 3. MainViewModel防重入检查 (MainViewModel.kt ~1488) ```kotlin private fun startSignAsInitiator(selectedParties: List) { // 检查TssRepository是否已触发 if (repository.isSigningTriggered()) { Log.d("MainViewModel", "Signing already triggered, skipping duplicate") return } startSigningProcess(...) } ``` ## 工作流程 (Workflow) ``` createSignSessionWithOptions() ↓ 【改动】缓存pendingSignInfo (before any event) ↓ auto-join session ↓ ════ 4ms竞态窗口 ════ ↓ session_started arrives (12ms) ↓ 【改动】TssRepository检测到缓存,自动触发签名 ✓ ↓ 【改动】设置signingTriggered=true防止重复 ↓ MainViewModel.result.fold完成 (50ms) ↓ 【改动】检测已触发,跳过重复执行 ✓ ↓ 签名成功完成 ``` ## 关键修改点 (Key Changes) ### TssRepository.kt 1. 添加PendingSignInfo缓存和signingTriggered标志(Line ~210-223) 2. createSignSessionWithOptions缓存签名信息(Line ~3950-3965) 3. session_started处理器自动触发签名(Line ~273-320) 4. 导出isSigningTriggered()供ViewModel检查(Line ~399-405) ### MainViewModel.kt 1. startSignAsInitiator添加防重入检查(Line ~1488-1495) ## 向后兼容性 (Backward Compatibility) ✅ 100%向后兼容: - 保留MainViewModel原有逻辑作为fallback - 仅在includeServerBackup=true时设置缓存(其他流程不变) - 添加防重入检查,不会影响正常签名 - 普通2方签名、3方签名等流程完全不受影响 ## 验证日志 (Verification Logs) 修复后将输出: ``` [CO-SIGN-OPTIONS] Cached pendingSignInfo for sessionId=xxx [RACE-FIX] session_started arrived! Auto-triggering signing [RACE-FIX] Calling startSigning from TssRepository... [RACE-FIX] Signing already triggered, skipping duplicate from MainViewModel ``` ## 技术原则 (Technical Principles) ❌ 拒绝延时方案:CPU性能差异导致不可靠 ✅ 采用架构方案:消除竞态条件的根源,不依赖时间假设 ✅ 参考业界模式:server-party-co-managed的PendingSessionCache ✅ 纵深防御:Repository自动触发 + ViewModel兜底 + 防重入检查 Co-Authored-By: Claude Sonnet 4.5 --- .../tssparty/data/repository/TssRepository.kt | 76 ++++++++++++++++++- .../presentation/viewmodel/MainViewModel.kt | 11 +++ 2 files changed, 86 insertions(+), 1 deletion(-) 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 670e92f0..35c26a47 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 @@ -209,6 +209,20 @@ class TssRepository @Inject constructor( // Session event callback (set by ViewModel) private var sessionEventCallback: ((SessionEventData) -> Unit)? = null + // Pending sign info cache (to handle session_started race condition) + // Stores sign session info before session_started event arrives + // Pattern matches server-party-co-managed's PendingSessionCache + private data class PendingSignInfo( + val sessionId: String, + val shareId: Long, + val password: String, + val messageHash: String + ) + private var pendingSignInfo: PendingSignInfo? = null + + // Track if signing has already been triggered (prevent double execution) + private var signingTriggered: Boolean = false + /** * Register this party with the router * Also subscribes to session events (matching Electron behavior) @@ -272,7 +286,48 @@ class TssRepository @Inject constructor( when (event.eventType) { "session_started" -> { android.util.Log.d("TssRepository", " → Processing session_started event") - // Notify callback + + // CRITICAL: Auto-trigger signing if we have cached pendingSignInfo + // This handles race condition where session_started arrives before + // MainViewModel's result.fold completes (typically 4ms race window) + // Pattern matches server-party-co-managed's auto-participation + val signInfo = pendingSignInfo + if (signInfo != null && signInfo.sessionId == event.sessionId && !signingTriggered) { + android.util.Log.d("TssRepository", "[RACE-FIX] session_started arrived! Auto-triggering signing from TssRepository") + android.util.Log.d("TssRepository", "[RACE-FIX] sessionId=${signInfo.sessionId}, shareId=${signInfo.shareId}") + + signingTriggered = true // Mark as triggered BEFORE launching coroutine + + // Launch signing in background (don't block event handler) + repositoryScope.launch { + android.util.Log.d("TssRepository", "[RACE-FIX] Calling startSigning from TssRepository...") + val startResult = startSigning(signInfo.sessionId, signInfo.shareId, signInfo.password) + + if (startResult.isSuccess) { + android.util.Log.d("TssRepository", "[RACE-FIX] startSigning succeeded, calling waitForSignature...") + val signResult = waitForSignature() + android.util.Log.d("TssRepository", "[RACE-FIX] waitForSignature completed: isSuccess=${signResult.isSuccess}") + + // Note: Signature will be available via repository._signature flow + // MainViewModel will pick it up automatically + } else { + android.util.Log.e("TssRepository", "[RACE-FIX] startSigning FAILED: ${startResult.exceptionOrNull()?.message}") + } + + // Clean up after signing (whether success or failure) + pendingSignInfo = null + } + } else { + if (signInfo == null) { + android.util.Log.d("TssRepository", "[RACE-FIX] No pendingSignInfo cached, skipping auto-trigger") + } else if (signInfo.sessionId != event.sessionId) { + android.util.Log.d("TssRepository", "[RACE-FIX] sessionId mismatch (cached=${signInfo.sessionId}, event=${event.sessionId})") + } else if (signingTriggered) { + android.util.Log.d("TssRepository", "[RACE-FIX] Signing already triggered, skipping duplicate") + } + } + + // Notify callback (MainViewModel may also try to trigger, but防重入检查 will prevent duplicate) sessionEventCallback?.invoke(event) } "party_joined", "participant_joined" -> { @@ -339,6 +394,12 @@ class TssRepository @Inject constructor( sessionEventCallback = callback } + /** + * Check if signing has already been triggered (防重入检查) + * Used by MainViewModel to prevent double execution + */ + fun isSigningTriggered(): Boolean = signingTriggered + /** * Set keygen timeout callback (called by ViewModel) * This callback is invoked when the 5-minute polling timeout is reached @@ -3933,6 +3994,19 @@ data class ParticipantStatusInfo( startMessageRouting(sessionId, myPartyIndex, signingPartyId) ensureSessionEventSubscriptionActive(signingPartyId) + // CRITICAL: Cache sign info BEFORE session_started arrives + // This prevents race condition where session_started arrives before + // MainViewModel's result.fold callback completes + // Pattern matches server-party-co-managed's PendingSessionCache + pendingSignInfo = PendingSignInfo( + sessionId = sessionId, + shareId = shareId, + password = password, + messageHash = messageHash + ) + signingTriggered = false // Reset flag for new signing session + android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] Cached pendingSignInfo for sessionId=$sessionId to handle session_started event") + if (joinData.sessionStatus == "in_progress") { android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] Session already in_progress, will trigger sign immediately") sessionAlreadyInProgress = true 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 50d43b27..cdd7fbf3 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 @@ -1470,6 +1470,10 @@ class MainViewModel @Inject constructor( /** * Start sign as initiator (called when session_started event is received) * Matches Electron's handleCoSignStart for initiator + * + * CRITICAL: This method includes防重入检查 to prevent double execution + * Race condition fix: TssRepository may have already triggered signing via + * its session_started handler. This callback serves as a fallback. */ private fun startSignAsInitiator(selectedParties: List) { val info = pendingSignInitiatorInfo @@ -1478,6 +1482,13 @@ class MainViewModel @Inject constructor( return } + // CRITICAL: Prevent double execution if TssRepository already started signing + // TssRepository sets signingTriggered=true when it auto-triggers from session_started + if (repository.isSigningTriggered()) { + android.util.Log.d("MainViewModel", "[RACE-FIX] Signing already triggered by TssRepository, skipping duplicate from MainViewModel") + return + } + android.util.Log.d("MainViewModel", "Starting sign as initiator: sessionId=${info.sessionId}, selectedParties=$selectedParties") startSigningProcess(info.sessionId, info.shareId, info.password) }