dcb2093343f2
the dialler's outer bound stops racing the coordinator's session timeout: DIAL_SESSION_TIMEOUT_MS is now derived as SESSION_TIMEOUT_MS + 15s of handshake headroom (45s) instead of a hand-written 30s equal to it, so a stalled peer is always named SYNC_TIMEOUT by the coordinator rather than cancelled by whichever of two equal deadlines the scheduler reached first; the outer bound now only fires for the empty-room case it was written for, and reports it as HANDSHAKE_FAILED with a distinguishing detail line instead of rethrowing a cancellation nobody reports (v2.5.11)
- time
- 2026-07-26 19:54 UTC · 2 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- android sync service reliability
- parent
- 0c5ab4dfdcdf
↓ peach-android-dcb20933.tar.gzatlas at this save
files changed6 files
VERSION+1 −1app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+57 −16app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt+2 −1app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+3 −3app/src/test/java/com/peachpasswords/sync/CloudRelayReconnectListenerTest.kt+99 −0app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616 applicationId "com.peachpasswords"1717 minSdk 261818 targetSdk 3519- versionCode 24011020- versionName "2.5.10"19+ versionCode 24011120+ versionName "2.5.11"2121 2222 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323 vectorDrawables {app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+57 −16
@@ -6,6 +6,7 @@66 import kotlinx.coroutines.CoroutineScope77 import kotlinx.coroutines.Dispatchers88 import kotlinx.coroutines.Job9+import kotlinx.coroutines.TimeoutCancellationException910 import kotlinx.coroutines.channels.Channel1011 import kotlinx.coroutines.delay1112 import kotlinx.coroutines.isActive@@ -28,8 +29,24 @@2829 2930 private const val RECONCILE_INTERVAL_MS = 60_000L3031 private const val RETRY_DELAY_MS = 1_000L31-/** A dialled session that has not reached sync by now is talking to nobody. */32-private const val DIAL_SESSION_TIMEOUT_MS = 30_000L32+/**33+ * Headroom over [SESSION_TIMEOUT_MS] for the part of a dial the coordinator34+ * cannot see: the selector, KK and the two identity frames. Half again the 10s35+ * this file allows for opening the socket in the first place, which is the36+ * longest single wait anywhere on this path.37+ */38+private const val DIAL_HANDSHAKE_HEADROOM_MS = 15_000L39+40+/**41+ * A dialled session that has not reached sync by now is talking to nobody.42+ *43+ * It must clear [SESSION_TIMEOUT_MS] rather than equal it. The two were both44+ * 30s, so a genuinely stalled sync was a coin toss: whichever timer the45+ * scheduler reached first decided whether the user was told "the device stopped46+ * responding mid-sync" or nothing at all, since this bound cancels the session47+ * and a cancellation is rethrown unreported.48+ */49+internal const val DIAL_SESSION_TIMEOUT_MS = SESSION_TIMEOUT_MS + DIAL_HANDSHAKE_HEADROOM_MS3350 /** Slower than the waiter's retry, so the peer is already in the room. */3451 private const val DIAL_RETRY_DELAY_MS = 5_000L3552 private const val RELAY_CONNECT_TIMEOUT_MS = 10_000L@@ -109,6 +126,26 @@109126 retainedPublicKey = null110127 }111128 129+ /**130+ * Now that the bound clears [SESSION_TIMEOUT_MS], a session that stalls131+ * names itself SYNC_TIMEOUT and never reaches here — so reaching here means132+ * the room stayed empty and nobody ever answered the dial. Distinguishable133+ * from a mid-sync stall by the detail line.134+ *135+ * Reported on change only, like the [RelayDecision.Refuse] branch below: a136+ * peer that is simply switched off would otherwise rewrite the store every137+ * time we give up on it.138+ */139+ private fun reportDialUnanswered() {140+ if (syncOutcomes.summary.value.reason == SyncOutcomeReason.HANDSHAKE_FAILED) return141+ syncOutcomes.report(142+ SyncOutcomeSummary(143+ reason = SyncOutcomeReason.HANDSHAKE_FAILED,144+ detail = "no answer to our dial within ${DIAL_SESSION_TIMEOUT_MS / 1000}s",145+ ),146+ )147+ }148+112149 private fun reconcile(113150 scope: CoroutineScope,114151 privateKey: ByteArray,@@ -182,20 +219,24 @@182219 // the wait so we close, free the room and try183220 // again in the other arrival order, instead of184221 // both sockets sitting there forever.185- withTimeout(DIAL_SESSION_TIMEOUT_MS) {186- runCloudRelayInitiatorSession(187- socket = socket,188- known = known,189- rendezvousId = target.rendezvousId,190- ourStaticPrivateKey = privateKey,191- ourStaticPublicKey = publicKey,192- localIdentity = localIdentity,193- vaultFactory = vaultFactory,194- onSyncComplete = {195- deviceStore.updateLastSync(known.deviceId)196- },197- report = syncOutcomes::report,198- )222+ try {223+ withTimeout(DIAL_SESSION_TIMEOUT_MS) {224+ runCloudRelayInitiatorSession(225+ socket = socket,226+ known = known,227+ rendezvousId = target.rendezvousId,228+ ourStaticPrivateKey = privateKey,229+ ourStaticPublicKey = publicKey,230+ localIdentity = localIdentity,231+ vaultFactory = vaultFactory,232+ onSyncComplete = {233+ deviceStore.updateLastSync(known.deviceId)234+ },235+ report = syncOutcomes::report,236+ )237+ }238+ } catch (_: TimeoutCancellationException) {239+ reportDialUnanswered()199240 }200241 } else {201242 runCloudRelayResponderSession(app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt+2 −1
@@ -26,7 +26,8 @@2626 * Last-write-wins at the entry level by mtime; tombstones override updates with2727 * a later deletion timestamp.2828 */29-private const val SESSION_TIMEOUT_MS = 30_000L29+/** Internal so the dialler's outer bound can be derived from it, never guessed. */30+internal const val SESSION_TIMEOUT_MS = 30_000L3031 private const val CLOCK_SKEW_WARN_MS = 120_000L3132 3233 /**app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+3 −3
@@ -199,9 +199,9 @@199199 PeachSyncService.start(context)200200 // Nothing else can close this attempt: the phone usually WAITS to be201201 // dialled, so if no peer turns up there is no session to report a202- // failure from and the light would pulse for ever. Long enough to cover203- // a dial round-trip (DIAL_SESSION_TIMEOUT_MS), short enough to answer204- // the person holding the phone.202+ // failure from and the light would pulse for ever. Deliberately shorter203+ // than the dialler's own bound: this one answers the person holding the204+ // phone, and a session still running will report over it.205205 attemptTimeout?.cancel()206206 attemptTimeout = viewModelScope.launch {207207 delay(SYNC_ATTEMPT_TIMEOUT_MS)app/src/test/java/com/peachpasswords/sync/CloudRelayReconnectListenerTest.kt+99 −0
@@ -2,8 +2,12 @@22 33 import kotlinx.coroutines.Dispatchers44 import kotlinx.coroutines.async5+import kotlinx.coroutines.awaitCancellation56 import kotlinx.coroutines.channels.Channel7+import kotlinx.coroutines.launch68 import kotlinx.coroutines.runBlocking9+import kotlinx.coroutines.test.runTest10+import kotlinx.coroutines.withTimeout711 import org.bouncycastle.crypto.params.X25519PrivateKeyParameters812 import org.junit.Assert.assertEquals913 import org.junit.Assert.assertFalse@@ -11,6 +15,18 @@1115 import org.junit.Test1216 import java.security.SecureRandom1317 18+/** Enough of a vault to send SYNC_HELLO; the peer stalls before anything else. */19+private object SilentPeerVault : SyncVaultAdapter {20+ override suspend fun computeVaultId() = "VAULT"21+ override suspend fun getOurDeviceId() = "phone"22+ override suspend fun getLocalVersion() = 1L23+ override suspend fun buildEntryManifest() = emptyMap<String, Long>()24+ override suspend fun buildTombstoneManifest() = emptyMap<String, Long>()25+ override suspend fun getEncryptedEntries(entryIds: List<String>) = emptyMap<String, String>()26+ override suspend fun applyEntries(entries: Map<String, String>) {}27+ override suspend fun applyTombstones(tombstones: Map<String, Long>) {}28+}29+1430 class CloudRelayReconnectListenerTest {1531 private data class KeyPair(val publicKey: ByteArray, val privateKey: ByteArray)1632 @@ -109,6 +125,89 @@109125 // A locked vault used to be a bare `return` here — the peer got as far110126 // as an authenticated session and then nothing was said to anyone.111127 assertEquals(listOf(SyncOutcomeReason.VAULT_LOCKED), reported.map { it.reason })128+ }129+130+ /**131+ * The bound the dialler puts around a session must never be the one that132+ * fires for a stalled peer — the coordinator's own is the specific one, and133+ * this one cancels without reporting anything at all.134+ */135+ @Test136+ fun `a dial the peer stalls mid-sync is named by the coordinator, not cut short by the dialler`() = runTest {137+ val phoneKeys = keys()138+ val peerKeys = keys()139+ val secretHex = "63".repeat(32)140+ val rendezvousId = Rendezvous.dialingIds(141+ Rendezvous.secretFromHex(secretHex),142+ System.currentTimeMillis() / 1000L,143+ ).first()144+ val known = PairedDevice(145+ deviceId = "peer-phone",146+ deviceName = "Pixel 6",147+ deviceType = "android",148+ longTermPublicKeyHex = pubkeyToHex(peerKeys.publicKey),149+ rendezvousSecretHex = secretHex,150+ pairedAt = 1L,151+ )152+ val phoneIdentity = LocalDeviceIdentity(153+ deviceId = "phone",154+ deviceName = "Pixel",155+ deviceType = DeviceType.ANDROID,156+ longTermPublicKeyHex = pubkeyToHex(phoneKeys.publicKey),157+ signingPublicKeyHex = "22".repeat(32),158+ )159+ val peerIdentity = LocalDeviceIdentity(160+ deviceId = "peer-phone",161+ deviceName = "Pixel 6",162+ deviceType = DeviceType.ANDROID,163+ longTermPublicKeyHex = pubkeyToHex(peerKeys.publicKey),164+ signingPublicKeyHex = "33".repeat(32),165+ )166+ val (peerSocket, phoneSocket) = socketPair()167+ val reported = mutableListOf<SyncOutcomeSummary>()168+169+ // Answers everything up to SYNC_HELLO and then goes quiet, which is the170+ // stall the coordinator's timeout exists to name.171+ val peer = launch {172+ peerSocket.receive()173+ val session = noiseKKResponder(174+ peerKeys.privateKey,175+ peerKeys.publicKey,176+ phoneKeys.publicKey,177+ peerSocket::send,178+ peerSocket::receive,179+ )180+ session.decrypt(peerSocket.receive())181+ peerSocket.send(session.encrypt(SyncPadding.pad(encodePairIdentityFrame(peerIdentity))))182+ awaitCancellation()183+ }184+185+ // Exactly how CloudRelayReconnectListener wraps a dialled session.186+ withTimeout(DIAL_SESSION_TIMEOUT_MS) {187+ runCloudRelayInitiatorSession(188+ socket = phoneSocket,189+ known = known,190+ rendezvousId = rendezvousId,191+ ourStaticPrivateKey = phoneKeys.privateKey,192+ ourStaticPublicKey = phoneKeys.publicKey,193+ localIdentity = phoneIdentity,194+ vaultFactory = { SilentPeerVault },195+ onSyncComplete = {},196+ report = { reported += it },197+ )198+ }199+ peer.cancel()200+201+ assertEquals(listOf(SyncOutcomeReason.SYNC_TIMEOUT), reported.map { it.reason })202+ }203+204+ /**205+ * The same invariant stated directly, because the test above depends on206+ * which of two equal deadlines the scheduler happens to reach first.207+ */208+ @Test209+ fun `the dialler's bound leaves the coordinator room to time out first`() {210+ assertTrue(DIAL_SESSION_TIMEOUT_MS > SESSION_TIMEOUT_MS)112211 }113212 114213 @Test