From 5ed919ac71665408de0915e8a5589b6e93fb891e Mon Sep 17 00:00:00 2001 From: Lobster <10343873+omarshahine@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:38:58 -0700 Subject: [PATCH] fix: tighten catchup balloon filter + suppress unhandled rejection in write queue Address Codex P1 review feedback: - Balloon filter: only skip when associatedMessageType OR balloonBundleId is set alongside associatedMessageGuid. Threaded replies use threadOriginatorGuid and are unaffected. - Write queue: .catch(() => {}) on the cleanup promise so a rejected next doesn't surface as an unhandled rejection in Node 22+. --- extensions/bluebubbles/src/catchup.ts | 14 +++++++++++--- src/plugin-sdk/persistent-dedupe.ts | 16 +++++++++++----- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/extensions/bluebubbles/src/catchup.ts b/extensions/bluebubbles/src/catchup.ts index aa6ee26e385..0aa70342264 100644 --- a/extensions/bluebubbles/src/catchup.ts +++ b/extensions/bluebubbles/src/catchup.ts @@ -498,19 +498,27 @@ async function runBlueBubblesCatchupInner( continue; } - // Skip balloon/tapback/reaction events: they carry an + // Skip tapback/reaction/balloon events. These carry an // `associatedMessageGuid` pointing at the parent text message and // have a different `guid` of their own. The live webhook path handles - // them via the debouncer, which coalesces balloons with their parent. + // balloons via the debouncer, which coalesces them with their parent. // Without debouncing here, replaying a balloon would dispatch it as a // standalone message — producing a duplicate reply to the parent. + // + // Guard: only skip when `associatedMessageType` is set (tapbacks and + // reactions — e.g., "like", 2000) OR `balloonBundleId` is set (URL + // previews, stickers). iMessage threaded replies use a separate + // `threadOriginatorGuid` field and do NOT set either of these, so + // they pass through for correct catchup replay. const assocGuid = typeof rec.associatedMessageGuid === "string" ? rec.associatedMessageGuid.trim() : typeof rec.associated_message_guid === "string" ? rec.associated_message_guid.trim() : ""; - if (assocGuid) { + const assocType = rec.associatedMessageType ?? rec.associated_message_type; + const balloonId = typeof rec.balloonBundleId === "string" ? rec.balloonBundleId.trim() : ""; + if (assocGuid && (assocType != null || balloonId)) { continue; } diff --git a/src/plugin-sdk/persistent-dedupe.ts b/src/plugin-sdk/persistent-dedupe.ts index f109a27d647..651a5b9193f 100644 --- a/src/plugin-sdk/persistent-dedupe.ts +++ b/src/plugin-sdk/persistent-dedupe.ts @@ -170,11 +170,17 @@ export function createPersistentDedupe(options: PersistentDedupeOptions): Persis const prev = fileWriteQueues.get(filePath) ?? Promise.resolve(); const next = prev.then(fn, fn); fileWriteQueues.set(filePath, next); - void next.finally(() => { - if (fileWriteQueues.get(filePath) === next) { - fileWriteQueues.delete(filePath); - } - }); + // Cleanup: remove the queue entry once this link settles, but only if + // no newer work was chained after us. The `.catch(() => {})` prevents + // an unhandled rejection when `next` rejects — callers still observe + // the rejection through the returned `next` promise directly. + next + .finally(() => { + if (fileWriteQueues.get(filePath) === next) { + fileWriteQueues.delete(filePath); + } + }) + .catch(() => {}); return next; }