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
6 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 −0
manifest.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": {

view file

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",

view file

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": {

view file

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();

view file

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);

view file

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

view file