Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions src/Utils/event-buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,18 +358,22 @@ const consolidateEvents = (data: BufferedEventData): BaileysEventData => {
const assignArray = <K extends keyof BaileysEventMap>(event: K, values: BaileysEventMap[K]) => {
if (Array.isArray(values) && values.length > 0) events[event] = values
}
// The order of these writes is the contract, not an implementation detail.
// A flush walks `Object.keys()` of this map, so insertion order decides both
// the key order a `process()` handler iterates and the order the individual
// events are re-dispatched to `.on()` listeners. Handlers that assume
// upstream's sequence — messages before the contacts they reference — see a
// different interleaving if these move. Keep them aligned with upstream's
// `consolidateEvents`.
assignArray('chats.upsert', Object.values(data.chatUpserts))
assignArray('chats.update', Object.values(data.chatUpdates))
assignArray('chats.delete', [...data.chatDeletes])
assignArray('contacts.upsert', Object.values(data.contactUpserts))
assignArray('contacts.update', Object.values(data.contactUpdates))
assignArray('messages.update', Object.values(data.messageUpdates))
assignArray('groups.update', Object.values(data.groupUpdates))

const upserts = Object.values(data.messageUpserts)
if (upserts.length) {
events['messages.upsert'] = { messages: upserts.map(item => item.message), type: upserts[0]!.type }
}
assignArray('messages.update', Object.values(data.messageUpdates))
const deleted = Object.values(data.messageDeletes)
if (deleted.length) events['messages.delete'] = { keys: deleted }
const reactions = Object.values(data.messageReactions).flatMap(({ key, reactions: values }) =>
Expand All @@ -380,6 +384,10 @@ const consolidateEvents = (data: BufferedEventData): BaileysEventData => {
userReceipt.map(receipt => ({ key, receipt }))
)
if (receipts.length) events['message-receipt.update'] = receipts

assignArray('contacts.upsert', Object.values(data.contactUpserts))
assignArray('contacts.update', Object.values(data.contactUpdates))
assignArray('groups.update', Object.values(data.groupUpdates))
return events
}

Expand Down
8 changes: 4 additions & 4 deletions src/__fuzz__/bridge-events.fuzz.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -918,10 +918,10 @@ describe('event buffer', () => {
// these values, and the default normalisation puts back exactly what
// the gate had held apart: `'0'` and `0` fold together and an
// explicitly-present `undefined` disappears. A type or presence
// regression occurring in a sequence that *also* shows the documented
// release-order difference then rendered as two records differing only
// by permutation, and `event-buffer-release-order` excused the whole
// finding.
// regression occurring in a sequence that *also* showed a documented
// consolidation difference then rendered as two records differing only
// in ways an existing entry already excused, and the regression went
// with it.
local: normalise(local, 0, strict),
upstream: normalise(remote, 0, strict),
detail: 'the two buffers released different events for the same sequence'
Expand Down
21 changes: 10 additions & 11 deletions src/__fuzz__/harness/__tests__/harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,10 +343,9 @@ describe('fuzz harness — known-divergence allowlist', () => {
)
})

// The merge-precedence entry pairs observations by kind and identity rather
// than by position, so that it composes with the release-order entry. That is
// exactly the construction that can quietly start excusing a lost event, so
// the boundaries are pinned.
// The merge-precedence entry pairs observations by position and then checks
// kind and identity. That is exactly the construction that can quietly start
// excusing a lost or moved event, so the boundaries are pinned.
it('excuses the buffer merge precedence only for the two kinds it names', async () => {
const { KNOWN_DIVERGENCES } = await import('../divergence.ts')
const registry = KNOWN_DIVERGENCES.filter(entry => entry.id === 'event-buffer-merge-precedence')
Expand Down Expand Up @@ -389,10 +388,13 @@ describe('fuzz harness — known-divergence allowlist', () => {
),
'a wrong value on an unpinned field is reported'
)
// The case that forced the pairing: a field difference *and* a reordering.
// This used to be excused, back when the two buffers released kinds in
// different orders and a sibling entry documented it. `consolidateEvents`
// now matches upstream's order, so a reordering is a finding again and may
// not ride along with a field difference.
assert.ok(
excused([upsert('first'), receipt], [receipt, upsert('second')]),
'a field difference alongside a reordering is the two documented entries together'
!excused([upsert('first'), receipt], [receipt, upsert('second')]),
'a reordering is no longer documented, with or without a field difference'
)

// Near-misses.
Expand All @@ -411,10 +413,7 @@ describe('fuzz harness — known-divergence allowlist', () => {
),
'a changed id is a different entity, not a merge precedence'
)
assert.ok(
!excused([upsert('same'), receipt], [receipt, upsert('same')]),
'a pure reordering belongs to the sibling entry'
)
assert.ok(!excused([upsert('same'), receipt], [receipt, upsert('same')]), 'a pure reordering is a finding')
})

it('excuses the cleanMessage JID rewrite only in the shape it documents', async () => {
Expand Down
69 changes: 16 additions & 53 deletions src/__fuzz__/harness/divergence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,18 +675,17 @@ const addsFieldsOrPinnedValues = (mine: unknown, theirs: unknown): boolean => {
* True when the two released sequences differ only in the fields of those
* kinds, for the same ids.
*
* Order-insensitive, and deliberately so. The release-order entry above
* documents that the two buffers interleave kinds differently, and the two
* differences co-occur constantly — measured, the finding that motivated this
* entry has `contacts.upsert` and `message-receipt.update` swapped *and* a
* contact field differing, so an index-wise comparison matched neither entry.
* Observations are paired by kind and identity first, then what is left has to
* be a field difference on one of those kinds. Composing this way is what the
* copy-strategy entries do, for the same reason.
* Position-sensitive. It was not always: the two buffers used to interleave
* kinds differently, so the finding that motivated this entry had
* `contacts.upsert` and `message-receipt.update` swapped *and* a contact field
* differing, and pairing by index matched neither entry. `consolidateEvents`
* now writes its keys in upstream's order, so the release sequences line up and
* the slack is gone: a reordering is no longer a documented difference, and an
* entry that still tolerated one would be the thing excusing it.
*
* Still narrow: the multiset of kinds has to match, the ids have to match, at
* least one field has to actually differ, and any difference on any other
* release fails.
* Narrow: the kinds have to match in sequence, the ids have to match, at least
* one field has to actually differ, and any difference on any other release
* fails.
*/
const mergePrecedenceFieldsOnly = (local: unknown, upstream: unknown): boolean => {
if (!Array.isArray(local) || !Array.isArray(upstream) || local.length !== upstream.length) return false
Expand All @@ -705,26 +704,19 @@ const mergePrecedenceFieldsOnly = (local: unknown, upstream: unknown): boolean =
: text(item)
}

const remaining = new Map<string, unknown[]>()
for (const item of upstream) {
const key = pairKey(item)
remaining.set(key, [...(remaining.get(key) ?? []), item])
}

let differing = 0
for (const mine of local) {
const key = pairKey(mine)
const bucket = remaining.get(key)
if (bucket === undefined || bucket.length === 0) return false
const theirs = bucket.shift()
for (const [index, mine] of local.entries()) {
const theirs = upstream[index]
// A release that moved is a reordering, and nothing documents one now.
if (pairKey(mine) !== pairKey(theirs)) return false
if (text(mine) === text(theirs)) continue
if (!addsFieldsOrPinnedValues((mine as { data?: unknown })?.data, (theirs as { data?: unknown })?.data)) {
return false
}
differing++
}
// Something has to have differed *inside* a contacts.upsert. Without this the
// entry would excuse a pure reordering, which is the sibling entry's subject.
// Something has to have differed *inside* one of the pinned kinds. Without
// this the entry would excuse two identical sequences, which is not a finding.
return differing > 0
}

Expand Down Expand Up @@ -841,22 +833,6 @@ const text = (value: unknown): string => {
const LONE_SURROGATE =
/[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]|\\ud[89ab][0-9a-f]{2}|\\ud[c-f][0-9a-f]{2}/iu

/**
* True when two observation streams hold the same entries in a different order.
*
* Compared as multisets of their serialised form: same events, same payloads,
* same throws, different sequence.
*/
const isPermutation = (left: unknown, right: unknown): boolean => {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false
const key = (items: unknown[]) =>
items
.map(item => text(item))
.toSorted()
.join('\u0000')
return key(left) === key(right) && text(left) !== text(right)
}

/**
* The registry.
*
Expand Down Expand Up @@ -1135,19 +1111,6 @@ export const KNOWN_DIVERGENCES: readonly KnownDivergence[] = [
"The two buffers consolidate `groups.update` and `contacts.upsert` differently, and on both kinds it is upstream that loses data rather than the two picking different winners. On groups, upstream stores only the *first* update for an id and discards every later one: `src/Utils/event-buffer.ts` merges with `Object.assign(data.groupUpdates[id] || {}, update)` where upstream guards the whole assignment with `if (!data.groupUpdates[id])`, which makes its own merge unreachable. Measured by buffering each pair and flushing: two updates for the same id with subjects 'first' then 'second' release 'second' here and 'first' upstream; and with *disjoint* fields — a subject then an announce — this releases both while upstream releases only the subject and drops the announce outright. On contacts, a buffered `contacts.update` carrying a name is folded into a later `contacts.upsert` here and is not folded upstream, so the released upsert carries the name here and does not upstream. baileyrs is the accumulating side on both, which is what the surrounding branches do for chats and messages and what 'consolidate' means for a buffer; matching upstream would mean deliberately dropping updates a consumer sent. `chats.update` twice, a `groups.update` whose second event carries nothing but the id, and the four message-level consolidation branches all agree, measured at the same time — the id-only case agreeing because there is no field to merge, which is why the `announce` case above does not. Found only once the buffer generator started drawing ids from a shared pool: before that no two buffered events ever referred to the same entity, so none of this ran.",
review: '2026-11-01'
},
{
id: 'event-buffer-release-order',
target: 'buffer:differential',
status: 'open',
// Ordering only. Without this predicate the entry would also excuse a
// corrupted payload, a different consolidation result or a different throw —
// all of which reach `buffer:differential`, and none of which
// `buffer:conservation` can see, since it only counts event names.
when: divergence => isPermutation(divergence.local, divergence.upstream),
reason:
"Flushing a buffer that holds several event kinds releases them in a different order than upstream: for the same sequence, baileyrs emitted contacts.upsert before message-receipt.update where upstream emitted them the other way round. No event is lost — buffer:conservation is clean — but a consumer whose handlers assume upstream's ordering (contacts populated before receipts reference them) sees a different interleaving.",
review: '2026-11-01'
},
{
id: 'proto-decode-above-max-safe-integer',
// Also matched on the wire fuzzer, where the same ceiling stops the library
Expand Down
46 changes: 46 additions & 0 deletions src/__tests__/event-buffer-compatibility.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { makeEventBuffer as makeUpstreamEventBuffer } from 'baileys/lib/Utils/event-buffer.js'
import { describe, it } from 'node:test'
import type { BaileysEventMap } from '../Types/index.ts'
import { makeEventBuffer } from '../Utils/event-buffer.ts'
Expand Down Expand Up @@ -188,4 +189,49 @@ describe('event buffer — upstream process() contract', () => {
// ...and the field that only the middle update carried is still there.
expect(released[0]?.announce).toEqual(true)
})

/**
* A flush walks `Object.keys()` of the consolidated map, so the order that
* `consolidateEvents` writes its keys is the order a `process()` handler
* iterates and the order the individual events reach `.on()` listeners. The
* expectation below is read from upstream at run time rather than written out
* by hand: a hardcoded list would go stale the moment upstream reorders, and
* would then assert compatibility with a version nobody runs.
*/
it('releases the consolidated kinds in upstream’s order', () => {
// Distinct message ids per kind on purpose: a reaction or receipt whose key
// matches a buffered upsert is folded into that message instead of landing
// in its own bucket, and the kind would never appear in the map.
const seed = (ev: ReturnType<typeof makeEventBuffer>) => {
const key = (id: string) => ({ remoteJid: '1@s.whatsapp.net', id, fromMe: false })
ev.buffer()
ev.emit('messages.upsert', { messages: [{ key: key('A'), message: {} } as never], type: 'notify' })
ev.emit('message-receipt.update', [{ key: key('E'), receipt: { userJid: '2@s.whatsapp.net', readTimestamp: 5 } }])
ev.emit('contacts.upsert', [{ id: '2@s.whatsapp.net', name: 'x' }])
ev.emit('contacts.update', [{ id: '3@s.whatsapp.net', name: 'y' }])
ev.emit('groups.update', [{ id: '120@g.us', subject: 's' }])
ev.emit('chats.upsert', [{ id: '9@s.whatsapp.net', conversationTimestamp: 1, unreadCount: 1 }])
ev.emit('chats.update', [{ id: '8@s.whatsapp.net', unreadCount: 1 }])
ev.emit('chats.delete', ['7@s.whatsapp.net'])
ev.emit('messages.update', [{ key: key('B'), update: { status: 3 } }])
ev.emit('messages.delete', { keys: [key('C')] })
ev.emit('messages.reaction', [{ key: key('D'), reaction: { text: '\u{1f44d}', key: key('D') } }])
ev.flush()
}
const orderOf = (make: typeof makeEventBuffer) => {
const ev = make(logger)
let keys: string[] = []
ev.process(events => {
keys = Object.keys(events)
})
seed(ev)
return keys
}

const upstream = orderOf(makeUpstreamEventBuffer as unknown as typeof makeEventBuffer)
// The seed has to actually exercise every kind, or the order it pins is
// only the order of whatever happened to survive consolidation.
expect(upstream.length).toEqual(11)
expect(orderOf(makeEventBuffer)).toEqual(upstream)
})
})
Loading