bcae1b330341
fix: resilient post-adoption sync fallback (v4.5.1)
- time
- 2026-07-30 06:23 UTC · 2 months ago
- machine
- koh2
- parent
- dbac78ce978b
↓ peach-browser-bcae1b33.tar.gzatlas at this save
files changed5 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 −4manifest.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": {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",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",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) {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+}