6d4f0fd4366a

fix: Refresh stale relay waiters on sync now (v2.5.19)

- Replace existing relay sockets when the service is nudged - Keep idle WebSockets alive with protocol pings - Preserve a conflated refresh request across repeated starts

time
2026-07-27 18:20 UTC · 2 months ago
machine
koh2
agent
codex-gpt-5
session
peach-sync-e2e
parent
e5e2965ac456
4 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+44 −4app/src/main/java/com/peachpasswords/sync/PeachSyncService.kt+4 −1app/src/test/java/com/peachpasswords/sync/CloudRelayReconnectListenerTest.kt+23 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24011820-        versionName "2.5.18"19+        versionCode 24011920+        versionName "2.5.19"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+44 −4
@@ -54,6 +54,7 @@5454 private const val RELAY_CONNECT_TIMEOUT_MS = 10_000L5555 private const val RELAY_INBOX_CAPACITY = 85656 private const val PEACH_SYNC_LOG_TAG = "PeachSync"57+internal const val RELAY_PING_INTERVAL_SECONDS = 20L5758 5859 internal object PeachSyncLog {5960     @Volatile@@ -90,6 +91,31 @@9091     val eligible: List<PairedDevice>,9192 )9293 94+internal data class RelayReconcileRequest(95+    val requested: Boolean,96+    val refreshExisting: Boolean,97+)98+99+/**100+ * Conflates service nudges without losing the stronger "replace the existing101+ * sockets" request. The service can be started repeatedly from different UI102+ * surfaces, so this state is deliberately thread-safe.103+ */104+internal class RelayReconcileRequests {105+    private val requested = AtomicBoolean(false)106+    private val refreshExisting = AtomicBoolean(false)107+108+    fun request(refreshExisting: Boolean) {109+        requested.set(true)110+        if (refreshExisting) this.refreshExisting.set(true)111+    }112+113+    fun consume(): RelayReconcileRequest = RelayReconcileRequest(114+        requested = requested.getAndSet(false),115+        refreshExisting = refreshExisting.getAndSet(false),116+    )117+}118+93119 internal fun logRelayReconcileInventory(94120     deviceStore: PairedDeviceRegistry,95121 ): RelayReconcileInventory {@@ -128,7 +154,7 @@128154      * nothing at all.129155      */130156     private val reconcileNow = Channel<Unit>(Channel.CONFLATED)131-    private val reconcileRequested = AtomicBoolean(false)157+    private val reconcileRequests = RelayReconcileRequests()132158     private var retainedPrivateKey: ByteArray? = null133159     private var retainedPublicKey: ByteArray? = null134160 @@ -147,9 +173,19 @@147173         supervisor = scope.launch(Dispatchers.IO) {148174             try {149175                 while (isActive) {150-                    if (reconcileRequested.getAndSet(false)) {176+                    val request = reconcileRequests.consume()177+                    if (request.requested) {151178                         PeachSyncLog.i("cloud-relay: reconcile requested")152179                     }180+                    if (request.refreshExisting) {181+                        // An idle WebSocket can remain locally "active" after182+                        // the relay or network path has stopped routing it.183+                        // Manual sync must replace that socket, not merely184+                        // observe the still-running coroutine and skip it.185+                        watchers.values.forEach(Job::cancel)186+                        watchers.clear()187+                        PeachSyncLog.i("cloud-relay: refreshing relay waiters")188+                    }153189                     reconcile(154190                         this,155191                         privateKey,@@ -167,8 +203,8 @@167203     }168204 169205     /** Re-read the device list at once instead of on the next interval. */170-    fun requestReconcile() {171-        reconcileRequested.set(true)206+    fun requestReconcile(refreshExisting: Boolean = false) {207+        reconcileRequests.request(refreshExisting)172208         reconcileNow.trySend(Unit)173209     }174210 @@ -509,6 +545,10 @@509545 internal object OkHttpRelaySocket {510546     private val client = OkHttpClient.Builder()511547         .readTimeout(0, TimeUnit.MILLISECONDS)548+        // Relay waiters are intentionally long-lived. A protocol ping keeps549+        // intermediaries from expiring an otherwise silent socket and makes550+        // a broken path fail promptly so the watcher loop can reconnect.551+        .pingInterval(RELAY_PING_INTERVAL_SECONDS, TimeUnit.SECONDS)512552         .build()513553 514554     suspend fun connect(url: String): RelayBinarySocket = withContext(Dispatchers.IO) {

view file

app/src/main/java/com/peachpasswords/sync/PeachSyncService.kt+4 −1
@@ -75,7 +75,10 @@7575         // "sync now", the app coming forward. Re-read the device list now7676         // rather than on the next interval; the listener is already running7777         // on every start but the first.78-        cloudRelayListener.requestReconcile()78+        // Replace existing waiters as well as re-reading the device list. A79+        // locally active coroutine does not prove its idle WebSocket still80+        // routes through the relay, and "sync now" must recover that state.81+        cloudRelayListener.requestReconcile(refreshExisting = true)7982         // START_STICKY: if the system reclaims us under pressure, come back —8083         // a listener that silently stays down looks identical to a peer that8184         // has simply gone offline, and the user gets no signal either way.

view file

app/src/test/java/com/peachpasswords/sync/CloudRelayReconnectListenerTest.kt+23 −0
@@ -47,6 +47,29 @@4747 4848 class CloudRelayReconnectListenerTest {4949     @Test50+    fun `relay reconcile requests preserve a conflated waiter refresh`() {51+        val requests = RelayReconcileRequests()52+53+        requests.request(refreshExisting = true)54+        requests.request(refreshExisting = false)55+56+        assertEquals(57+            RelayReconcileRequest(requested = true, refreshExisting = true),58+            requests.consume(),59+        )60+        assertEquals(61+            RelayReconcileRequest(requested = false, refreshExisting = false),62+            requests.consume(),63+        )64+    }65+66+    @Test67+    fun `relay websocket keepalive is shorter than the reconcile interval`() {68+        assertTrue(RELAY_PING_INTERVAL_SECONDS > 0)69+        assertTrue(RELAY_PING_INTERVAL_SECONDS * 1_000 < 60_000L)70+    }71+72+    @Test5073     fun `reconcile logs paired and eligible counts when no peer passes the filter`() = runBlocking {5174         val devices = listOf(5275             PairedDevice(

view file