293e14f7664e
fix(sw): register onMessage handler synchronously at SW startup + gate unlock-dependent handlers on a restore-ready promise — fixes cold-start race where the popup's HAS_VAULT hit a listenerless worker and flashed onboarding; preserves the no-spurious-lock fix (v3.6.130)
- time
- 2026-05-28 23:24 UTC · 5 months ago
- machine
- koh2
- parent
- d4136f2febfd
↓ peach-browser-293e14f7.tar.gzatlas at this save
files changed6 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/entry.ts+16 −11src/background/messages.test.ts+22 −0src/background/messages.ts+18 −0manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22 "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33 "manifest_version": 3,44 "name": "Peach Password Manager",5- "version": "3.6.129",5+ "version": "3.6.130",66 "description": "Zero-knowledge password manager",77 "browser_specific_settings": {88 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "3.6.129",4+ "version": "3.6.130",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": "3.6.129",3+ "version": "3.6.130",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/background/entry.ts+16 −11
@@ -1,4 +1,4 @@1-import { setupMessageHandler } from "./messages";1+import { setupMessageHandler, setRestoreReady } from "./messages";22 import { setupAutoLock } from "./alarms";33 import { setupPasskeyProxy } from "./passkey";44 import { setupCommands } from "./commands";@@ -29,14 +29,19 @@2929 });3030 });3131 32-export async function start(): Promise<void> {33- // Restore vault unlock state from chrome.storage.session if the MV3 service34- // worker was restarted mid-session. This MUST complete before the message35- // handler is registered: a popup/content message that arrives in the gap36- // between handler registration and restore completing would see a locked37- // vault and bounce the user to the lock screen even though the session is38- // still unlocked (SEV-3-B). The cost is one session-storage read (<1ms).39- await restoreFromSession();32+export function start(): void {33+ // MV3: the chrome.runtime.onMessage listener MUST be registered during the34+ // worker's initial SYNCHRONOUS evaluation. If registration is deferred behind35+ // an await, a popup/content message that wakes a cold worker is dispatched36+ // before the listener exists → "receiving end does not exist", and the popup37+ // misreads the failed HAS_VAULT as "no vault" and shows onboarding.38+ //39+ // So: kick off session-restore, hand its promise to the message layer as a40+ // gate, and register the handler synchronously. Unlock-dependent handlers41+ // await the gate (see handleMessage), which preserves the SEV-3-B fix — no42+ // spurious lock screen — without delaying listener registration.43+ const restored = restoreFromSession();44+ setRestoreReady(restored);4045 setupMessageHandler();4146 void setupAutoLock();4247 setupCommands();@@ -45,7 +50,7 @@4550 // `triggerSyncIfReachable` is a no-op (the runner slot starts null) — the4651 // pairing/transport/coordinator stack ships dormant until this assignment.4752 setSyncRunner(productionSyncRunner);48- void hydratePersistedSyncState();53+ void restored.then(() => hydratePersistedSyncState()).catch(() => undefined);4954 }5055 51-void start();56+start();src/background/messages.test.ts+22 −0
@@ -790,6 +790,28 @@790790 expect(storage.peach_pro_celebrated).toBe(true);791791 });792792 793+ it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {794+ const messages = await import("./messages");795+ const vault = await import("./vault");796+797+ let resolveGate: () => void = () => {};798+ messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));799+800+ let settled = false;801+ const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });802+803+ // While restore is in flight the handler must NOT have answered yet —804+ // this is the window where the old code returned "no vault" → onboarding.805+ await new Promise((r) => setTimeout(r, 20));806+ expect(settled).toBe(false);807+808+ // Restore completes (the vault becomes available), gate opens.809+ await vault.createVault("correct-pass-12345");810+ resolveGate();811+812+ await expect(pending).resolves.toMatchObject({ ok: true, data: true });813+ });814+793815 it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {794816 const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });795817 expect(res.ok).toBe(false);src/background/messages.ts+18 −0
@@ -200,11 +200,29 @@200200 return undefined;201201 }202202 203+// Resolves once restoreFromSession() has run, so handlers see the correct204+// post-restore unlock state. entry.ts registers this BEFORE the message205+// handler so the onMessage listener is added synchronously at SW startup206+// (MV3 requirement) while unlock-dependent handlers still wait for restore.207+let restoreReady: Promise<void> = Promise.resolve();208+209+/** Called by entry.ts at startup with the in-flight restoreFromSession promise. */210+export function setRestoreReady(p: Promise<unknown>): void {211+ restoreReady = p.then(() => undefined, () => undefined);212+}213+203214 export async function handleMessage(204215 msg: Message,205216 sender: Partial<chrome.runtime.MessageSender> = {}206217 ): Promise<MessageResponse> {207218 try {219+ // Wait for session-restore to finish before reading any unlock/vault state.220+ // On a cold SW start the popup's first message arrives while restore is221+ // still in flight; without this gate IS_UNLOCKED would report locked (a222+ // spurious lock screen) and the storage reads could race. The onMessage223+ // listener itself is registered synchronously (entry.ts) so this gate, not224+ // listener registration, is what absorbs the cold-start window.225+ await restoreReady;208226 // Validate sender — only allow messages from our extension pages/scripts.209227 // `sender.id` is undefined for messages originating from extension UI210228 // (popup/options), which is legitimate; reject only when it is defined