cca4cca7f47e
fix: Flush final adoption acknowledgement (v2.7.4)
- time
- 2026-07-27 22:05 UTC · 2 months ago
- machine
- koh2
- parent
- c13446e90fea
↓ peach-android-cca4cca7.tar.gzatlas at this save
files changed4 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 −4app/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 {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 }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 }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)