bcae1b330341

fix: resilient post-adoption sync fallback (v4.5.1)

The inline snapshot fallback used a single fire-and-forget triggerSyncIfReachable plus a setTimeout retry that dies with the MV3 service worker. If the snapshot failed and the user navigated away, the vault stayed empty until the 10-minute alarm tick. - Snapshot receive timeout bumped 15s → 30s - setTimeout retry replaced with chrome.alarms one-shots at 30s and 2min (schedulePostAdoptionSync) — survives SW death - Both source and receiver session fallbacks use the alarm-based retry - Alarm listener in sync-alarm.ts handles the post-adoption prefix Gate: tsc clean, 3645 tests pass, Chrome/Firefox/Mobile builds pass.

time
2026-07-30 06:23 UTC · 2 months ago
machine
koh2
parent
dbac78ce978b
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/relay-adoption-source-bg.ts+8 −8src/background/sync-alarm.ts+33 −4
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.5.0",4+  "version": "4.5.1",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "4.5.0",5+  "version": "4.5.1",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.5.0",3+  "version": "4.5.1",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/relay-adoption-source-bg.ts+8 −8
@@ -43,6 +43,7 @@4343 import { markLastSyncedAt, upsertPairedDevice } from "./paired-devices";4444 import { createSyncVaultAdapter } from "./sync-vault-adapter";4545 import { triggerSyncIfReachable } from "./sync-trigger";46+import { schedulePostAdoptionSync } from "./sync-alarm";4647 4748 const SOURCE_WINDOW_MS = 5 * 60 * 1000;4849 @@ -308,13 +309,10 @@308309     }309310     push("ADOPTION_SOURCE_RESULT", { status: "complete", device });310311     // The Android receiver starts its relay listener as soon as its source311-    // record is durable. Try once now and once after service startup settles so312-    // the newly adopted empty vault receives its first sync without another313-    // user action. Both calls are best-effort and contain no user data.312+    // record is durable. Immediate best-effort trigger; alarm-based retries313+    // cover MV3 service-worker death (setTimeout does not survive it).314314     void triggerSyncIfReachable({ reason: "manual", force: true });315-    setTimeout(() => {316-      void triggerSyncIfReachable({ reason: "manual", force: true });317-    }, 1_000);315+    schedulePostAdoptionSync();318316   } catch (error) {319317     push("ADOPTION_SOURCE_RESULT", {320318       status: "error",@@ -449,7 +447,7 @@449447     // corrupt chunk) falls through to the existing sync pipeline below.450448     let snapshotApplied = false;451449     try {452-      const snapshot = await withTimeout(receiveVaultSnapshot(channel), 15_000);450+      const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);453451       const adapter = createSyncVaultAdapter();454452       await adapter.applyEntries(snapshot.entries);455453       await adapter.applyTombstones(snapshot.tombstones);@@ -504,8 +502,10 @@504502       // pending marker's job is done — clear it instead of making the popup wait.505503       await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);506504     } else {507-      // Fallback: the existing sync pipeline fills the vault.505+      // Fallback: the existing sync pipeline fills the vault. Immediate506+      // best-effort trigger plus alarm-based retries that survive SW death.508507       void triggerSyncIfReachable({ reason: "manual", force: true });508+      schedulePostAdoptionSync();509509     }510510   } catch (error) {511511     if (createdVault) {

view file

src/background/sync-alarm.ts+33 −4
@@ -1,11 +1,13 @@11 // sync-alarm.ts — the 10-minute background sync tick.22 //3-// Four triggers, in order of reliability:3+// Five triggers, in order of reliability:44 //   1. this alarm (survives MV3 service-worker death — only path that does)55 //   2. SW activation hook in entry.ts (startup reason — first dial without66 //      waiting for the alarm)77 //   3. device_list_changed on pairing/unpair (paired-devices.ts)88 //   4. vault_change on every committed write (vault.ts fireSyncTrigger)9+//   5. post-adoption one-shot (schedulePostAdoptionSync) — bridges the gap10+//      between a failed inline snapshot and the next 10-minute tick911 //1012 // All four call the same `triggerSyncIfReachable`. The in-flight guard there1113 // ensures a slow sync is never preempted by an alarm firing while it runs.@@ -27,6 +29,13 @@2729  */2830 export const SYNC_ALARM_PERIOD_MINUTES = 10;2931 32+/**33+ * One-shot alarm prefix for post-adoption sync retries. Unlike the periodic34+ * alarm, these are fire-once: Chrome clears them after they fire. The prefix35+ * lets the listener match any retry without a fixed name.36+ */37+const POST_ADOPTION_ALARM_PREFIX = "peach-post-adoption-";38+3039 let alarmListenerInstalled = false;3140 3241 /**@@ -43,8 +52,11 @@4352     // is top-level-imported via sync-trigger, which itself statically imports4453     // its deps — no cycle, no dynamic import.4554     chrome.alarms.onAlarm.addListener((alarm) => {46-      if (alarm.name !== SYNC_ALARM_NAME) return;47-      void triggerSyncIfReachable({ reason: "alarm" }).catch(() => undefined);55+      if (alarm.name === SYNC_ALARM_NAME) {56+        void triggerSyncIfReachable({ reason: "alarm" }).catch(() => undefined);57+      } else if (alarm.name.startsWith(POST_ADOPTION_ALARM_PREFIX)) {58+        void triggerSyncIfReachable({ reason: "alarm", force: true }).catch(() => undefined);59+      }4860     });4961     alarmListenerInstalled = true;5062   }@@ -62,4 +74,21 @@6274  */6375 export function _resetSyncAlarmForTest(): void {6476   alarmListenerInstalled = false;65-}\ No newline at end of file77+}78+79+/**80+ * Schedule one-shot sync retries after a failed inline snapshot. Uses81+ * chrome.alarms (survives SW death) instead of setTimeout (does not).82+ * Two retries: 30s and 2min. The 10-minute periodic alarm is the long-term83+ * backstop. Chrome's minimum alarm resolution is ~30s in stable.84+ */85+export function schedulePostAdoptionSync(): void {86+  if (!chrome.alarms?.create) return;87+  const now = Date.now();88+  chrome.alarms.create(`${POST_ADOPTION_ALARM_PREFIX}${now}-1`, {89+    delayInMinutes: 0.5,90+  });91+  chrome.alarms.create(`${POST_ADOPTION_ALARM_PREFIX}${now}-2`, {92+    delayInMinutes: 2,93+  });94+}

view file