Compare commits

...

11 Commits

Author SHA1 Message Date
Renato Alcara dc24e60e5b fix(inbound): avoid HOL blocking on malformed-JID fallback bucket
Addresses Copilot review on PR #392.

The previous fallback `normalizedChatId || 'unknown'` funneled every
message with a missing or malformed chat id into a single global queue
on `postUpsertMutex`, head-of-line blocking unrelated malformed inputs
behind a shared bucket.

Prefer `msg.key?.id` over the constant: it is unique per message so
unrelated malformed inputs no longer serialize together, while valid
messages continue to hit the normalized chat-id path — preserving the
per-chat ordering guarantee where it actually matters. The CodeRabbit
concern about `msg.key.id` fragmenting the queue applied to the primary
key derivation (where ordering matters); here it is purely a fallback
for a corruption edge case where there is no chat to order by anyway.
2026-04-26 16:03:07 -03:00
Renato Alcara 942f4ae2c9 fix(inbound): recover from doAppStateSync failures + route bg errors
Addresses two Copilot review comments on PR #392.

1. doAppStateSync stuck-state recovery (pre-existing bug, made more
   visible by Promise.allSettled silencing the propagation):
   If `resyncAppState` throws, the lines that set `syncState = Online`
   and call `ev.flush()` never execute — leaving the event buffer
   pinned and `syncState` stuck at `Syncing` until the buffer's own
   safety timeout expires.

   Wrap `resyncAppState` in try/catch that forces the state machine
   forward (`syncState = Online`, `ev.flush()`) before re-throwing, so
   live inbound events can flow even when app-state sync fails.
   Collections were already cleared, so blocked patches will be
   retried on the next creds.update tick.

2. Replace `void postUpsertWork` with a defensive
   `.catch(err => onUnexpectedError(err, ...))`. The previous `void`
   was clean but exposed the long-running socket to potential process
   termination from UnhandledPromiseRejection on Node ≥15. The new
   pattern matches existing background-work handling elsewhere in
   chats.ts (presence updates, init queries) and routes truly
   unexpected rejections through the centralised error handler
   instead of letting them surface as unhandled.
2026-04-26 15:50:47 -03:00
Renato Alcara 98d2de2984 fix(inbound): skip doAppStateSync when key-share persistence fails
Addresses Copilot review on PR #392.

Previous commit moved per-task error handling into `postUpsertTasks` via
per-task `.catch(log)` so the keyed mutex would only release after BOTH
tasks settle (preventing per-chat ordering races). Side effect: the
combined promise resolves *even if processMessage failed to persist the
new app-state-sync key*. The keyShare branch then ran `doAppStateSync()`
unconditionally, which hits `isMissingKeyError`, parks collections in
`blockedCollections`, and regresses the exact scenario this branch
was created to fix.

Restructure: `postUpsertTasks` now uses `Promise.allSettled` and returns
`{ processMessageOk: boolean }`. Per-task failures are still logged
inline. The keyShare branch reads `processMessageOk` and skips
`doAppStateSync()` when the key wasn't persisted — emitting an explicit
warning instead. The fire-and-forget branch is unaffected (still `void`).
2026-04-26 15:33:38 -03:00
Renato Alcara b0c34cb536 chore(inbound): drop dead outer catch on postUpsertWork
Addresses Copilot review on PR #392.

Both tasks inside `postUpsertTasks` already swallow their rejections via
`.catch`, so `Promise.all([taskA.catch, taskB.catch])` never rejects, and
`postUpsertMutex.mutex(... postUpsertTasks)` therefore never rejects in
practice. The outer `.catch` could only fire on AsyncMutex internal
corruption — an extremely rare event, and one whose log message would
be misleading ("background post-upsert work failed" pointing at the
mutex layer, not the work).

Replace with `void postUpsertWork`: marks the floating promise as
intentional, and lets a truly unexpected rejection surface as an
UnhandledPromiseRejection — a loud, attributable signal of a real bug
rather than a swallowed warn buried in logs.
2026-04-26 15:25:41 -03:00
Renato Alcara d169a6f755 fix(inbound): handle malformed JIDs in postUpsertChatId fallback
Addresses Copilot review on PR #392.

`jidNormalizedUser()` returns an empty string when `jidDecode()` fails
(e.g. a malformed JID missing the `@` separator). With the previous
ternary `rawChatId ? jidNormalizedUser(rawChatId) : 'unknown'`, a
truthy-but-malformed `rawChatId` produced `postUpsertChatId === ''`,
so the keyed mutex ran with an empty-string key instead of the
intended `'unknown'` fallback bucket — defeating the per-chat
serialization and silently lumping malformed-key messages into a
single anonymous queue.

Compute the normalized value first then apply the fallback only when
the result is empty.
2026-04-26 15:13:42 -03:00
Renato Alcara aef4973cac fix(inbound): use dedicated postUpsertMutex to preserve enqueue order
Addresses Copilot review on PR #392.

Sharing `messageMutex(chatId)` between the outer inbound caller and the
inner post-upsert task creates an enqueue-order race. The outer callback
awaits `decrypt()` before reaching the inner enqueue site, so a concurrently
arrived message N+1 can land on the same mutex queue *before* msg N's
post-upsert task gets enqueued. The queue ends up as [OuterN+1, InnerN, ...]
and msg N+1's processMessage runs before msg N's, breaking the per-chat
ordering of side effects (chat.unreadCount, LID/PN mapping, messages.update,
history downloads).

Introduce a dedicated `postUpsertMutex` (separate KeyedMutex instance)
keyed on chatId. Post-upsert tasks enqueue strictly in upsertMessage call
order — and that IS message arrival order because the outer messageMutex
serializes upserts per-chat. The two mutexes never collide, so no
reentrancy or deadlock concern in either branch.

The keyShare branch can now also use postUpsertMutex (instead of running
inline) since the deadlock is gone, which preserves ordering with any
fire-and-forget post-upsert tasks already queued for the same chat.
2026-04-26 15:05:00 -03:00
Renato Alcara b90b383811 fix(inbound): ensure mutex holds until both post-upsert tasks settle
Addresses Copilot review on PR #392.

A plain `Promise.all([doAppStateSync(), processMessage(...)])` rejects on the
first task that fails. When that happens inside the messageMutex callback,
the mutex releases as soon as the rejection settles even though the other
task is still running. The next message for the same chat can then overtake
the in-flight task and break the per-chat ordering guarantee that was the
whole point of the inner mutex.

Wrap each task in its own `.catch` that logs and resolves. The combined
Promise.all now only settles after BOTH operations finish (success or
failure), so the mutex is held for the full duration of the work and
ordering is preserved. Errors are still surfaced via the per-task warnings.
2026-04-26 14:50:14 -03:00
Renato Alcara 35461f96c5 fix(inbound): align mutex key with processMessage chat scope
Addresses CodeRabbit Minor + Copilot review on PR #392:

1. Replace `msg.key.remoteJid || msg.key.id || 'unknown'` with
   `getChatId(msg.key) + jidNormalizedUser`. The previous derivation:
   - Diverged from processMessage's chat scoping (which calls
     `jidNormalizedUser(getChatId(message.key))`). For non-status
     broadcasts processMessage targets `participant`, not `remoteJid`,
     so the inner mutex would queue under a different key than the chat
     processMessage actually mutates — defeating per-chat ordering for
     broadcast traffic.
   - Used `msg.key.id` as a fallback. Per-message ids are unique, so
     malformed-key messages each got their own queue, defeating the
     mutex entirely. Replaced with the constant `'unknown'` bucket.

2. Drop hard-coded line numbers (`process-message.ts:650`,
   `messages-recv.ts:2416`) from comments — references rot. Comments
   now point at the function/handler instead (`APP_STATE_SYNC_KEY_SHARE
   handler`, `inbound caller`).
2026-04-26 14:38:40 -03:00
Renato Alcara 1e37448e05 fix(inbound): avoid messageMutex deadlock in appStateSyncKeyShare branch
Addresses Copilot review on PR #392.

The previous commit wrapped postUpsertWork in messageMutex.mutex(chatId, ...)
for both branches and then awaited it in the appStateSyncKeyShare path. This
deadlocks when upsertMessage is invoked from messages-recv.ts:2416, which is
already holding messageMutex.mutex(chatId, ...) for the same chat:

  outer mutex (in caller)  → await upsertMessage(...)
   └─ upsertMessage work() → schedules inner mutex on same chatId (queued)
       └─ await postUpsertWork  ← inner cannot acquire while outer holds it
                                   outer cannot release while it awaits inner
                                   = eternal deadlock (async-mutex is not re-entrant)

Fire-and-forget path is safe because it does NOT await: outer releases as
soon as upsertMessage returns, then the queued inner acquires.

Fix: in the appStateSyncKeyShare branch, run postUpsertTasks() inline (no
inner messageMutex). Per-chat ordering is still preserved by the caller's
outer mutex while we await. Other paths keep the inner mutex wrapper for
the latency win + per-chat ordering of detached work.
2026-04-26 14:14:16 -03:00
Renato Alcara 72e78a12e0 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.
2026-04-26 13:47:14 -03:00
Renato Alcara a5b7ce7ef9 perf(inbound): detach post-upsert work from buffered function
Removes the await around Promise.all([doAppStateSync, processMessage])
inside upsertMessage. createBufferedFunction (event-buffer.ts:819) only
schedules the buffer flush AFTER its work() callback resolves, so awaiting
post-upsert work pinned the messages.upsert emit inside the buffer for the
full duration of processMessage (signal cleanup, store writes, tcToken
maintenance, history mappings).

After this change the buffered function returns as soon as the synchronous
state-machine bookkeeping completes, the debounced flush releases
messages.upsert to the consumer, and processMessage / doAppStateSync run
in the background. Failures are caught and warn-logged so unhandled
rejections cannot crash the socket.

This is the last remaining sync hop on the inbound hot path that was still
adding latency on top of PR #390 (fire-and-forget LID mapping + tcToken
history sync).
2026-04-26 13:21:52 -03:00
+136 -10
View File
@@ -54,7 +54,7 @@ import {
resolveLidToPn
} from '../Utils'
import { makeKeyedMutex, makeMutex } from '../Utils/make-mutex'
import processMessage from '../Utils/process-message'
import processMessage, { getChatId } from '../Utils/process-message'
import { buildTcTokenFromJid } from '../Utils/tc-token-utils'
import {
type BinaryNode,
@@ -130,6 +130,18 @@ export const makeChatsSocket = (config: SocketConfig) => {
/** this mutex ensures that notifications from the same chat are processed in order, while allowing parallel processing across chats */
const notificationMutex = makeKeyedMutex()
/**
* Per-chat mutex dedicated to post-upsert work (history app-state sync +
* processMessage side effects). Kept separate from `messageMutex` because
* the inbound caller already holds `messageMutex(chatId)` while running
* decrypt + upsertMessage; sharing the same mutex would let a concurrently-
* arrived message N+1 enqueue *between* msg N's outer callback and msg N's
* post-upsert task, so msg N+1's processMessage could run before msg N's
* (breaking per-chat ordering of side effects). With a separate mutex,
* post-upsert tasks enqueue strictly in upsertMessage call order.
*/
const postUpsertMutex = makeKeyedMutex()
// Timeout for AwaitingInitialSync state
let awaitingSyncTimeout: NodeJS.Timeout | undefined
@@ -1399,7 +1411,19 @@ export const makeChatsSocket = (config: SocketConfig) => {
blockedCollections.clear()
logger.info('Doing app state sync')
try {
await resyncAppState(ALL_WA_PATCH_NAMES, true)
} catch (err) {
// Failure recovery: without this, syncState would stay at Syncing
// and ev.flush() would never run, leaving the event buffer pinned
// until the buffer's own safety timeout expires. Force the state
// machine forward so live inbound events can flow even if the
// app-state resync failed (collections are already cleared, so
// blocked patches will be retried on the next creds.update tick).
syncState = SyncState.Online
ev.flush()
throw err
}
// Sync is complete, go online and flush everything
syncState = SyncState.Online
@@ -1411,12 +1435,25 @@ export const makeChatsSocket = (config: SocketConfig) => {
}
}
await Promise.all([
(async () => {
if (shouldProcessHistoryMsg) {
await doAppStateSync()
}
})(),
// Post-upsert work: history app-state sync + processMessage side effects.
// Awaiting here keeps `messages.upsert` pinned in the event buffer
// (createBufferedFunction only schedules flush after work() resolves), so
// the hot path detaches this work to release the emit on the next debounce
// tick.
//
// Use Promise.allSettled so the combined promise only settles after BOTH
// tasks finish. With a plain Promise.all, an early rejection from one task
// would release the keyed mutex while the other task is still mutating
// chat state — letting the next message of the same chat overtake it and
// break per-chat ordering.
//
// Returns the per-task settle status so the keyShare branch can know
// whether processMessage actually persisted the new app-state-sync key
// before triggering doAppStateSync (otherwise the sync would hit
// isMissingKeyError and park collections in blockedCollections).
const postUpsertTasks = async (): Promise<{ processMessageOk: boolean }> => {
const [historyResult, processResult] = await Promise.allSettled([
shouldProcessHistoryMsg ? doAppStateSync() : Promise.resolve(),
processMessage(msg, {
signalRepository,
shouldProcessHistoryMsg,
@@ -1430,10 +1467,99 @@ export const makeChatsSocket = (config: SocketConfig) => {
})
])
// If the app state key arrives and we are waiting to sync, trigger the sync now.
if (msg.message?.protocolMessage?.appStateSyncKeyShare && syncState === SyncState.Syncing) {
logger.info('App state sync key arrived, triggering app state sync')
if (historyResult.status === 'rejected') {
logger?.warn(
{ err: historyResult.reason, messageId: msg.key?.id, remoteJid: msg.key?.remoteJid },
'history doAppStateSync failed'
)
}
if (processResult.status === 'rejected') {
logger?.warn(
{ err: processResult.reason, messageId: msg.key?.id, remoteJid: msg.key?.remoteJid },
'processMessage failed'
)
}
return { processMessageOk: processResult.status === 'fulfilled' }
}
// Use getChatId + jidNormalizedUser so the mutex key matches the chat-id
// scheme processMessage uses for chat updates (broadcasts target the
// participant). When getChatId/jidNormalizedUser yields nothing usable
// (missing or malformed JID), prefer a message-derived fallback over a
// single global 'unknown' bucket — that bucket would head-of-line block
// every malformed message behind a shared queue. msg.key.id is unique
// per message so unrelated malformed inputs no longer serialize together;
// for valid messages we still hit the normalized chat-id path, so the
// per-chat ordering guarantee is unchanged where it matters.
const rawChatId = getChatId(msg.key)
const normalizedChatId = rawChatId ? jidNormalizedUser(rawChatId) : ''
const postUpsertChatId = normalizedChatId || msg.key?.id || 'unknown'
// Wrap in `postUpsertMutex(chatId)` (a SEPARATE keyed mutex from the outer
// `messageMutex` held by the inbound caller) 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.
//
// Why a separate mutex: if we re-used messageMutex, a concurrently-arrived
// message N+1 could enqueue on the outer mutex BEFORE msg N's post-upsert
// task gets enqueued (because N's outer callback yields on `await decrypt()`
// before reaching the inner enqueue site). The queue would then be
// [OuterN+1, InnerN, ...], so InnerN+1 would beat InnerN to processMessage.
// With its own mutex, post-upsert tasks enqueue strictly in upsertMessage
// call order (which IS message arrival order because the outer
// messageMutex serializes the upserts per-chat).
const postUpsertWork = postUpsertMutex.mutex(postUpsertChatId, postUpsertTasks)
const isKeyShareDuringSync =
!!msg.message?.protocolMessage?.appStateSyncKeyShare && syncState === SyncState.Syncing
if (isKeyShareDuringSync) {
// appStateSyncKeyShare path: processMessage persists the new app-state-sync
// key in its APP_STATE_SYNC_KEY_SHARE handler (via keyStore.transaction).
// The follow-up doAppStateSync() needs that key to decrypt patches, so
// we MUST wait for processMessage to actually succeed before kicking off
// the sync — otherwise it would hit isMissingKeyError and park
// collections in blockedCollections, regressing the very issue this
// branch was added to fix.
//
// No deadlock with the inbound caller's messageMutex because
// postUpsertMutex is a different mutex instance.
logger.info('App state sync key arrived, awaiting persistence before triggering sync')
const { processMessageOk } = await postUpsertWork
if (!processMessageOk) {
logger?.warn(
{ messageId: msg.key?.id, remoteJid: msg.key?.remoteJid },
'processMessage failed during key-share — skipping doAppStateSync to avoid isMissingKeyError'
)
} else {
try {
await doAppStateSync()
} catch (err) {
logger?.warn(
{ err, messageId: msg.key?.id, remoteJid: msg.key?.remoteJid },
'doAppStateSync failed after key-share persistence'
)
}
}
} else {
// `postUpsertWork` is not expected to reject — `Promise.allSettled`
// inside `postUpsertTasks` never rejects, and per-task failures are
// already logged inline. The defensive catch routes any truly
// unexpected rejection (e.g. `postUpsertMutex` internal corruption,
// future synchronous throws inside processMessage) through
// `onUnexpectedError` instead of letting it surface as an
// UnhandledPromiseRejection — which on Node ≥15 can terminate the
// long-running socket process.
postUpsertWork.catch(err =>
onUnexpectedError(
err,
`processing post-upsert work for message ${msg.key?.id || 'unknown'} on ${msg.key?.remoteJid || 'unknown chat'}`
)
)
}
})