fix(inbound): preserve per-chat ordering + key-share await in detached upsert

Addresses bot review on PR #392:

1. Codex P1 + CodeRabbit Major: removing the `await` on Promise.all broke
   the per-chat ordering guarantee enforced by `messageMutex` in
   messages-recv.ts:2416 (`await messageMutex.mutex(... await upsertMessage(...))`).
   The outer mutex unlocked before the detached processMessage finished,
   so a follow-up message from the same chat could overtake state mutations
   (chat.unreadCount, LID/PN mapping, messages.update emits, history downloads).

   Fix: wrap the fire-and-forget Promise.all in `messageMutex.mutex(chatId, ...)`
   inside chats.ts. The outer mutex (in messages-recv) releases as soon as
   upsertMessage returns (fast — work is detached), then this inner mutex
   enqueues per-chat. Reentrancy is safe because outer always releases before
   inner acquires.

2. CodeRabbit Critical: appStateSyncKeyShare race. processMessage persists
   the new app-state-sync key via keyStore.transaction at process-message.ts:650.
   Previously the awaited Promise.all guaranteed the key was in the store
   before the follow-up `await doAppStateSync()` (which decodes patches via
   that key) ran. After detaching, doAppStateSync could hit isMissingKeyError
   and park collections in blockedCollections, requiring an extra retry cycle.

   Fix: for the rare appStateSyncKeyShare + Syncing branch, await the
   postUpsertWork before triggering doAppStateSync. All other messages stay
   fire-and-forget — the latency win is preserved for the hot path.

3. Copilot nit: enrich the warn payload with messageId, remoteJid and
   shouldProcessHistoryMsg so background failures can be correlated.

4. CodeRabbit nit: replace the IIFE around the conditional doAppStateSync
   with a ternary expression.
This commit is contained in:
Renato Alcara
2026-04-26 13:47:14 -03:00
parent a5b7ce7ef9
commit 72e78a12e0
+38 -10
View File
@@ -1415,14 +1415,18 @@ export const makeChatsSocket = (config: SocketConfig) => {
// must NOT block the buffered function. Awaiting here keeps `messages.upsert` // must NOT block the buffered function. Awaiting here keeps `messages.upsert`
// pinned in the event buffer (createBufferedFunction only schedules flush // pinned in the event buffer (createBufferedFunction only schedules flush
// after work() resolves), delaying delivery to the consumer by the duration // after work() resolves), delaying delivery to the consumer by the duration
// of processMessage. Detaching this work releases the emit on the next debounce // of processMessage.
// tick while signal/store/tcToken side-effects continue in the background. //
// We wrap the work in `messageMutex.mutex(chatId, ...)` so per-chat ordering
// of processMessage side-effects (chat.unreadCount, LID/PN mapping, messages.update,
// history downloads) is preserved across messages of the same chat. The outer
// mutex acquired by messages-recv.ts releases as soon as upsertMessage returns
// (fast, since work is detached), then this inner mutex enqueues per-chat —
// no deadlock, strict per-chat ordering kept.
const postUpsertChatId = msg.key.remoteJid || msg.key.id || 'unknown'
const postUpsertWork = messageMutex.mutex(postUpsertChatId, () =>
Promise.all([ Promise.all([
(async () => { shouldProcessHistoryMsg ? doAppStateSync() : Promise.resolve(),
if (shouldProcessHistoryMsg) {
await doAppStateSync()
}
})(),
processMessage(msg, { processMessage(msg, {
signalRepository, signalRepository,
shouldProcessHistoryMsg, shouldProcessHistoryMsg,
@@ -1434,12 +1438,36 @@ export const makeChatsSocket = (config: SocketConfig) => {
options: config.options, options: config.options,
getMessage getMessage
}) })
]).catch(err => logger?.warn({ err }, 'background post-upsert work failed')) ])
)
// If the app state key arrives and we are waiting to sync, trigger the sync now. // appStateSyncKeyShare path: processMessage persists the new app-state-sync key
// via keyStore.transaction. The follow-up doAppStateSync() needs that key to
// decrypt patches, so for this rare branch we MUST await postUpsertWork first.
// All other messages stay fire-and-forget (the latency win).
if (msg.message?.protocolMessage?.appStateSyncKeyShare && syncState === SyncState.Syncing) { if (msg.message?.protocolMessage?.appStateSyncKeyShare && syncState === SyncState.Syncing) {
logger.info('App state sync key arrived, triggering app state sync') logger.info('App state sync key arrived, awaiting persistence before triggering sync')
try {
await postUpsertWork
await doAppStateSync() await doAppStateSync()
} catch (err) {
logger?.warn(
{ err, messageId: msg.key?.id, remoteJid: msg.key?.remoteJid },
'app-state key-share post-upsert work failed'
)
}
} else {
postUpsertWork.catch(err =>
logger?.warn(
{
err,
messageId: msg.key?.id,
remoteJid: msg.key?.remoteJid,
shouldProcessHistoryMsg
},
'background post-upsert work failed'
)
)
} }
}) })