cca4cca7f47e

fix: Flush final adoption acknowledgement (v2.7.4)

time
2026-07-27 22:05 UTC · 2 months ago
machine
koh2
parent
c13446e90fea
4 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+18 −0app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+34 −2app/src/test/java/com/peachpasswords/sync/RelayAdoptionHostTest.kt+48 −4
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24013020-        versionName "2.7.3"19+        versionCode 24013120+        versionName "2.7.4"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+18 −0
@@ -455,6 +455,15 @@455455     fun send(frame: ByteArray)456456     suspend fun receive(): ByteArray457457     fun close(code: Int = 1000, reason: String = "relay session complete")458+    /**459+     * Finish a successful exchange without discarding frames already accepted460+     * by the WebSocket send queue. Most test transports are synchronous, so461+     * their ordinary close already has this behavior.462+     */463+    fun closeAfterQueuedFrames(464+        code: Int = 1000,465+        reason: String = "relay session complete",466+    ) = close(code, reason)458467 }459468 460469 /**@@ -624,6 +633,15 @@624633                 // releases its single-occupant room immediately.625634                 connected.close(code, reason)626635                 connected.cancel()636+                incoming.close()637+            }638+639+            override fun closeAfterQueuedFrames(code: Int, reason: String) {640+                // OkHttp transmits already-enqueued messages before its close641+                // frame. Do not immediately cancel here: cancellation discards642+                // the final adoption acknowledgement and creates a false643+                // failure on the source after both pair records are durable.644+                connected.close(code, reason)627645                 incoming.close()628646             }629647         }

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+34 −2
@@ -22,7 +22,34 @@2222         val invitation = parsePairingInvitation(encodedInvitation)2323             ?: throw IllegalArgumentException("invalid_pairing_invitation")2424         val socket = OkHttpRelaySocket.connect(invitation.roomUrl())25-        return try {25+        return adoptFromRelaySocket(26+            socket = socket,27+            invitation = invitation,28+            deps = deps,29+            confirm = confirm,30+            windowMs = windowMs,31+        )32+    }33+}34+35+/**36+ * Complete one receiver session and choose the correct socket shutdown.37+ *38+ * The final receiver action is an encrypted ADOPT_APPLIED frame. OkHttp's39+ * send() only queues it, so a successful session must close gracefully; an40+ * immediate cancel can discard that frame after both devices have already41+ * persisted one another, leaving the source to report a false failure.42+ */43+internal suspend fun adoptFromRelaySocket(44+    socket: RelayBinarySocket,45+    invitation: PairingInvitation,46+    deps: AdoptionReceiverDeps,47+    confirm: suspend (sas: String) -> Boolean,48+    windowMs: Long = PAIRING_WINDOW_MS,49+): PairedDevice {50+    var completed = false51+    return try {52+        val adopted =2653             withTimeout(windowMs) {2754                 runRelayAdoptionReceiverSession(2855                     socket = socket,@@ -31,7 +58,12 @@3158                     confirm = confirm,3259                 ) ?: throw IllegalStateException("adoption_rejected")3360             }34-        } finally {61+        completed = true62+        adopted63+    } finally {64+        if (completed) {65+            socket.closeAfterQueuedFrames()66+        } else {3567             socket.close()3668         }3769     }

view file

app/src/test/java/com/peachpasswords/sync/RelayAdoptionHostTest.kt+48 −4
@@ -27,6 +27,47 @@2727         }2828     }2929 30+    /**31+     * Models OkHttp's asynchronous send queue: each receive flushes the prior32+     * outbound frame, but ADOPT_APPLIED has no following receive and therefore33+     * reaches the peer only through a graceful close.34+     */35+    private class QueuedFinalFrameSocket(36+        private val delegate: RelayBinarySocket,37+    ) : RelayBinarySocket {38+        private val queued = mutableListOf<ByteArray>()39+        var gracefullyClosed = false40+            private set41+        var aborted = false42+            private set43+44+        override fun send(frame: ByteArray) {45+            queued += frame.copyOf()46+        }47+48+        override suspend fun receive(): ByteArray {49+            flush()50+            return delegate.receive()51+        }52+53+        override fun close(code: Int, reason: String) {54+            aborted = true55+            queued.clear()56+            delegate.close(code, reason)57+        }58+59+        override fun closeAfterQueuedFrames(code: Int, reason: String) {60+            gracefullyClosed = true61+            flush()62+            delegate.closeAfterQueuedFrames(code, reason)63+        }64+65+        private fun flush() {66+            queued.forEach(delegate::send)67+            queued.clear()68+        }69+    }70+3071     private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {3172         val leftToRight = Channel<ByteArray>(Channel.UNLIMITED)3273         val rightToLeft = Channel<ByteArray>(Channel.UNLIMITED)@@ -205,10 +246,11 @@205246             )206247         }207248 208-        val received = runRelayAdoptionReceiverSession(209-            receiverSocket,210-            PairingInvitation(relay, token),211-            object : AdoptionReceiverDeps {249+        val queuedReceiverSocket = QueuedFinalFrameSocket(receiverSocket)250+        val received = adoptFromRelaySocket(251+            socket = queuedReceiverSocket,252+            invitation = PairingInvitation(relay, token),253+            deps = object : AdoptionReceiverDeps {212254                 override val deviceName = "receiver"213255                 override val deviceType = DeviceType.ANDROID214256                 override fun generateDeviceId() = "session-only-request"@@ -231,6 +273,8 @@231273 232274         assertNotNull(received)233275         assertNotNull(sourceJob.await())276+        assertTrue(queuedReceiverSocket.gracefullyClosed)277+        assertFalse(queuedReceiverSocket.aborted)234278         assertEquals(sourceSas, receiverSas)235279         assertEquals(relay, sourceStored?.relayUrl)236280         assertEquals(relay, receiverStored?.relayUrl)

view file