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
7 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 −0
manifest.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": {

view file

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",

view file

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",

view file

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 });

view file

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 

view file

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"

view file

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 

view file