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
6 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 −0
VERSION+1 −1
@@ -1 +1 @@1-2.5.101+2.5.11

view file

app/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 {

view file

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(

view file

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 /**

view file

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)

view file

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

view file