6d4f0fd4366a
fix: Refresh stale relay waiters on sync now (v2.5.19)
- time
- 2026-07-27 18:20 UTC · 2 months ago
- machine
- koh2
- agent
- codex-gpt-5
- session
- peach-sync-e2e
- parent
- e5e2965ac456
↓ peach-android-6d4f0fd4.tar.gzatlas at this save
files changed4 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 −0app/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 {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) {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.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(