88f4c97bcaab
treat a peer-closed WebSocket after entry exchange as a completed sync, not run_failed; classify mid-protocol disconnects as connection_lost (yellow) so the indicator stops turning red on syncs that demonstrably landed (v4.3.5)
- time
- 2026-07-27 23:21 UTC · 3 months ago
- machine
- koh2
- agent
- claude-MiniMax-M3
- session
- sync false-failure and bug trio
- parent
- aee880956950
↓ peach-browser-88f4c97b.tar.gzatlas at this save
files changed7 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-coordinator.test.ts+39 −1src/background/sync-coordinator.ts+35 −8src/background/sync-trigger.ts+13 −1src/shared/sync-outcome.ts+19 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.3.4",4+ "version": "4.3.5",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.3.4",4+ "version": "4.3.5",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "4.3.4",3+ "version": "4.3.5",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/sync-coordinator.test.ts+39 −1
@@ -10,6 +10,8 @@1010 const b: Uint8Array[] = [];1111 const aR: Array<(v: Uint8Array) => void> = [];1212 const bR: Array<(v: Uint8Array) => void> = [];13+ const aRej: Array<(e: Error) => void> = [];14+ let closed = false;1315 1416 const ourSession: SyncSession = {1517 send(bytes) {@@ -19,7 +21,8 @@1921 receive(): Promise<Uint8Array> {2022 const v = a.shift();2123 if (v) return Promise.resolve(v);22- return new Promise<Uint8Array>((res) => aR.push(res));24+ if (closed) return Promise.reject(new Error("WebSocket closed"));25+ return new Promise<Uint8Array>((res, rej) => { aR.push(res); aRej.push(rej); });2326 },2427 async close() { /* no-op */ },2528 remoteStaticPublicKey: new Uint8Array(32),@@ -36,6 +39,16 @@3639 const bytes = v ?? await new Promise<Uint8Array>((res) => bR.push(res));3740 return JSON.parse(new TextDecoder().decode(bytes)) as SyncMessage;3841 },42+ /**43+ * Android's listener closes the socket the moment it has our final44+ * SYNC_DONE — there is no courtesy echo. Surface that as a "WebSocket45+ * closed" rejection on the initiator's parked receive().46+ */47+ async closeInitiatorReceive() {48+ if (closed) return;49+ closed = true;50+ while (aRej.length > 0) aRej.shift()!(new Error("WebSocket closed"));51+ },3952 };4053 return [ourSession, peer];4154 }@@ -43,6 +56,8 @@4356 interface FakePeer {4457 send(msg: SyncMessage): Promise<void>;4558 receive(): Promise<SyncMessage>;59+ /** Hang up on the initiator's parked receive(), as Android does at DONE. */60+ closeInitiatorReceive(): Promise<void>;4661 }4762 4863 function makeVaultAdapter(opts: {@@ -357,5 +372,28 @@357372 const outcome = await initPromise;358373 expect(outcome.status).toBe("errored");359374 expect(outcome.error).toMatch(/SYNC_MANIFEST/);375+ });376+377+ it("reports completed when the peer closes the socket while we wait for its closing SYNC_DONE", async () => {378+ // Bug: by the time we send our own SYNC_DONE, every entry and tombstone379+ // has already been exchanged and applied on both sides. The peer's reply380+ // is a courtesy, not a commit. Android closes the socket the moment it381+ // has our DONE; previously the initiator's parked receive() rejected with382+ // "WebSocket closed" and the run was reported as `errored` → run_failed383+ // → red, even though the data had demonstrably landed.384+ const [session, peer] = inMemoryPair();385+ const vault = makeVaultAdapter({ localVersion: 5 });386+387+ const initPromise = runSyncInitiator(session, vault);388+ void (async () => {389+ await peer.receive(); // hello390+ await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 5, deviceTime: Date.now() });391+ await peer.receive(); // our done392+ // No SYNC_DONE echo — Android just hangs up.393+ await peer.closeInitiatorReceive();394+ })();395+396+ const outcome = await initPromise;397+ expect(outcome.status).toBe("completed");360398 });361399 });src/background/sync-coordinator.ts+35 −8
@@ -20,6 +20,7 @@2020 import type { SyncMessage, SignedDeviceManifest } from "../shared/sync-types";2121 import type { SyncSession } from "../shared/sync-transport";2222 import { SYNC_LIMITS } from "../shared/sync-limits";23+import { isConnectionClosedError } from "../shared/sync-outcome";2324 2425 const SESSION_TIMEOUT_MS = 30_000;2526 @@ -144,10 +145,8 @@144145 if (typeof helloIn.version === "number" && helloIn.version === localVersion) {145146 await sendMessage(session, { type: "SYNC_DONE", entriesReceived: 0, entriesSent: 0 });146147 stage = "waiting_for_device_done";147- const done = await receiveMessage(session, timer);148- if (done.type !== "SYNC_DONE") {149- return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);150- }148+ const closing = await awaitClosingDone(session, timer);149+ if (closing.error) return errorOutcome(result, closing.error);151150 result.status = "completed";152151 return result;153152 }@@ -239,10 +238,8 @@239238 entriesSent: result.entriesSent,240239 });241240 stage = "waiting_for_device_done";242- const done = await receiveMessage(session, timer);243- if (done.type !== "SYNC_DONE") {244- return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);245- }241+ const closing = await awaitClosingDone(session, timer);242+ if (closing.error) return errorOutcome(result, closing.error);246243 result.status = "completed";247244 return result;248245 } catch (err) {@@ -257,6 +254,36 @@257254 // closes it in its own finally block. The coordinator must not close it258255 // here — the runner still needs to record the outcome after we return.259256 timer.cancel();257+ }258+}259+260+/**261+ * Wait for the peer's closing SYNC_DONE echo, treating a hang-up as success.262+ *263+ * By the time we send our own SYNC_DONE, every entry and tombstone has already264+ * been exchanged and applied on both sides — the echo is a courtesy, not a265+ * commit. Android closes the socket as soon as it has our DONE, so the parked266+ * receive() rejects with "WebSocket closed"; counting that as `run_failed`267+ * reported a red indicator for syncs that had fully succeeded and whose data268+ * had demonstrably landed on the peer.269+ *270+ * A wrong *message* here is still a real protocol error — only a closed271+ * transport is forgiven.272+ */273+async function awaitClosingDone(274+ session: SyncSession,275+ timer: Timer,276+): Promise<{ error?: string }> {277+ try {278+ const done = await receiveMessage(session, timer);279+ if (done.type !== "SYNC_DONE") {280+ return { error: `expected SYNC_DONE, got ${done.type}` };281+ }282+ return {};283+ } catch (err) {284+ if ((err as Error).message === "sync_timeout") throw err;285+ if (isConnectionClosedError(err)) return {};286+ return { error: (err as Error).message };260287 }261288 }262289 src/background/sync-trigger.ts+13 −1
@@ -19,7 +19,11 @@1919 setLastSyncResult,2020 } from "./paired-devices";2121 import type { PairedDevice } from "../shared/sync-types";22-import { isSyncFailure, type SyncOutcome } from "../shared/sync-outcome";22+import {23+ isConnectionClosedError,24+ isSyncFailure,25+ type SyncOutcome,26+} from "../shared/sync-outcome";2327 2428 // `alarm` — the 10-minute chrome.alarms background tick. The only2529 // trigger that survives MV3 service-worker death.@@ -249,6 +253,14 @@249253 if (error.includes("private_relay_unset")) return "private_relay_unset";250254 if (error.includes("vault_id_mismatch")) return "vault_id_mismatch";251255 if (error.includes("sync_timeout")) return "sync_timeout";256+ // A transport that hung up mid-protocol. The alarm retries, and the peer may257+ // well have applied what we already sent, so this is network-class (yellow)258+ // rather than "the app is broken" (red). A close during the *dial* is excluded259+ // here and falls to the branch below, where `relay_unreachable` is the more260+ // specific diagnosis.261+ if (!error.startsWith("relay_sync_failed") && isConnectionClosedError(error)) {262+ return "connection_lost";263+ }252264 if (error.startsWith("relay_sync_failed")) {253265 return /handshake|PAIR_IDENTITY|identity does not match/i.test(error)254266 ? "handshake_failed"src/shared/sync-outcome.ts+19 −0
@@ -17,6 +17,7 @@1717 | "handshake_failed"1818 | "vault_id_mismatch"1919 | "sync_timeout"20+ | "connection_lost"2021 | "run_failed";2122 2223 /**@@ -45,8 +46,25 @@4546 "relay_unreachable",4647 "handshake_failed",4748 "sync_timeout",49+ "connection_lost",4850 ]);4951 52+/**53+ * A transport that hung up, as opposed to a protocol or vault problem.54+ *55+ * Lives here because two callers need the same judgement and must not drift:56+ * the coordinator (a close while waiting for the peer's final SYNC_DONE echo57+ * is a completed sync) and the trigger's error classifier (a close anywhere58+ * else is network-class, so yellow rather than red).59+ *60+ * Deliberately does not match SyncFrameTooLargeError's "WebSocket close 1009"61+ * — an oversized frame is a misbehaving peer and must keep failing.62+ */63+export function isConnectionClosedError(err: unknown): boolean {64+ const message = err instanceof Error ? err.message : String(err);65+ return /websocket closed|connection closed/i.test(message);66+}67+5068 export function isSyncFailure(outcome: SyncOutcome): boolean {5169 return FAILURES.has(outcome);5270 }@@ -117,6 +135,7 @@117135 handshake_failed: "Reached the relay, but the device didn't answer",118136 vault_id_mismatch: "That device holds a different vault",119137 sync_timeout: "The device stopped responding mid-sync",138+ connection_lost: "The connection dropped mid-sync — will retry",120139 run_failed: "Sync failed",121140 };122141