From 8e04df0205cc7093364aefc6aba7ca1cdc115ee7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 23 Aug 2026 10:06:09 -0700 Subject: [PATCH] fix(whatsapp): fence stale group metadata lookups (#128283) --- .../src/inbound/group-metadata-cache.ts | 103 ++++---- .../src/monitor-inbox.metadata-cache.test.ts | 227 ++++++++++++++++++ 2 files changed, 285 insertions(+), 45 deletions(-) diff --git a/extensions/whatsapp/src/inbound/group-metadata-cache.ts b/extensions/whatsapp/src/inbound/group-metadata-cache.ts index cc6955e9df1b..8383f2409a12 100644 --- a/extensions/whatsapp/src/inbound/group-metadata-cache.ts +++ b/extensions/whatsapp/src/inbound/group-metadata-cache.ts @@ -95,8 +95,7 @@ function readGroupMetadataCacheEntry( export function createWhatsAppGroupMetadataCacheOwner(params: GroupMetadataCacheOwnerParams) { const reconnectCache = params.reconnectCache ?? new Map(); const localCache = new Map(); - const publishedJids = new Set(); - const invalidatedJids = new Set(); + const groupMetadataGenerations = new Map(); const detachListeners: Array<() => void> = []; let closed = false; let started = false; @@ -133,62 +132,76 @@ export function createWhatsAppGroupMetadataCacheOwner(params: GroupMetadataCache if (closed) { return; } + groupMetadataGenerations.set(jid, {}); rememberWhatsAppBaileysCacheEntry(params.baileysCache, jid, meta, GROUP_META_TTL_MS); - publishedJids.add(jid); - invalidatedJids.delete(jid); rememberGroupMetadataCacheEntry(reconnectCache, jid, summarizeForReconnect(meta)); localCache.delete(jid); }; const forgetFullMetadata = (jid: string) => { + groupMetadataGenerations.set(jid, {}); params.baileysCache?.delete(jid); reconnectCache.delete(jid); localCache.delete(jid); - publishedJids.delete(jid); - invalidatedJids.add(jid); }; const get = async (jid: string): Promise => { - const cached = readGroupMetadataCacheEntry(localCache, jid); - if (cached) { - return cached; - } - try { - const hydratedEntry = params.baileysCache?.get(jid); - const providerMetadata = params.baileysCache - ? readWhatsAppBaileysCacheEntry(params.baileysCache, jid) - : undefined; - const hydratedMetadata = providerMetadata?.participants?.length - ? providerMetadata - : undefined; - const meta = - hydratedMetadata ?? (await (params.getCurrentSock() ?? params.sock).groupMetadata(jid)); - if (!hydratedMetadata) { - rememberWhatsAppBaileysCacheEntry(params.baileysCache, jid, meta, GROUP_META_TTL_MS); + for (;;) { + if (closed) { + return { expires: resolveGroupMetadataExpiresAt() ?? 0 }; } - publishedJids.add(jid); - const entry = await summarize(meta); - if (hydratedMetadata && hydratedEntry) { - // Reusing provider-owned membership must not extend its authoritative freshness window. - entry.expires = hydratedEntry.expiresAt; + const cached = readGroupMetadataCacheEntry(localCache, jid); + if (cached) { + return cached; } - rememberGroupMetadataCacheEntry(reconnectCache, jid, { - subject: entry.subject, - expires: entry.expires, - }); - rememberGroupMetadataCacheEntry(localCache, jid, entry); - return entry; - } catch (error) { - const hydrated = readGroupMetadataCacheEntry(reconnectCache, jid); - if (hydrated) { - rememberGroupMetadataCacheEntry(localCache, jid, hydrated); - params.logVerbose( - `Using cached group metadata for ${jid} after fetch failure: ${String(error)}`, - ); - return hydrated; + const generation = groupMetadataGenerations.get(jid); + try { + const hydratedEntry = params.baileysCache?.get(jid); + const providerMetadata = params.baileysCache + ? readWhatsAppBaileysCacheEntry(params.baileysCache, jid) + : undefined; + const hydratedMetadata = providerMetadata?.participants?.length + ? providerMetadata + : undefined; + const meta = + hydratedMetadata ?? (await (params.getCurrentSock() ?? params.sock).groupMetadata(jid)); + if (closed || groupMetadataGenerations.get(jid) !== generation) { + continue; + } + const entry = await summarize(meta); + // Membership updates and shutdown can happen during either provider lookup or LID mapping. + // Publish all caches together only while this JID still owns its exact live generation. + if (closed || groupMetadataGenerations.get(jid) !== generation) { + continue; + } + if (hydratedMetadata && hydratedEntry) { + // Reusing provider-owned membership must not extend its authoritative freshness window. + entry.expires = hydratedEntry.expiresAt; + } else { + rememberWhatsAppBaileysCacheEntry(params.baileysCache, jid, meta, GROUP_META_TTL_MS); + } + groupMetadataGenerations.set(jid, {}); + rememberGroupMetadataCacheEntry(reconnectCache, jid, { + subject: entry.subject, + expires: entry.expires, + }); + rememberGroupMetadataCacheEntry(localCache, jid, entry); + return entry; + } catch (error) { + if (closed || groupMetadataGenerations.get(jid) !== generation) { + continue; + } + const hydrated = readGroupMetadataCacheEntry(reconnectCache, jid); + if (hydrated) { + rememberGroupMetadataCacheEntry(localCache, jid, hydrated); + params.logVerbose( + `Using cached group metadata for ${jid} after fetch failure: ${String(error)}`, + ); + return hydrated; + } + params.logVerbose(`Failed to fetch group metadata for ${jid}: ${String(error)}`); + return { expires: resolveGroupMetadataExpiresAt() ?? 0 }; } - params.logVerbose(`Failed to fetch group metadata for ${jid}: ${String(error)}`); - return { expires: resolveGroupMetadataExpiresAt() ?? 0 }; } }; @@ -268,10 +281,10 @@ export function createWhatsAppGroupMetadataCacheOwner(params: GroupMetadataCache return; } for (const [jid, meta] of Object.entries(groups ?? {})) { - if (meta && !publishedJids.has(jid) && !invalidatedJids.has(jid)) { + if (meta && !groupMetadataGenerations.has(jid)) { rememberGroupMetadataCacheEntry(reconnectCache, jid, summarizeForReconnect(meta)); rememberWhatsAppBaileysCacheEntry(params.baileysCache, jid, meta, GROUP_META_TTL_MS); - publishedJids.add(jid); + groupMetadataGenerations.set(jid, {}); } } params.logVerbose( diff --git a/extensions/whatsapp/src/monitor-inbox.metadata-cache.test.ts b/extensions/whatsapp/src/monitor-inbox.metadata-cache.test.ts index 80f4bb6c2962..44664408ebdc 100644 --- a/extensions/whatsapp/src/monitor-inbox.metadata-cache.test.ts +++ b/extensions/whatsapp/src/monitor-inbox.metadata-cache.test.ts @@ -284,6 +284,233 @@ describe("web monitor inbox metadata cache", () => { } }); + it.each([ + { + name: "participant updates", + publishUpdate: (sock: ReturnType) => + sock.ev.emit("group-participants.update", { id: "123@g.us" }), + expectedMetadataRequests: 2, + }, + { + name: "partial group updates", + publishUpdate: (sock: ReturnType) => + sock.ev.emit("groups.update", [{ id: "123@g.us" }]), + expectedMetadataRequests: 2, + }, + { + name: "complete group updates", + publishUpdate: (sock: ReturnType) => + sock.ev.emit("groups.update", [ + groupMetadata({ + subject: "Current Group", + participants: ["15559876543@s.whatsapp.net"], + }), + ]), + expectedMetadataRequests: 1, + }, + ])( + "group metadata cache never restores stale members after $name during a live fetch", + async ({ publishUpdate, expectedMetadataRequests }) => { + const groupMetadataCache: NonNullable = new Map(); + const sock = getSock(); + let resolveStaleMetadata!: (metadata: GroupMetadata) => void; + sock.groupMetadata + .mockImplementationOnce( + async () => + await new Promise((resolve) => { + resolveStaleMetadata = resolve; + }), + ) + .mockResolvedValueOnce( + groupMetadata({ + subject: "Current Group", + participants: ["15559876543@s.whatsapp.net"], + }), + ); + + const { listener, baileysCache } = await startInboxMonitorWithBaileysCache({ + groupMetadataCache, + }); + try { + const sending = listener.sendMessage( + "123@g.us", + "removed @15551234567 current @15559876543", + ); + await vi.waitFor(() => { + expect(sock.groupMetadata).toHaveBeenCalledOnce(); + }); + + publishUpdate(sock); + resolveStaleMetadata( + groupMetadata({ + subject: "Former Group", + participants: ["15551234567@s.whatsapp.net"], + }), + ); + await sending; + + expect(sock.sendMessage).toHaveBeenCalledWith("123@g.us", { + text: "removed @15551234567 current @15559876543", + mentions: ["15559876543@s.whatsapp.net"], + }); + expect(sock.groupMetadata).toHaveBeenCalledTimes(expectedMetadataRequests); + await expectCachedGroupMetadata(baileysCache, { + id: "123@g.us", + subject: "Current Group", + participants: [{ id: "15559876543@s.whatsapp.net" }], + }); + expect(groupMetadataCache.get("123@g.us")?.subject).toBe("Current Group"); + } finally { + await listener.close(); + } + }, + ); + + it("group metadata cache discards participants invalidated during identity normalization", async () => { + const groupMetadataCache: NonNullable = new Map(); + const sock = getSock(); + let resolveFormerParticipant!: (jid: string) => void; + sock.signalRepository.lidMapping.getPNForLID.mockImplementationOnce( + async () => + await new Promise((resolve) => { + resolveFormerParticipant = resolve; + }), + ); + sock.groupMetadata + .mockResolvedValueOnce( + groupMetadata({ + subject: "Former Group", + participants: ["277038292303944@lid"], + }), + ) + .mockResolvedValueOnce( + groupMetadata({ + subject: "Current Group", + participants: ["15559876543@s.whatsapp.net"], + }), + ); + + const { listener, baileysCache } = await startInboxMonitorWithBaileysCache({ + groupMetadataCache, + }); + try { + const sending = listener.sendMessage("123@g.us", "removed @15551234567 current @15559876543"); + await vi.waitFor(() => { + expect(sock.signalRepository.lidMapping.getPNForLID).toHaveBeenCalledWith( + "277038292303944@lid", + ); + }); + + sock.ev.emit("group-participants.update", { id: "123@g.us" }); + resolveFormerParticipant("15551234567@s.whatsapp.net"); + await sending; + + expect(sock.sendMessage).toHaveBeenCalledWith("123@g.us", { + text: "removed @15551234567 current @15559876543", + mentions: ["15559876543@s.whatsapp.net"], + }); + await expectCachedGroupMetadata(baileysCache, { + id: "123@g.us", + subject: "Current Group", + participants: [{ id: "15559876543@s.whatsapp.net" }], + }); + expect(groupMetadataCache.get("123@g.us")?.subject).toBe("Current Group"); + } finally { + await listener.close(); + } + }); + + it("group membership updates do not invalidate concurrent metadata for another group", async () => { + const sock = getSock(); + let resolveStaleMetadata!: (metadata: GroupMetadata) => void; + sock.groupMetadata + .mockImplementationOnce( + async () => + await new Promise((resolve) => { + resolveStaleMetadata = resolve; + }), + ) + .mockResolvedValueOnce( + groupMetadata({ + id: "456@g.us", + subject: "Unrelated Group", + participants: ["15551111111@s.whatsapp.net"], + }), + ) + .mockResolvedValueOnce( + groupMetadata({ + subject: "Current Group", + participants: ["15559876543@s.whatsapp.net"], + }), + ); + + const { listener, baileysCache } = await startInboxMonitorWithBaileysCache(); + try { + const affectedSend = listener.sendMessage("123@g.us", "current @15559876543"); + await vi.waitFor(() => { + expect(sock.groupMetadata).toHaveBeenCalledOnce(); + }); + const unrelatedSend = listener.sendMessage("456@g.us", "unrelated @15551111111"); + await unrelatedSend; + + sock.ev.emit("group-participants.update", { id: "123@g.us" }); + resolveStaleMetadata( + groupMetadata({ + subject: "Former Group", + participants: ["15551234567@s.whatsapp.net"], + }), + ); + await affectedSend; + + expect(sock.groupMetadata).toHaveBeenCalledTimes(3); + expect(sock.sendMessage).toHaveBeenCalledWith("456@g.us", { + text: "unrelated @15551111111", + mentions: ["15551111111@s.whatsapp.net"], + }); + await expectCachedGroupMetadata(baileysCache, { + id: "456@g.us", + subject: "Unrelated Group", + participants: [{ id: "15551111111@s.whatsapp.net" }], + }); + } finally { + await listener.close(); + } + }); + + it("group metadata cache never republishes a live fetch after its owner closes", async () => { + const groupMetadataCache: NonNullable = new Map(); + const sock = getSock(); + let resolveStaleMetadata!: (metadata: GroupMetadata) => void; + sock.groupMetadata.mockImplementationOnce( + async () => + await new Promise((resolve) => { + resolveStaleMetadata = resolve; + }), + ); + + const { listener, baileysCache } = await startInboxMonitorWithBaileysCache({ + groupMetadataCache, + }); + const sending = listener.sendMessage("123@g.us", "closed @15551234567").catch(() => undefined); + await vi.waitFor(() => { + expect(sock.groupMetadata).toHaveBeenCalledOnce(); + }); + + await listener.close(); + resolveStaleMetadata( + groupMetadata({ + subject: "Closed Group", + participants: ["15551234567@s.whatsapp.net"], + }), + ); + await sending; + + expect(groupMetadataCache.has("123@g.us")).toBe(false); + await expect( + baileysCache.socketOptions.cachedGroupMetadata("123@g.us"), + ).resolves.toBeUndefined(); + }); + it("group metadata cache invalidates partial group and participant updates", async () => { const groupMetadataCache: NonNullable = new Map(); const { listener, sock, baileysCache } = await startInboxMonitorWithBaileysCache({