d14b4d0b8ef7

fix: productionSyncRunner returns {ok:false,error} on vault locked instead of throwing; SyncRunResult becomes discriminated union (v3.6.102)

time
2026-05-22 22:05 UTC · 5 months ago
machine
koh2
parent
1969735e961d
4 files src/background/chirp-sync-runner.test.ts+8 −4src/background/chirp-sync-runner.ts+6 −6src/background/sync-trigger.test.ts+6 −6src/background/sync-trigger.ts+7 −4
src/background/chirp-sync-runner.test.ts+8 −4
@@ -314,8 +314,11 @@314314       peerEntriesB64: { "id-1": "AAAA", "id-2": "BBBB", "id-3": "CCCC" },315315     });316316     const result = await runPromise;317-    expect(result.entriesReceived).toBe(3);318-    expect(result.entriesSent).toBe(0);317+    expect(result.ok).toBe(true);318+    if (result.ok) {319+      expect(result.entriesReceived).toBe(3);320+      expect(result.entriesSent).toBe(0);321+    }319322   });320323 321324   it("surfaces entriesSent from the adapter's encrypted-entry response", async () => {@@ -339,7 +342,8 @@339342     const runPromise = runWithDeps(makeDevice(), deps);340343     await runPeer(peer, { wantFromUsIds: ["a", "b"] });341344     const result = await runPromise;342-    expect(result.entriesSent).toBe(2);345+    expect(result.ok).toBe(true);346+    if (result.ok) expect(result.entriesSent).toBe(2);343347   });344348 345349   it("translates a vault_id_mismatch coordinator outcome into an error throw", async () => {@@ -394,7 +398,7 @@394398       longTermPublicKeyHex: "a".repeat(64),395399       pairedAt: 1700000000000,396400     });397-    trigger.setSyncRunner(async () => ({ entriesReceived: 1, entriesSent: 0 }));401+    trigger.setSyncRunner(async () => ({ ok: true, entriesReceived: 1, entriesSent: 0 }));398402     await trigger.triggerSyncIfReachable({ reason: "manual" });399403     expect(typeof local.peach_last_sync_at).toBe("number");400404     expect(local.peach_last_sync_at).toBeGreaterThan(0);

view file

src/background/chirp-sync-runner.ts+6 −6
@@ -19,7 +19,7 @@1919 // into the throw-or-return contract the dispatcher expects.2020 2121 import { runChirpSyncInitiator, type ChirpSyncVaultAdapter } from "./chirp-sync-coordinator";22-import { createChirpSyncVaultAdapter, VaultLockedError } from "./chirp-sync-vault-adapter";22+import { createChirpSyncVaultAdapter } from "./chirp-sync-vault-adapter";2323 import {2424   getLastKnownIp,2525   setLastKnownIp,@@ -81,7 +81,7 @@8181   device: PairedDevice,8282   deps: SyncRunnerDeps,8383 ): Promise<SyncRunResult> {84-  if (!isUnlocked()) throw new VaultLockedError();84+  if (!isUnlocked()) return { ok: false, error: "VAULT_LOCKED" };8585   if (!device.longTermPublicKeyHex) {8686     throw new Error("paired_device_missing_pubkey");8787   }@@ -89,7 +89,7 @@8989   const port = device.wsPort ?? DEFAULT_PORT;9090 9191   const masterKeyHex = getDerivedKeyHex();92-  if (!masterKeyHex) throw new VaultLockedError();92+  if (!masterKeyHex) return { ok: false, error: "VAULT_LOCKED" };9393   const masterKey = hexToBytesLocal(masterKeyHex);9494   const ourDeviceId = await getOrCreateDeviceId();9595   const ourKeypair = deriveSyncIdentityKeypair(masterKey, ourDeviceId);@@ -104,6 +104,7 @@104104     const outcome = await runChirpSyncInitiator(session, adapter);105105     if (outcome.status === "completed") {106106       return {107+        ok: true,107108         entriesReceived: outcome.entriesReceived,108109         entriesSent: outcome.entriesSent,109110       };@@ -116,9 +117,8 @@116117     }117118     throw new Error(outcome.error ?? "sync_errored");118119   } finally {119-    // The coordinator already closes the session inside its own `finally`;120-    // calling close again is harmless because `ChirpSyncSession.close()` is121-    // idempotent (ws.close() is a no-op once closed).120+    // The coordinator does NOT close the session (it returns and leaves the121+    // session open). This runner is the sole owner of session lifetime.122122     await session.close().catch(() => undefined);123123   }124124 }

view file

src/background/sync-trigger.test.ts+6 −6
@@ -71,7 +71,7 @@7171 7272   it("does not call the runner when no mobile hosts are paired", async () => {7373     await upsertPairedDevice(EXT);74-    const runner = vi.fn(async () => ({ entriesReceived: 0, entriesSent: 0 }));74+    const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 }));7575     setSyncRunner(runner);7676     await triggerSyncIfReachable({ reason: "manual" });7777     expect(runner).not.toHaveBeenCalled();@@ -84,7 +84,7 @@8484     const seen: string[] = [];8585     setSyncRunner(async (device) => {8686       seen.push(device.deviceId);87-      return { entriesReceived: 1, entriesSent: 0 };87+      return { ok: true, entriesReceived: 1, entriesSent: 0 };8888     });8989     await triggerSyncIfReachable({ reason: "manual" });9090     expect(seen.sort()).toEqual([ANDROID.deviceId, IOS.deviceId]);@@ -93,7 +93,7 @@9393   it("marks lastSyncedAt on each host that completes successfully", async () => {9494     await upsertPairedDevice(ANDROID);9595     await upsertPairedDevice(IOS);96-    setSyncRunner(async () => ({ entriesReceived: 0, entriesSent: 0 }));96+    setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));9797     await triggerSyncIfReachable({ reason: "manual" });9898     const a = await findPairedDeviceById(ANDROID.deviceId);9999     const b = await findPairedDeviceById(IOS.deviceId);@@ -105,7 +105,7 @@105105     await upsertPairedDevice(ANDROID);106106     const runner = vi.fn(async () => {107107       await new Promise<void>((r) => setTimeout(r, 50));108-      return { entriesReceived: 0, entriesSent: 0 };108+      return { ok: true as const, entriesReceived: 0, entriesSent: 0 };109109     });110110     setSyncRunner(runner);111111     const a = triggerSyncIfReachable({ reason: "vault_change" });@@ -118,7 +118,7 @@118118     await upsertPairedDevice(ANDROID);119119     const runner = vi.fn(async () => {120120       await new Promise<void>((r) => setTimeout(r, 30));121-      return { entriesReceived: 0, entriesSent: 0 };121+      return { ok: true as const, entriesReceived: 0, entriesSent: 0 };122122     });123123     setSyncRunner(runner);124124     const a = triggerSyncIfReachable({ reason: "vault_change" });@@ -140,7 +140,7 @@140140   it("returns to idle state on success with aggregated entry counts", async () => {141141     await upsertPairedDevice(ANDROID);142142     await upsertPairedDevice(IOS);143-    setSyncRunner(async () => ({ entriesReceived: 3, entriesSent: 2 }));143+    setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 2 }));144144     await triggerSyncIfReachable({ reason: "manual" });145145     const s = getSyncState();146146     expect(s.status).toBe("idle");

view file

src/background/sync-trigger.ts+7 −4
@@ -32,10 +32,9 @@3232   error?: string;3333 }3434 35-export interface SyncRunResult {36-  entriesReceived: number;37-  entriesSent: number;38-}35+export type SyncRunResult =36+  | { ok: true; entriesReceived: number; entriesSent: number }37+  | { ok: false; error: string };3938 4039 export type RunSyncWithDevice = (device: PairedDevice) => Promise<SyncRunResult>;4140 @@ -109,6 +108,10 @@109108   for (const host of hosts) {110109     try {111110       const result = await runner(host);111+      if (!result.ok) {112+        if (firstError === undefined) firstError = result.error;113+        continue;114+      }112115       entriesReceived += result.entriesReceived;113116       entriesSent += result.entriesSent;114117       await markLastSyncedAt(host.deviceId, Date.now());

view file