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
↓ peach-browser-5b989b3f.tar.gzatlas at this save
files changed6 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 −1manifest.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": {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",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",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({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);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 {