5b989b3f66e2

fix: Preserve sync timeout stage (v4.2.16)

time
2026-07-27 18:44 UTC · 3 months ago
machine
koh2
agent
codex-gpt-5
session
peach-sync-e2e
parent
c6bed9d2aebe
6 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-coordinator.test.ts+20 −0src/background/sync-coordinator.ts+30 −1src/background/sync-runner.ts+1 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.2.15",4+  "version": "4.2.16",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.2.15",4+  "version": "4.2.16",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.2.15",3+  "version": "4.2.16",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/sync-coordinator.test.ts+20 −0
@@ -159,6 +159,26 @@159159 });160160 161161 describe("runSyncInitiator", () => {162+  it("reports the privacy-safe protocol stage when a device stops answering", async () => {163+    vi.useFakeTimers();164+    try {165+      const [session, peer] = inMemoryPair();166+      const vault = makeVaultAdapter({});167+      const initPromise = runSyncInitiator(session, vault);168+169+      const hello = await peer.receive();170+      expect(hello.type).toBe("SYNC_HELLO");171+172+      await vi.advanceTimersByTimeAsync(30_000);173+      await expect(initPromise).resolves.toMatchObject({174+        status: "timeout",175+        error: "sync_timeout:waiting_for_device_hello",176+      });177+    } finally {178+      vi.useRealTimers();179+    }180+  });181+162182   it("completes a full sync with bi-directional updates", async () => {163183     const [session, peer] = inMemoryPair();164184     const vault = makeVaultAdapter({

view file

src/background/sync-coordinator.ts+30 −1
@@ -23,6 +23,19 @@2323 2424 const SESSION_TIMEOUT_MS = 30_000;2525 26+type SyncStage =27+  | "preparing_hello"28+  | "waiting_for_device_hello"29+  | "building_local_manifest"30+  | "waiting_for_device_manifest"31+  | "waiting_for_device_request"32+  | "building_requested_entries"33+  | "waiting_for_device_entries"34+  | "waiting_for_device_tombstones"35+  | "building_device_manifest"36+  | "waiting_for_device_graph"37+  | "waiting_for_device_done";38+2639 /**2740  * Vault-side adapter — the coordinator's dependency on the vault layer.2841  * The vault holds the canonical entry/tombstone tables; the coordinator@@ -94,6 +107,11 @@94107   };95108 96109   const timer = scheduleTimeout(SESSION_TIMEOUT_MS);110+  // Privacy-safe progress only: stages contain no device identifiers, relay111+  // capabilities, keys, entry ids/counts, or vault content. Keeping the last112+  // stage turns an otherwise opaque 30-second timeout into an actionable113+  // cross-device diagnostic.114+  let stage: SyncStage = "preparing_hello";97115   try {98116     const ourVaultId = await vault.computeVaultId();99117     const ourDeviceId = await vault.getOurDeviceId();@@ -107,6 +125,7 @@107125       version: localVersion,108126       deviceTime: localTime,109127     });128+    stage = "waiting_for_device_hello";110129     const helloIn = await receiveMessage(session, timer);111130     if (helloIn.type !== "SYNC_HELLO") {112131       return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);@@ -124,6 +143,7 @@124143     // Equal versions — nothing to exchange.125144     if (typeof helloIn.version === "number" && helloIn.version === localVersion) {126145       await sendMessage(session, { type: "SYNC_DONE", entriesReceived: 0, entriesSent: 0 });146+      stage = "waiting_for_device_done";127147       const done = await receiveMessage(session, timer);128148       if (done.type !== "SYNC_DONE") {129149         return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);@@ -132,6 +152,7 @@132152       return result;133153     }134154 155+    stage = "building_local_manifest";135156     const ourEntries = await vault.buildEntryManifest();136157     const ourTombstones = await vault.buildTombstoneManifest();137158     await sendMessage(session, {@@ -139,6 +160,7 @@139160       entries: ourEntries,140161       tombstones: ourTombstones,141162     });163+    stage = "waiting_for_device_manifest";142164     const manifestIn = await receiveMessage(session, timer);143165     if (manifestIn.type !== "SYNC_MANIFEST") {144166       return errorOutcome(result, `expected SYNC_MANIFEST, got ${manifestIn.type}`);@@ -147,6 +169,7 @@147169     const wantFromPeer = diffNewer(manifestIn.entries, ourEntries);148170     await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer });149171 172+    stage = "waiting_for_device_request";150173     const requestIn = await receiveMessage(session, timer);151174     if (requestIn.type !== "SYNC_REQUEST") {152175       return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`);@@ -155,11 +178,13 @@155178     // declared as newer. A malformed/over-eager request is a protocol156179     // violation but not a data-safety problem — we just send what they ask.157180 181+    stage = "building_requested_entries";158182     const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds);159183     await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload });160184     result.entriesSent = Object.keys(ourPayload).length;161185 162186     if (wantFromPeer.length > 0) {187+      stage = "waiting_for_device_entries";163188       const entriesIn = await receiveMessage(session, timer);164189       if (entriesIn.type !== "SYNC_ENTRIES") {165190         return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`);@@ -178,6 +203,7 @@178203     await sendMessage(session, { type: "SYNC_TOMBSTONES", tombstones: newerTombstonesForPeer });179204     result.tombstonesSent = Object.keys(newerTombstonesForPeer).length;180205 206+    stage = "waiting_for_device_tombstones";181207     const tombstonesIn = await receiveMessage(session, timer);182208     if (tombstonesIn.type !== "SYNC_TOMBSTONES") {183209       return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`);@@ -191,8 +217,10 @@191217     // production adapter accepts only a directly paired issuer whose stored192218     // long-term key matches the Noise-authenticated remote static key.193219     if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {220+      stage = "building_device_manifest";194221       const ours = await vault.buildOutgoingDeviceManifest();195222       await sendMessage(session, { type: "SYNC_DEVICE_MANIFEST", manifest: ours });223+      stage = "waiting_for_device_graph";196224       const manifestIn = await receiveMessage(session, timer);197225       if (manifestIn.type !== "SYNC_DEVICE_MANIFEST") {198226         return errorOutcome(result, `expected SYNC_DEVICE_MANIFEST, got ${manifestIn.type}`);@@ -210,6 +238,7 @@210238       entriesReceived: result.entriesReceived,211239       entriesSent: result.entriesSent,212240     });241+    stage = "waiting_for_device_done";213242     const done = await receiveMessage(session, timer);214243     if (done.type !== "SYNC_DONE") {215244       return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);@@ -219,7 +248,7 @@219248   } catch (err) {220249     if ((err as Error).message === "sync_timeout") {221250       result.status = "timeout";222-      result.error = "session timed out after 30s";251+      result.error = `sync_timeout:${stage}`;223252       return result;224253     }225254     return errorOutcome(result, (err as Error).message);

view file

src/background/sync-runner.ts+1 −1
@@ -140,7 +140,7 @@140140       throw new Error("vault_id_mismatch");141141     }142142     if (outcome.status === "timeout") {143-      throw new Error("sync_timeout");143+      throw new Error(outcome.error ?? "sync_timeout");144144     }145145     throw new Error(outcome.error ?? "sync_errored");146146   } finally {

view file