32907007805d

feat: Add transport-injected sync session lifecycle (v2.13.22)

- Add runSyncSession that wires preflight, session-key fetch, frame dispatch, and outbound send to a SyncSessionTransport - Initiator role sends SYNC_HELLO on start; responder waits - Status: completed (peer SYNC_DONE), errored (preflight/transport/dispatch error), closed (transport.onClose) - Sending our own SYNC_DONE does not end the session — only peer SYNC_DONE/SYNC_ERROR/close does - Aggregate merge totals across SYNC_ENTRIES frames within a session - Cover bootstrap, preflight abort, transport error, merge propagation in 5 new lifecycle tests (22 total) Verification: npm typecheck, focused coordinator 22/22, npm test 274/274, npm run build clean.

time
2026-04-30 14:44 UTC · 6 months ago
machine
koh2
parent
35252658ce1f
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-coordinator.test.ts+231 −1src/background/sync-coordinator.ts+115 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.13.21",4+  "version": "2.13.22",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.13.21",4+  "version": "2.13.22",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": "2.13.21",3+  "version": "2.13.22",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/sync-coordinator.test.ts+231 −1
@@ -8,6 +8,8 @@88   checkSyncPreflight,99   dispatchSyncFrame,1010   planManifestSync,11+  runSyncSession,12+  type SyncSessionTransport,1113 } from "./sync-coordinator";1214 import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire";1315 import type { VaultEntry } from "../shared/types";@@ -323,7 +325,7 @@323325       expect(result.done).toBe(true);324326     });325327 326-    it("treats SYNC_ERROR from peer as session end with no outbound frames", async () => {328+    it("treats SYNC_ERROR-from-peer as session end with no outbound frames", async () => {327329       const result = await dispatchSyncFrame(328330         encodeSyncMessage({329331           type: "SYNC_ERROR",@@ -336,6 +338,234 @@336338       expect(result.outbound).toEqual([]);337339       expect(result.done).toBe(true);338340       expect(result.error).toEqual({ code: "peer_failed", message: "Peer reported error." });341+    });342+  });343+344+  describe("runSyncSession", () => {345+    interface TransportHarness {346+      transport: SyncSessionTransport;347+      sent: string[];348+      emitFrame: (frame: string) => Promise<void>;349+      emitError: (message: string) => Promise<void>;350+      emitClose: () => Promise<void>;351+      isClosed: () => boolean;352+      ready: () => Promise<void>;353+    }354+355+    function makeTransport(rawKey: Uint8Array): TransportHarness {356+      const sent: string[] = [];357+      const frameQueue: string[] = [];358+      const errorQueue: string[] = [];359+      let pendingClose = false;360+      let frameHandler: ((frame: string) => void) | null = null;361+      let errorHandler: ((message: string) => void) | null = null;362+      let closeHandler: (() => void) | null = null;363+      let resolveReady: (() => void) | null = null;364+      const ready = new Promise<void>((resolve) => {365+        resolveReady = resolve;366+      });367+      let closed = false;368+369+      const checkReady = (): void => {370+        if (frameHandler && errorHandler && closeHandler && resolveReady) {371+          resolveReady();372+          resolveReady = null;373+          for (const queued of frameQueue.splice(0)) frameHandler(queued);374+          for (const queued of errorQueue.splice(0)) errorHandler(queued);375+          if (pendingClose && closeHandler) {376+            pendingClose = false;377+            closeHandler();378+          }379+        }380+      };381+382+      const transport: SyncSessionTransport = {383+        send: (frame) => {384+          sent.push(frame);385+        },386+        onFrame: (handler) => {387+          frameHandler = handler;388+          checkReady();389+          return () => {390+            if (frameHandler === handler) frameHandler = null;391+          };392+        },393+        onError: (handler) => {394+          errorHandler = handler;395+          checkReady();396+          return () => {397+            if (errorHandler === handler) errorHandler = null;398+          };399+        },400+        onClose: (handler) => {401+          closeHandler = handler;402+          checkReady();403+          return () => {404+            if (closeHandler === handler) closeHandler = null;405+          };406+        },407+        close: () => {408+          closed = true;409+        },410+        getSessionKey: async () => rawKey,411+      };412+413+      const flush = async (): Promise<void> => {414+        for (let i = 0; i < 50; i += 1) {415+          await new Promise<void>((resolve) => setTimeout(resolve, 0));416+        }417+      };418+419+      return {420+        transport,421+        sent,422+        emitFrame: async (frame) => {423+          if (frameHandler) frameHandler(frame);424+          else frameQueue.push(frame);425+          await flush();426+        },427+        emitError: async (message) => {428+          if (errorHandler) errorHandler(message);429+          else errorQueue.push(message);430+          await flush();431+        },432+        emitClose: async () => {433+          if (closeHandler) closeHandler();434+          else pendingClose = true;435+          await flush();436+        },437+        isClosed: () => closed,438+        ready: () => ready,439+      };440+    }441+442+    it("kicks off with SYNC_HELLO when initiator and ends on peer SYNC_DONE", async () => {443+      const harness = makeTransport(new Uint8Array(32).fill(11));444+445+      const sessionPromise = runSyncSession({446+        role: "initiator",447+        deviceId: "device-a",448+        vaultVersion: 1,449+        transport: harness.transport,450+        deps: {451+          isProUser: async () => true,452+          isUnlocked: () => true,453+          getEntries: () => [],454+          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),455+        },456+      });457+458+      await harness.ready();459+      expect(harness.sent).toHaveLength(1);460+      expect(decodeSyncMessage(harness.sent[0]!).type).toBe("SYNC_HELLO");461+462+      await harness.emitFrame(encodeSyncMessage({ type: "SYNC_DONE" }));463+464+      const result = await sessionPromise;465+      expect(result.status).toBe("completed");466+      expect(harness.isClosed()).toBe(true);467+    });468+469+    it("does not send SYNC_HELLO when responder", async () => {470+      const harness = makeTransport(new Uint8Array(32));471+      const sessionPromise = runSyncSession({472+        role: "responder",473+        deviceId: "device-b",474+        vaultVersion: 1,475+        transport: harness.transport,476+        deps: {477+          isProUser: async () => true,478+          isUnlocked: () => true,479+          getEntries: () => [],480+          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),481+        },482+      });483+      await harness.ready();484+      expect(harness.sent).toEqual([]);485+486+      await harness.emitClose();487+      const result = await sessionPromise;488+      expect(result.status).toBe("closed");489+    });490+491+    it("aborts immediately with SYNC_ERROR when preflight fails", async () => {492+      const harness = makeTransport(new Uint8Array(32));493+      const result = await runSyncSession({494+        role: "initiator",495+        deviceId: "device",496+        vaultVersion: 1,497+        transport: harness.transport,498+        deps: {499+          isProUser: async () => false,500+          isUnlocked: () => true,501+          getEntries: () => [],502+          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),503+        },504+      });505+506+      expect(result.status).toBe("errored");507+      expect(result.error?.code).toBe("pro_required");508+      expect(decodeSyncMessage(harness.sent[0]!).type).toBe("SYNC_ERROR");509+      expect(harness.isClosed()).toBe(true);510+    });511+512+    it("propagates transport errors as a session result", async () => {513+      const harness = makeTransport(new Uint8Array(32));514+      const sessionPromise = runSyncSession({515+        role: "responder",516+        deviceId: "device",517+        vaultVersion: 1,518+        transport: harness.transport,519+        deps: {520+          isProUser: async () => true,521+          isUnlocked: () => true,522+          getEntries: () => [],523+          restoreEntries: vi.fn(async () => ({ added: 0, updated: 0, skipped: 0 })),524+        },525+      });526+      await harness.ready();527+      await harness.emitError("native host disconnected");528+      const result = await sessionPromise;529+      expect(result.status).toBe("errored");530+      expect(result.error).toEqual({531+        code: "transport_error",532+        message: "native host disconnected",533+      });534+    });535+536+    it("records merge totals from a SYNC_ENTRIES frame on the result", async () => {537+      const rawKey = new Uint8Array(32).fill(12);538+      const harness = makeTransport(rawKey);539+      const restoreEntries = vi540+        .fn()541+        .mockResolvedValue({ added: 1, updated: 2, skipped: 3 });542+543+      const sessionPromise = runSyncSession({544+        role: "responder",545+        deviceId: "device",546+        vaultVersion: 1,547+        transport: harness.transport,548+        deps: {549+          isProUser: async () => true,550+          isUnlocked: () => true,551+          getEntries: () => [],552+          restoreEntries,553+        },554+      });555+      await harness.ready();556+557+      const incoming = await buildSyncEntriesMessage(["a"], rawKey, {558+        isProUser: async () => true,559+        isUnlocked: () => true,560+        getEntries: () => [entry({ id: "a", title: "First" })],561+      });562+      await harness.emitFrame(encodeSyncMessage(incoming));563+      // Peer signals end of its side; session can resolve.564+      await harness.emitFrame(encodeSyncMessage({ type: "SYNC_DONE" }));565+566+      const result = await sessionPromise;567+      expect(result.status).toBe("completed");568+      expect(result.merges).toEqual({ added: 1, updated: 2, skipped: 3 });339569     });340570   });341571 });

view file

src/background/sync-coordinator.ts+115 −0
@@ -255,6 +255,121 @@255255   }256256 }257257 258+export interface SyncSessionTransport {259+  send: (frame: string) => void;260+  onFrame: (handler: (frame: string) => void) => () => void;261+  onError: (handler: (message: string) => void) => () => void;262+  onClose: (handler: () => void) => () => void;263+  close: () => void;264+  getSessionKey: () => Promise<Uint8Array>;265+}266+267+export interface SyncSessionResult {268+  status: "completed" | "errored" | "closed";269+  merges: { added: number; updated: number; skipped: number };270+  error?: { code: string; message: string };271+}272+273+export type SyncRole = "initiator" | "responder";274+275+export async function runSyncSession(options: {276+  role: SyncRole;277+  deviceId: string;278+  vaultVersion: number;279+  transport: SyncSessionTransport;280+  deps?: Pick<281+    SyncCoordinatorDeps,282+    "isProUser" | "isUnlocked" | "getEntries" | "restoreEntries"283+  >;284+}): Promise<SyncSessionResult> {285+  const readyDeps = options.deps ?? (await loadDefaultDeps());286+  const merges = { added: 0, updated: 0, skipped: 0 };287+288+  const preflight = await checkSyncPreflight(readyDeps);289+  if (!preflight.ok) {290+    const message =291+      preflight.error === "pro_required"292+        ? "Sync requires a Pro license."293+        : "Vault is locked; unlock Peach before syncing.";294+    options.transport.send(295+      encodeSyncMessage({296+        type: "SYNC_ERROR",297+        code: preflight.error ?? "preflight_failed",298+        message,299+      })300+    );301+    options.transport.close();302+    return {303+      status: "errored",304+      merges,305+      error: { code: preflight.error ?? "preflight_failed", message },306+    };307+  }308+309+  const sessionKey = await options.transport.getSessionKey();310+311+  return new Promise<SyncSessionResult>((resolve) => {312+    let settled = false;313+    const unsubscribers: Array<() => void> = [];314+315+    const finish = (result: SyncSessionResult): void => {316+      if (settled) return;317+      settled = true;318+      for (const fn of unsubscribers) fn();319+      options.transport.close();320+      resolve(result);321+    };322+323+    unsubscribers.push(324+      options.transport.onClose(() =>325+        finish({ status: "closed", merges })326+      )327+    );328+    unsubscribers.push(329+      options.transport.onError((message) =>330+        finish({331+          status: "errored",332+          merges,333+          error: { code: "transport_error", message },334+        })335+      )336+    );337+338+    unsubscribers.push(339+      options.transport.onFrame((frame) => {340+        void (async () => {341+          const result = await dispatchSyncFrame(frame, sessionKey, readyDeps);342+          if (settled) return;343+          if (result.merge) {344+            merges.added += result.merge.added;345+            merges.updated += result.merge.updated;346+            merges.skipped += result.merge.skipped;347+          }348+          for (const outbound of result.outbound) options.transport.send(outbound);349+          if (result.error) {350+            finish({ status: "errored", merges, error: result.error });351+            return;352+          }353+          if (result.done) {354+            finish({ status: "completed", merges });355+          }356+        })();357+      })358+    );359+360+    if (options.role === "initiator") {361+      options.transport.send(362+        encodeSyncMessage({363+          type: "SYNC_HELLO",364+          deviceId: options.deviceId,365+          clockMs: Date.now(),366+          vaultVersion: options.vaultVersion,367+        })368+      );369+    }370+  });371+}372+258373 export async function cancelSyncIfVaultLocked(259374   connectionId: string,260375   deps?: Pick<SyncCoordinatorDeps, "isUnlocked" | "sendSyncData">

view file