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
7 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 −4
app/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 {

view file

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 }

view file

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 the

view file

app/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         },

view file

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 )

view file

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 }

view file

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 

view file