727903630472
treat a peer's post-DONE hang-up as a completed sync, not errored; add CONNECTION_LOST outcome and a WARN indicator tier so network-class misses (relay/handshake/timeout/connection_lost) read amber, not red, mirroring the browser fix (v2.7.8)
- time
- 2026-07-28 00:47 UTC · 2 months ago
- machine
- koh2
- agent
- claude-MiniMax-M3
- session
- sync red indicator port from browser
- parent
- 33515d267786
↓ peach-android-72790363.tar.gzatlas at this save
files changed7 files
app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt+27 −2app/src/main/java/com/peachpasswords/sync/SyncOutcomeReason.kt+44 −7app/src/main/java/com/peachpasswords/ui/components/SyncIndicatorDot.kt+1 −0app/src/main/java/com/peachpasswords/ui/theme/PeachPalette.kt+2 −0app/src/test/java/com/peachpasswords/sync/SyncCoordinatorTest.kt+59 −0app/src/test/java/com/peachpasswords/sync/SyncOutcomeTest.kt+26 −4app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616 applicationId "com.peachpasswords"1717 minSdk 261818 targetSdk 3519- versionCode 24013420- versionName "2.7.7"19+ versionCode 24013520+ versionName "2.7.8"2121 2222 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323 vectorDrawables {app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt+27 −2
@@ -114,7 +114,7 @@114114 115115 if (hello.version == localVersion) {116116 session.sendMsg(SyncMessage.Done(0, 0))117- session.recvAs<SyncMessage.Done>("initiator", "SYNC_DONE")117+ session.awaitClosingDone("initiator", "SYNC_DONE")118118 return@withTimeout SyncOutcome(SyncOutcomeStatus.COMPLETED)119119 }120120 @@ -163,7 +163,7 @@163163 }164164 165165 session.sendMsg(SyncMessage.Done(entriesReceived, entriesSent))166- session.recvAs<SyncMessage.Done>("initiator", "SYNC_DONE")166+ session.awaitClosingDone("initiator", "SYNC_DONE")167167 SyncOutcome(168168 SyncOutcomeStatus.COMPLETED,169169 entriesReceived, entriesSent, tombstonesReceived, tombstonesSent,@@ -309,4 +309,29 @@309309 requireSyncFrameSize(bytes.size)310310 val msg = decodeSyncMessage(bytes)311311 return msg as? T ?: throw IllegalStateException("expected $expected, got ${msg::class.simpleName}")312+}313+314+/**315+ * Wait for the peer's closing SYNC_DONE echo, treating a hang-up as success.316+ *317+ * By the time we send our own SYNC_DONE, every entry and tombstone has already318+ * been exchanged and applied on both sides — the echo is a courtesy, not a319+ * commit. The peer closes the socket as soon as it has our DONE; previously320+ * the parked receive() threw and the run was reported as ERRORED → run_failed321+ * → red, even though the data had demonstrably landed.322+ *323+ * A wrong *message* here is still a real protocol error — only a closed324+ * transport is forgiven. Mirrors the browser's `awaitClosingDone`.325+ */326+private suspend fun SyncSession.awaitClosingDone(327+ role: String,328+ expected: String,329+): Boolean {330+ return try {331+ recvAs<SyncMessage.Done>(role, expected)332+ true333+ } catch (e: Exception) {334+ if (isConnectionClosedError(e.message ?: "")) true335+ else throw e336+ }312337 }app/src/main/java/com/peachpasswords/sync/SyncOutcomeReason.kt+44 −7
@@ -43,6 +43,7 @@4343 HANDSHAKE_FAILED,4444 VAULT_ID_MISMATCH,4545 SYNC_TIMEOUT,46+ CONNECTION_LOST,4647 RUN_FAILED,4748 ;4849 @@ -56,10 +57,7 @@5657 PRIVATE_RELAY_UNSET,5758 VAULT_LOCKED,5859 FOREGROUND_START_BLOCKED,59- RELAY_UNREACHABLE,60- HANDSHAKE_FAILED,6160 VAULT_ID_MISMATCH,62- SYNC_TIMEOUT,6361 RUN_FAILED,6462 -> true6563 @@ -67,8 +65,28 @@6765 COMPLETED_NO_CHANGES,6866 NO_PAIRED_DEVICES,6967 SYNC_DISABLED,68+ RELAY_UNREACHABLE,69+ HANDSHAKE_FAILED,70+ SYNC_TIMEOUT,71+ CONNECTION_LOST,7072 -> false7173 }74+75+ /**76+ * Network-class outcomes — the dial did not reach the peer this attempt.77+ * The background reconnect loop keeps trying, so the light goes amber78+ * rather than red. Mirrors the browser's WARN tier.79+ */80+ val isWarning: Boolean81+ get() = when (this) {82+ RELAY_UNREACHABLE,83+ HANDSHAKE_FAILED,84+ SYNC_TIMEOUT,85+ CONNECTION_LOST,86+ -> true87+88+ else -> false89+ }7290 }7391 7492 /**@@ -90,15 +108,18 @@90108 )91109 92110 /**93- * The one indicator light. [BUSY] pulses green, [OK] is solid green, [ERROR] is94- * red, [IDLE] is unlit. Nothing that is actually broken may resolve to [OK] — a95- * paired-but-unsyncable device is red, not green.111+ * The one indicator light. [BUSY] pulses green, [OK] is solid green, [WARN] is112+ * amber (a transient network miss — the background loop retries), [ERROR] is113+ * red (the app is actually broken), [IDLE] is unlit. Nothing that is actually114+ * broken may resolve to [OK] — a paired-but-unsyncable device is red, not115+ * green.96116 */97-enum class SyncIndicator { BUSY, OK, ERROR, IDLE }117+enum class SyncIndicator { BUSY, OK, WARN, ERROR, IDLE }98118 99119 fun syncIndicator(summary: SyncOutcomeSummary, pairedCount: Int): SyncIndicator {100120 if (summary.syncing) return SyncIndicator.BUSY101121 val reason = summary.reason122+ if (reason != null && reason.isWarning) return SyncIndicator.WARN102123 if (reason != null && reason.isFailure) return SyncIndicator.ERROR103124 if (reason == SyncOutcomeReason.COMPLETED_CHANGES ||104125 reason == SyncOutcomeReason.COMPLETED_NO_CHANGES@@ -114,6 +135,7 @@114135 private fun indicatorHeadline(indicator: SyncIndicator): String = when (indicator) {115136 SyncIndicator.BUSY -> "Syncing"116137 SyncIndicator.OK -> "Sync healthy"138+ SyncIndicator.WARN -> "Last sync attempt missed — will retry"117139 SyncIndicator.ERROR -> "Sync problem"118140 SyncIndicator.IDLE -> "Not syncing"119141 }@@ -143,6 +165,7 @@143165 SyncOutcomeReason.HANDSHAKE_FAILED -> "Reached the relay, but the device didn't answer"144166 SyncOutcomeReason.VAULT_ID_MISMATCH -> "That device holds a different vault"145167 SyncOutcomeReason.SYNC_TIMEOUT -> "The device stopped responding mid-sync"168+ SyncOutcomeReason.CONNECTION_LOST -> "The connection dropped mid-sync — will retry"146169 SyncOutcomeReason.RUN_FAILED -> "Sync failed"147170 }148171 if (reason == SyncOutcomeReason.COMPLETED_CHANGES) {@@ -168,10 +191,24 @@168191 // relay was fine and the peer was not who we expected.169192 Regex("handshake|PAIR_IDENTITY|identity does not match|capability does not match|clock-skew")170193 .containsMatchIn(error) -> SyncOutcomeReason.HANDSHAKE_FAILED194+ // A hang-up after a successful dial is a connection-lost, not a relay195+ // problem — checked before the broad relay/websocket regex below so it196+ // doesn't get swallowed into RELAY_UNREACHABLE. The browser does the same197+ // narrow check.198+ isConnectionClosedError(error) -> SyncOutcomeReason.CONNECTION_LOST171199 Regex("relay_|websocket|Connection|connect|socket|Unable to resolve host", RegexOption.IGNORE_CASE)172200 .containsMatchIn(error) -> SyncOutcomeReason.RELAY_UNREACHABLE173201 else -> SyncOutcomeReason.RUN_FAILED174202 }203+204+/**205+ * A transport that hung up, as opposed to a protocol or vault problem.206+ * Mirrors `isConnectionClosedError` in the browser's sync-outcome.ts; the two207+ * must stay in lockstep so a close on either side maps to the same outcome.208+ */209+internal fun isConnectionClosedError(error: String): Boolean =210+ Regex("websocket closed|connection closed|Socket closed", RegexOption.IGNORE_CASE)211+ .containsMatchIn(error)175212 176213 /**177214 * Translate one finished session ([SyncCoordinator]'s per-round result) into theapp/src/main/java/com/peachpasswords/ui/components/SyncIndicatorDot.kt+1 −0
@@ -44,6 +44,7 @@4444 // Busy is green too, not amber: it is the same healthy sync, in4545 // progress. The pulse carries "in progress", the hue carries "well".4646 SyncIndicator.BUSY, SyncIndicator.OK -> peachColors.SyncOk47+ SyncIndicator.WARN -> peachColors.SyncWarn4748 SyncIndicator.ERROR -> peachColors.SyncErr4849 SyncIndicator.IDLE -> peachColors.T24950 },app/src/main/java/com/peachpasswords/ui/theme/PeachPalette.kt+2 −0
@@ -39,6 +39,7 @@3939 val SyncOk: Color,4040 val SyncBusy: Color,4141 val SyncErr: Color,42+ val SyncWarn: Color,4243 // Shell top fade4344 val ShellTopSolid: Color,4445 val ShellTopFade: Color,@@ -79,6 +80,7 @@7980 SyncOk = PeachColors.SyncOk,8081 SyncBusy = PeachColors.SyncBusy,8182 SyncErr = PeachColors.SyncErr,83+ SyncWarn = PeachColors.SyncBusy,8284 ShellTopSolid = PeachColors.ShellTopSolid,8385 ShellTopFade = PeachColors.ShellTopFade,8486 )app/src/test/java/com/peachpasswords/sync/SyncCoordinatorTest.kt+59 −0
@@ -157,4 +157,63 @@157157 val wire = decodeSyncMessage(SyncMessage.DeviceManifest(null).encodeToBytes())158158 assertEquals(SyncMessage.DeviceManifest(null), wire)159159 }160+161+ /**162+ * Bug: the initiator sends its final SYNC_DONE only after every entry,163+ * tombstone and (optional) device manifest has already been exchanged and164+ * applied on both sides. The peer's reply is a courtesy, not a commit.165+ * When the peer closes the socket as soon as it has our DONE, the parked166+ * receive() throws "WebSocket closed"; previously the initiator's round167+ * was reported as ERRORED → run_failed → red, even though the data had168+ * demonstrably landed.169+ *170+ * The responder side already returned as soon as it had sent its own171+ * DONE, so it was never affected — the fix is one-sided.172+ */173+ @Test fun peer_hangup_during_closing_done_is_still_completed() = runBlocking {174+ val (sa, sb) = duplex()175+ val a = FakeAdapter("A", version = 1).apply { put("x", 100) }176+ val b = FakeAdapter("B", version = 2).apply { put("y", 200) }177+178+ val pk = ByteArray(32)179+ // Hijack the initiator's receive: feed each frame in turn, then throw180+ // a "WebSocket closed" IOException after the initiator's DONE goes181+ // out, simulating a peer that hung up the moment it had our echo.182+ val frames: ArrayDeque<ByteArray> = ArrayDeque()183+ // 1: peer's HELLO (same vault, different version, with a future184+ // mtime so the equal-version short-circuit doesn't fire).185+ frames.add(186+ SyncMessage.Hello("VAULT", "B", version = 2, deviceTime = 0L).encodeToBytes(),187+ )188+ // 2: peer's MANIFEST (y@200).189+ frames.add(190+ SyncMessage.Manifest(b.entries, b.tombstones).encodeToBytes(),191+ )192+ // 3: peer's REQUEST (asking for x).193+ frames.add(SyncMessage.Request(listOf("x")).encodeToBytes())194+ // 4: peer's ENTRIES.195+ frames.add(SyncMessage.Entries(b.blobs).encodeToBytes())196+ // 5: peer's TOMBSTONES.197+ frames.add(SyncMessage.Tombstones(emptyMap()).encodeToBytes())198+ // 6: nothing — the socket hangs up before the peer sends its DONE.199+ val hijackedReceive: suspend () -> ByteArray = {200+ frames.removeFirstOrNull()201+ ?: throw java.io.IOException("WebSocket closed")202+ }203+ val sa2 = SyncSession(204+ remoteStaticPublicKey = pk,205+ send = { /* ignore — duplex's send writes to sb's inbox */ },206+ receive = hijackedReceive,207+ close = {},208+ )209+ // sb is a real duplex half so b's adapter sees our HELLO/MANIFEST/210+ // REQUEST/ENTRIES/TOMBSTONES/DONE through the channel.211+ val rb = async { runSyncResponder(sb, b, nowMs = 0L) }212+ val outA = runSyncInitiator(sa2, a, nowMs = 0L)213+ rb.await()214+215+ assertEquals(SyncOutcomeStatus.COMPLETED, outA.status)216+ // The data still crossed: b got x.217+ assertTrue(a.blobs.containsKey("y"))218+ }160219 }app/src/test/java/com/peachpasswords/sync/SyncOutcomeTest.kt+26 −4
@@ -34,6 +34,11 @@3434 * reason out of it would otherwise flip red to unlit with every other test3535 * still green. "Paired, but nothing here can accept a sync" in particular3636 * is a problem, not a quiet state.37+ *38+ * Network-class outcomes (relay/handshake/timeout/connection_lost) live in39+ * [SyncOutcomeReason.isWarning] and resolve to the amber light, not red —40+ * the background loop keeps trying, so a transient miss is a warning, not41+ * a problem.3742 */3843 @Test fun `the failing outcomes are exactly these`() {3944 assertEquals(@@ -44,17 +49,26 @@4449 // Android refused to start the runner: nothing synced, and the4550 // person can do something about it.4651 SyncOutcomeReason.FOREGROUND_START_BLOCKED,47- SyncOutcomeReason.RELAY_UNREACHABLE,48- SyncOutcomeReason.HANDSHAKE_FAILED,4952 SyncOutcomeReason.VAULT_ID_MISMATCH,50- SyncOutcomeReason.SYNC_TIMEOUT,5153 SyncOutcomeReason.RUN_FAILED,5254 ),5355 SyncOutcomeReason.entries.filter { it.isFailure }.toSet(),5456 )5557 }5658 57- @Test fun `every failure is red and nothing else is`() {59+ @Test fun `the warning outcomes are exactly the network class`() {60+ assertEquals(61+ setOf(62+ SyncOutcomeReason.RELAY_UNREACHABLE,63+ SyncOutcomeReason.HANDSHAKE_FAILED,64+ SyncOutcomeReason.SYNC_TIMEOUT,65+ SyncOutcomeReason.CONNECTION_LOST,66+ ),67+ SyncOutcomeReason.entries.filter { it.isWarning }.toSet(),68+ )69+ }70+71+ @Test fun `every failure is red and every warning is amber, and nothing else is either`() {5872 for (reason in SyncOutcomeReason.entries) {5973 val indicator = syncIndicator(SyncOutcomeSummary(reason = reason), pairedCount = 1)6074 assertEquals(@@ -62,6 +76,11 @@6276 reason.isFailure,6377 indicator == SyncIndicator.ERROR,6478 )79+ assertEquals(80+ "$reason: isWarning and the amber light must agree",81+ reason.isWarning,82+ indicator == SyncIndicator.WARN,83+ )6584 }6685 }6786 @@ -117,6 +136,7 @@117136 assertEquals("Reached the relay, but the device didn't answer", copy(SyncOutcomeReason.HANDSHAKE_FAILED))118137 assertEquals("That device holds a different vault", copy(SyncOutcomeReason.VAULT_ID_MISMATCH))119138 assertEquals("The device stopped responding mid-sync", copy(SyncOutcomeReason.SYNC_TIMEOUT))139+ assertEquals("The connection dropped mid-sync — will retry", copy(SyncOutcomeReason.CONNECTION_LOST))120140 assertEquals("Sync failed", copy(SyncOutcomeReason.RUN_FAILED))121141 }122142 @@ -159,6 +179,8 @@159179 classifySyncError("reconnect capability does not match relay room"),160180 )161181 assertEquals(SyncOutcomeReason.RELAY_UNREACHABLE, classifySyncError("relay_closed_1006"))182+ assertEquals(SyncOutcomeReason.CONNECTION_LOST, classifySyncError("WebSocket closed"))183+ assertEquals(SyncOutcomeReason.CONNECTION_LOST, classifySyncError("connection closed by peer"))162184 assertEquals(SyncOutcomeReason.RUN_FAILED, classifySyncError("something nobody predicted"))163185 }164186