sync-coordinator.test.ts

23.9 KB · raw

1import { describe, expect, it, vi } from "vitest";2import { SYNC_LIMITS } from "../shared/sync-limits";3import {4  runSyncInitiator,5  runSyncResponder,6  type SyncVaultAdapter,7} from "./sync-coordinator";8import type { SyncMessage, SignedDeviceManifest } from "../shared/sync-types";9import type { SyncSession } from "../shared/sync-transport";10import { buildSignedDeviceManifest } from "../shared/device-manifest";11import { deriveSyncSigningKeypair } from "../shared/sync-identity";1213function inMemoryPair(): [SyncSession, FakePeer] {14  const a: Uint8Array[] = [];15  const b: Uint8Array[] = [];16  const aR: Array<(v: Uint8Array) => void> = [];17  const bR: Array<(v: Uint8Array) => void> = [];18  const aRej: Array<(e: Error) => void> = [];19  let closed = false;2021  const ourSession: SyncSession = {22    send(bytes) {23      const r = bR.shift();24      if (r) r(bytes); else b.push(bytes);25    },26    receive(): Promise<Uint8Array> {27      const v = a.shift();28      if (v) return Promise.resolve(v);29      if (closed) return Promise.reject(new Error("WebSocket closed"));30      return new Promise<Uint8Array>((res, rej) => { aR.push(res); aRej.push(rej); });31    },32    async close() { /* no-op */ },33    remoteStaticPublicKey: new Uint8Array(32),34  };3536  const peer: FakePeer = {37    async send(msg: SyncMessage) {38      const bytes = new TextEncoder().encode(JSON.stringify(msg));39      const r = aR.shift();40      if (r) r(bytes); else a.push(bytes);41    },42    async receive(): Promise<SyncMessage> {43      const v = b.shift();44      const bytes = v ?? await new Promise<Uint8Array>((res) => bR.push(res));45      return JSON.parse(new TextDecoder().decode(bytes)) as SyncMessage;46    },47    /**48     * Android's listener closes the socket the moment it has our final49     * SYNC_DONE — there is no courtesy echo. Surface that as a "WebSocket50     * closed" rejection on the initiator's parked receive().51     */52    async closeInitiatorReceive() {53      if (closed) return;54      closed = true;55      while (aRej.length > 0) aRej.shift()!(new Error("WebSocket closed"));56    },57  };58  return [ourSession, peer];59}6061function symmetricSessionPair(): [SyncSession, SyncSession] {62  const queues: [Uint8Array[], Uint8Array[]] = [[], []];63  const readers: [64    Array<(value: Uint8Array) => void>,65    Array<(value: Uint8Array) => void>,66  ] = [[], []];67  const make = (side: 0 | 1): SyncSession => ({68    send(bytes) {69      const other = side === 0 ? 1 : 0;70      const reader = readers[other].shift();71      if (reader) reader(bytes);72      else queues[other].push(bytes);73    },74    receive() {75      const queued = queues[side].shift();76      if (queued) return Promise.resolve(queued);77      return new Promise<Uint8Array>((resolve) => readers[side].push(resolve));78    },79    async close() {},80    remoteStaticPublicKey: new Uint8Array(32).fill(side === 0 ? 0x22 : 0x11),81  });82  return [make(0), make(1)];83}8485interface FakePeer {86  send(msg: SyncMessage): Promise<void>;87  receive(): Promise<SyncMessage>;88  /** Hang up on the initiator's parked receive(), as Android does at DONE. */89  closeInitiatorReceive(): Promise<void>;90}9192function makeVaultAdapter(opts: {93  vaultId?: string;94  deviceId?: string;95  localVersion?: number;96  entries?: Record<string, number>;97  tombstones?: Record<string, number>;98  ciphertext?: Record<string, string>;99}): SyncVaultAdapter & {100  appliedEntries: Record<string, string>[];101  appliedTombstones: Record<string, number>[];102} {103  const appliedEntries: Record<string, string>[] = [];104  const appliedTombstones: Record<string, number>[] = [];105  return {106    async computeVaultId() { return opts.vaultId ?? "vault-abc"; },107    async getOurDeviceId() { return opts.deviceId ?? "device-init"; },108    // Use a non-zero default so the equal-version short circuit doesn't fire in109    // tests that aren't explicitly testing that path.110    async getLocalVersion() { return opts.localVersion ?? 5; },111    async buildEntryManifest() { return { ...(opts.entries ?? {}) }; },112    async buildTombstoneManifest() { return { ...(opts.tombstones ?? {}) }; },113    async getEncryptedEntries(ids) {114      const out: Record<string, string> = {};115      const cipher = opts.ciphertext ?? {};116      for (const id of ids) if (cipher[id] !== undefined) out[id] = cipher[id];117      return out;118    },119    async applyEntries(entries) { appliedEntries.push(entries); },120    async applyTombstones(t) { appliedTombstones.push(t); },121    appliedEntries,122    appliedTombstones,123  };124}125126// PB-26-111: SYNC_REQUEST.entryIds was bounded only by the 24 MiB frame size —127// roughly a million UUIDs — while each requested id costs an HKDF derivation128// plus an AES-GCM encryption of the entry envelope. An authenticated peer could129// burn ~30s of service-worker CPU per dial, repeatably, with no data effect that130// would look like an attack.131describe("runSyncInitiator — bounded SYNC_REQUEST (PB-26-111)", () => {132  it("rejects an oversized id list before any per-entry work", async () => {133    const [session, peer] = inMemoryPair();134    const vault = makeVaultAdapter({ vaultId: "vault-abc", entries: { "id-1": 1 } });135    let encryptedFor: string[] | null = null;136    const spied = Object.assign({}, vault, {137      async getEncryptedEntries(ids: string[]) {138        encryptedFor = ids;139        return {};140      },141    });142143    const initPromise = runSyncInitiator(session, spied);144    void (async () => {145      await peer.receive(); // hello146      await peer.send({147        type: "SYNC_HELLO",148        vaultId: "vault-abc",149        deviceId: "p",150        version: 0,151        deviceTime: Date.now(),152      });153      await peer.receive(); // our manifest154      await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });155      await peer.receive(); // our request156      // Ask for more ids than the protocol allows.157      await peer.send({158        type: "SYNC_REQUEST",159        entryIds: Array.from(160          { length: SYNC_LIMITS.MAX_REQUEST_ENTRY_COUNT + 1 },161          (_, i) => `id-${i}`,162        ),163      });164    })();165166    const outcome = await initPromise;167    expect(outcome.status).toBe("errored");168    expect(outcome.error).toMatch(/entry count exceeds limit/);169    // The whole point: the bound fires before the expensive per-id work.170    expect(encryptedFor).toBeNull();171  });172173  it("still serves a request at the limit", async () => {174    const [session, peer] = inMemoryPair();175    const ids = Array.from({ length: 3 }, (_, i) => `id-${i}`);176    const vault = makeVaultAdapter({177      vaultId: "vault-abc",178      entries: { "id-0": 1, "id-1": 1, "id-2": 1 },179      ciphertext: { "id-0": "AAAA", "id-1": "BBBB", "id-2": "CCCC" },180    });181182    const initPromise = runSyncInitiator(session, vault);183    void (async () => {184      await peer.receive(); // hello185      await peer.send({186        type: "SYNC_HELLO",187        vaultId: "vault-abc",188        deviceId: "p",189        version: 0,190        deviceTime: Date.now(),191      });192      await peer.receive(); // our manifest193      await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });194      await peer.receive(); // our request195      await peer.send({ type: "SYNC_REQUEST", entryIds: ids });196      await peer.receive(); // SYNC_ENTRIES197      await peer.receive(); // SYNC_TOMBSTONES198      await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });199      await peer.receive(); // SYNC_DONE200      await peer.send({ type: "SYNC_DONE" });201    })();202203    const outcome = await initPromise;204    expect(outcome.status).toBe("completed");205    expect(outcome.entriesSent).toBe(3);206  });207});208209describe("runSyncInitiator — SYNC_MANIFEST limits (PB-26-312)", () => {210  it("rejects oversized entry and tombstone manifests before diffing", async () => {211    const [session, peer] = inMemoryPair();212    const vault = makeVaultAdapter({ vaultId: "vault-abc" });213    const initPromise = runSyncInitiator(session, vault);214    void (async () => {215      await peer.receive();216      await peer.send({217        type: "SYNC_HELLO",218        vaultId: "vault-abc",219        deviceId: "p",220        version: 0,221        deviceTime: Date.now(),222      });223      await peer.receive();224      const entries = Object.fromEntries(225        Array.from({ length: SYNC_LIMITS.MAX_ENTRY_COUNT + 1 }, (_, i) => [`id-${i}`, 1]),226      );227      await peer.send({ type: "SYNC_MANIFEST", entries, tombstones: {} });228    })();229    await expect(initPromise).resolves.toMatchObject({230      status: "errored",231      error: expect.stringMatching(/manifest entry count exceeds limit/),232    });233234    const [tombstoneSession, tombstonePeer] = inMemoryPair();235    const tombstonePromise = runSyncInitiator(tombstoneSession, vault);236    void (async () => {237      await tombstonePeer.receive();238      await tombstonePeer.send({239        type: "SYNC_HELLO",240        vaultId: "vault-abc",241        deviceId: "p",242        version: 0,243        deviceTime: Date.now(),244      });245      await tombstonePeer.receive();246      const tombstones = Object.fromEntries(247        Array.from({ length: SYNC_LIMITS.MAX_TOMBSTONE_COUNT + 1 }, (_, i) => [`id-${i}`, 1]),248      );249      await tombstonePeer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones });250    })();251    await expect(tombstonePromise).resolves.toMatchObject({252      status: "errored",253      error: expect.stringMatching(/manifest tombstone count exceeds limit/),254    });255  });256});257258describe("runSyncInitiator — HELLO fields and version optimisation", () => {259  it("sends HELLO with version and deviceTime", async () => {260    const [session, peer] = inMemoryPair();261    const vault = makeVaultAdapter({ localVersion: 7 });262263    const initPromise = runSyncInitiator(session, vault);264265    const sentHello = await peer.receive();266    expect(sentHello.type).toBe("SYNC_HELLO");267    if (sentHello.type === "SYNC_HELLO") {268      expect(sentHello.version).toBe(7);269      expect(typeof sentHello.deviceTime).toBe("number");270      expect(sentHello.deviceTime).toBeGreaterThan(0);271    }272    // Send a mismatched vaultId to abort early so the test doesn't hang.273    await peer.send({ type: "SYNC_HELLO", vaultId: "different-vault", deviceId: "p", version: 7, deviceTime: Date.now() });274    await peer.receive(); // SYNC_ERROR275    await initPromise;276  });277278  it("skips manifest exchange and returns completed when versions are equal", async () => {279    const [session, peer] = inMemoryPair();280    const vault = makeVaultAdapter({ localVersion: 10 });281282    const initPromise = runSyncInitiator(session, vault);283284    await peer.receive(); // SYNC_HELLO285    await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 10, deviceTime: Date.now() });286287    // Coordinator should send SYNC_DONE without any manifest exchange.288    const done = await peer.receive();289    expect(done.type).toBe("SYNC_DONE");290291    // No SYNC_MANIFEST was sent — peer's queue only has the DONE.292    await peer.send({ type: "SYNC_DONE" });293294    const outcome = await initPromise;295    expect(outcome.status).toBe("completed");296    expect(outcome.entriesReceived).toBe(0);297    expect(outcome.entriesSent).toBe(0);298  });299300  it("exchanges device graphs at equal entry versions when both peers advertise support", async () => {301    const [dialerSession, waiterSession] = symmetricSessionPair();302    const dialerManifest = {303      body: {304        manifestVersion: 3,305        issuerDeviceId: "dialer",306        issuedAt: 1,307        devices: [],308        revoked: [],309      },310      signatureHex: "11".repeat(64),311    } satisfies SignedDeviceManifest;312    const waiterManifest = {313      body: {314        manifestVersion: 3,315        issuerDeviceId: "waiter",316        issuedAt: 1,317        devices: [],318        revoked: [],319      },320      signatureHex: "22".repeat(64),321    } satisfies SignedDeviceManifest;322    const dialerApplied: SignedDeviceManifest[] = [];323    const waiterApplied: SignedDeviceManifest[] = [];324    const dialer: SyncVaultAdapter = {325      ...makeVaultAdapter({ deviceId: "dialer", localVersion: 7 }),326      async buildOutgoingDeviceManifest() { return dialerManifest; },327      async applyIncomingDeviceManifest(manifest) { dialerApplied.push(manifest); },328    };329    const waiter: SyncVaultAdapter = {330      ...makeVaultAdapter({ deviceId: "waiter", localVersion: 7 }),331      async buildOutgoingDeviceManifest() { return waiterManifest; },332      async applyIncomingDeviceManifest(manifest) { waiterApplied.push(manifest); },333    };334335    const [dialerOutcome, waiterOutcome] = await Promise.all([336      runSyncInitiator(dialerSession, dialer),337      runSyncResponder(waiterSession, waiter),338    ]);339340    expect(dialerOutcome.status).toBe("completed");341    expect(waiterOutcome.status).toBe("completed");342    expect(dialerApplied).toEqual([waiterManifest]);343    expect(waiterApplied).toEqual([dialerManifest]);344  });345346  it("logs a clock-skew warning but does not abort when clocks differ by > 120s", async () => {347    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);348    const [session, peer] = inMemoryPair();349    const vault = makeVaultAdapter({ localVersion: 5 });350351    const initPromise = runSyncInitiator(session, vault);352353    await peer.receive(); // SYNC_HELLO354    // Peer reports a deviceTime 200 s in the past — beyond the 120 s threshold.355    await peer.send({356      type: "SYNC_HELLO",357      vaultId: "vault-abc",358      deviceId: "p",359      version: 3, // different version so exchange proceeds360      deviceTime: Date.now() - 200_000,361    });362    // Yield so the coordinator's async continuation can run the clock-skew check.363    await new Promise((r) => setTimeout(r, 0));364    expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("clock skew"));365366    // Drive the sync to completion so the test doesn't leak.367    await peer.receive(); // SYNC_MANIFEST368    await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });369    await peer.receive(); // SYNC_REQUEST370    await peer.send({ type: "SYNC_REQUEST", entryIds: [] });371    await peer.receive(); // SYNC_ENTRIES372    await peer.receive(); // SYNC_TOMBSTONES373    await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });374    await peer.receive(); // SYNC_DONE375    await peer.send({ type: "SYNC_DONE" });376377    const outcome = await initPromise;378    expect(outcome.status).toBe("completed");379    warnSpy.mockRestore();380  });381});382383describe("runSyncInitiator", () => {384  it("syncs two mesh peers directly with their introducer absent", async () => {385    const [dialerSession, waiterSession] = symmetricSessionPair();386    const dialer = makeVaultAdapter({387      deviceId: "laptop-a",388      localVersion: 10,389      entries: { a: 10 },390      ciphertext: { a: "cipher-a" },391    });392    const waiter = makeVaultAdapter({393      deviceId: "laptop-c",394      localVersion: 11,395      entries: { c: 11 },396      ciphertext: { c: "cipher-c" },397    });398399    const [dialerOutcome, waiterOutcome] = await Promise.all([400      runSyncInitiator(dialerSession, dialer),401      runSyncResponder(waiterSession, waiter),402    ]);403404    expect(dialerOutcome.status).toBe("completed");405    expect(waiterOutcome.status).toBe("completed");406    expect(dialer.appliedEntries).toEqual([{ c: "cipher-c" }]);407    expect(waiter.appliedEntries).toEqual([{ a: "cipher-a" }]);408  });409410  it("reports the privacy-safe protocol stage when a device stops answering", async () => {411    vi.useFakeTimers();412    try {413      const [session, peer] = inMemoryPair();414      const vault = makeVaultAdapter({});415      const initPromise = runSyncInitiator(session, vault);416417      const hello = await peer.receive();418      expect(hello.type).toBe("SYNC_HELLO");419420      await vi.advanceTimersByTimeAsync(30_000);421      await expect(initPromise).resolves.toMatchObject({422        status: "timeout",423        error: "sync_timeout:waiting_for_device_hello",424      });425    } finally {426      vi.useRealTimers();427    }428  });429430  it("completes a full sync with bi-directional updates", async () => {431    const [session, peer] = inMemoryPair();432    const vault = makeVaultAdapter({433      entries: { a: 100, b: 50 }, // we have 'a' at t=100, 'b' at t=50434      tombstones: { x: 70 },435      ciphertext: { a: "ct-a", b: "ct-b" },436    });437438    const initPromise = runSyncInitiator(session, vault);439440    const peerProtocol = (async () => {441      const hello = await peer.receive();442      expect(hello.type).toBe("SYNC_HELLO");443      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "device-peer", version: 3, deviceTime: Date.now() });444445      const ourManifest = await peer.receive();446      expect(ourManifest.type).toBe("SYNC_MANIFEST");447      await peer.send({448        type: "SYNC_MANIFEST",449        entries: { a: 80, b: 90, c: 200 }, // we have newer 'a'; peer has newer 'b' and 'c'450        tombstones: { x: 60, y: 99 },        // peer has newer tombstone for 'y'451      });452453      const ourRequest = await peer.receive();454      expect(ourRequest.type).toBe("SYNC_REQUEST");455      // We should request the entries the peer reported newer456      if (ourRequest.type === "SYNC_REQUEST") {457        expect(ourRequest.entryIds.sort()).toEqual(["b", "c"]);458      }459      // Peer asks us for entries the peer claimed it had older versions of460      await peer.send({ type: "SYNC_REQUEST", entryIds: ["a"] });461462      const ourEntries = await peer.receive();463      expect(ourEntries.type).toBe("SYNC_ENTRIES");464      if (ourEntries.type === "SYNC_ENTRIES") {465        expect(ourEntries.entries).toEqual({ a: "ct-a" });466      }467      await peer.send({ type: "SYNC_ENTRIES", entries: { b: "ct-b-new", c: "ct-c" } });468469      const ourTombs = await peer.receive();470      expect(ourTombs.type).toBe("SYNC_TOMBSTONES");471      if (ourTombs.type === "SYNC_TOMBSTONES") {472        // We had x@70, peer had x@60, so we ship x@70473        expect(ourTombs.tombstones).toEqual({ x: 70 });474      }475      await peer.send({ type: "SYNC_TOMBSTONES", tombstones: { y: 99 } });476477      const ourDone = await peer.receive();478      expect(ourDone.type).toBe("SYNC_DONE");479      await peer.send({ type: "SYNC_DONE" });480    })();481482    const outcome = await initPromise;483    await peerProtocol;484485    expect(outcome.status).toBe("completed");486    expect(outcome.entriesSent).toBe(1);487    expect(outcome.entriesReceived).toBe(2);488    expect(outcome.tombstonesSent).toBe(1);489    expect(outcome.tombstonesReceived).toBe(1);490    expect(vault.appliedEntries).toEqual([{ b: "ct-b-new", c: "ct-c" }]);491    expect(vault.appliedTombstones).toEqual([{ y: 99 }]);492  });493494  it("aborts with vault_mismatch on a peer with a different vault_id", async () => {495    const [session, peer] = inMemoryPair();496    const vault = makeVaultAdapter({ vaultId: "vault-A" });497498    const initPromise = runSyncInitiator(session, vault);499    const peerProtocol = (async () => {500      const hello = await peer.receive();501      expect(hello.type).toBe("SYNC_HELLO");502      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-B", deviceId: "evil", version: 3, deviceTime: Date.now() });503      // Peer should receive a SYNC_ERROR and we should stop.504      const err = await peer.receive();505      expect(err.type).toBe("SYNC_ERROR");506    })();507508    const outcome = await initPromise;509    await peerProtocol;510    expect(outcome.status).toBe("vault_mismatch");511  });512513  it("exchanges device manifests when both adapters opt in", async () => {514    const [session, peer] = inMemoryPair();515    const ourKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x11), "us");516    const peerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x22), "them");517518    const outgoing = await buildSignedDeviceManifest("us", [], ourKp.privateKey);519    const incomingFromPeer = await buildSignedDeviceManifest("them", [], peerKp.privateKey);520    const applied: SignedDeviceManifest[] = [];521522    const base = makeVaultAdapter({});523    const vault: SyncVaultAdapter = {524      ...base,525      async buildOutgoingDeviceManifest() { return outgoing; },526      async applyIncomingDeviceManifest(m, authenticatedKey) {527        expect(authenticatedKey).toEqual(session.remoteStaticPublicKey);528        applied.push(m);529      },530    };531532    const initPromise = runSyncInitiator(session, vault);533    const peerProtocol = (async () => {534      await peer.receive(); // hello535      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now() });536      await peer.receive(); // manifest537      await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });538      await peer.receive(); // request539      await peer.send({ type: "SYNC_REQUEST", entryIds: [] });540      await peer.receive(); // entries541      await peer.receive(); // tombstones542      await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });543      const ourManifest = await peer.receive();544      expect(ourManifest.type).toBe("SYNC_DEVICE_MANIFEST");545      if (ourManifest.type === "SYNC_DEVICE_MANIFEST") {546        expect(ourManifest.manifest?.body.issuerDeviceId).toBe("us");547      }548      await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: incomingFromPeer });549      await peer.receive(); // done550      await peer.send({ type: "SYNC_DONE" });551    })();552553    const outcome = await initPromise;554    await peerProtocol;555    expect(outcome.status).toBe("completed");556    expect(applied).toHaveLength(1);557    expect(applied[0].body.issuerDeviceId).toBe("them");558  });559560  it("uses null to represent an absent device manifest", async () => {561    const [session, peer] = inMemoryPair();562    const applyIncoming = vi.fn();563    const vault: SyncVaultAdapter = {564      ...makeVaultAdapter({}),565      async buildOutgoingDeviceManifest() { return null; },566      applyIncomingDeviceManifest: applyIncoming,567    };568569    const initPromise = runSyncInitiator(session, vault);570    const peerProtocol = (async () => {571      await peer.receive();572      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 3, deviceTime: Date.now() });573      await peer.receive();574      await peer.send({ type: "SYNC_MANIFEST", entries: {}, tombstones: {} });575      await peer.receive();576      await peer.send({ type: "SYNC_REQUEST", entryIds: [] });577      await peer.receive();578      await peer.receive();579      await peer.send({ type: "SYNC_TOMBSTONES", tombstones: {} });580      const manifest = await peer.receive();581      expect(manifest).toEqual({ type: "SYNC_DEVICE_MANIFEST", manifest: null });582      await peer.send({ type: "SYNC_DEVICE_MANIFEST", manifest: null });583      await peer.receive();584      await peer.send({ type: "SYNC_DONE" });585    })();586587    const outcome = await initPromise;588    await peerProtocol;589    expect(outcome.status).toBe("completed");590    expect(applyIncoming).not.toHaveBeenCalled();591  });592593  it("returns errored when peer sends a wrong frame", async () => {594    const [session, peer] = inMemoryPair();595    const vault = makeVaultAdapter({ vaultId: "v" });596597    const initPromise = runSyncInitiator(session, vault);598    void (async () => {599      await peer.receive(); // hello600      await peer.send({ type: "SYNC_HELLO", vaultId: "v", deviceId: "p", version: 3, deviceTime: Date.now() });601      await peer.receive(); // our manifest602      await peer.send({ type: "SYNC_DONE" }); // wrong frame603    })();604605    const outcome = await initPromise;606    expect(outcome.status).toBe("errored");607    expect(outcome.error).toMatch(/SYNC_MANIFEST/);608  });609610  it("reports completed when the peer closes the socket while we wait for its closing SYNC_DONE", async () => {611    // Bug: by the time we send our own SYNC_DONE, every entry and tombstone612    // has already been exchanged and applied on both sides. The peer's reply613    // is a courtesy, not a commit. Android closes the socket the moment it614    // has our DONE; previously the initiator's parked receive() rejected with615    // "WebSocket closed" and the run was reported as `errored` → run_failed616    // → red, even though the data had demonstrably landed.617    const [session, peer] = inMemoryPair();618    const vault = makeVaultAdapter({ localVersion: 5 });619620    const initPromise = runSyncInitiator(session, vault);621    void (async () => {622      await peer.receive(); // hello623      await peer.send({ type: "SYNC_HELLO", vaultId: "vault-abc", deviceId: "p", version: 5, deviceTime: Date.now() });624      await peer.receive(); // our done625      // No SYNC_DONE echo — Android just hangs up.626      await peer.closeInitiatorReceive();627    })();628629    const outcome = await initPromise;630    expect(outcome.status).toBe("completed");631  });632});