fix(android): 修复2-of-3签名session_started竞态条件导致的签名失败

## 问题描述 (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<String>) {
    // 检查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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-26 20:11:17 -08:00
parent dfc984f536
commit 9f7a5cbb12
2 changed files with 86 additions and 1 deletions

View File

@ -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

View File

@ -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<String>) {
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)
}