fix(app-state) - missing key Blocked state, history sync status & 120s timeout

fix(app-state) - missing key Blocked state, history sync status & 120s timeout
This commit is contained in:
Renato Alcara
2026-02-24 00:05:29 -03:00
committed by GitHub
parent a2382e6fb4
commit a9753fde6b
7 changed files with 198 additions and 48 deletions
+3
View File
@@ -156,6 +156,9 @@ export type MediaType = keyof typeof MEDIA_HKDF_KEY_MAPPING
export const MEDIA_KEYS = Object.keys(MEDIA_PATH_MAP) as MediaType[] export const MEDIA_KEYS = Object.keys(MEDIA_PATH_MAP) as MediaType[]
/** 120s timeout for history sync stall detection, same as WA Web's handleChunkProgress / restartPausedTimer (g = 120) */
export const HISTORY_SYNC_PAUSED_TIMEOUT_MS = 120_000
export const MIN_PREKEY_COUNT = 5 export const MIN_PREKEY_COUNT = 5
// Moderate prekey count (upstream uses 812, reduced to balance rate limiting and availability) // Moderate prekey count (upstream uses 812, reduced to balance rate limiting and availability)
+119 -4
View File
@@ -2,7 +2,7 @@ import NodeCache from '@cacheable/node-cache'
import { Boom } from '@hapi/boom' import { Boom } from '@hapi/boom'
import { LRUCache } from 'lru-cache' import { LRUCache } from 'lru-cache'
import { proto } from '../../WAProto/index.js' import { proto } from '../../WAProto/index.js'
import { DEFAULT_CACHE_MAX_KEYS, DEFAULT_CACHE_TTLS, PROCESSABLE_HISTORY_TYPES } from '../Defaults' import { DEFAULT_CACHE_MAX_KEYS, DEFAULT_CACHE_TTLS, HISTORY_SYNC_PAUSED_TIMEOUT_MS, PROCESSABLE_HISTORY_TYPES } from '../Defaults'
import type { import type {
BotListInfo, BotListInfo,
CacheStore, CacheStore,
@@ -42,6 +42,8 @@ import {
generateProfilePicture, generateProfilePicture,
getHistoryMsg, getHistoryMsg,
isAppStateSyncIrrecoverable, isAppStateSyncIrrecoverable,
isMissingKeyError,
MAX_SYNC_ATTEMPTS,
newLTHashState, newLTHashState,
processSyncAction processSyncAction
} from '../Utils' } from '../Utils'
@@ -106,6 +108,17 @@ export const makeChatsSocket = (config: SocketConfig) => {
// Timeout for AwaitingInitialSync state // Timeout for AwaitingInitialSync state
let awaitingSyncTimeout: NodeJS.Timeout | undefined let awaitingSyncTimeout: NodeJS.Timeout | undefined
// In-memory history sync completion tracking (resets on reconnection)
const historySyncStatus = {
initialBootstrapComplete: false,
recentSyncComplete: false
}
let historySyncPausedTimeout: NodeJS.Timeout | undefined
// Collections blocked on missing app state sync keys (mirrors WA Web's "Blocked" state).
// When a key arrives via APP_STATE_SYNC_KEY_SHARE, these are re-synced.
const blockedCollections = new Set<WAPatchName>()
const placeholderResendCache = const placeholderResendCache =
config.placeholderResendCache || config.placeholderResendCache ||
(new NodeCache<number>({ (new NodeCache<number>({
@@ -695,7 +708,6 @@ export const makeChatsSocket = (config: SocketConfig) => {
} catch (error: any) { } catch (error: any) {
attemptsMap[name] = (attemptsMap[name] || 0) + 1 attemptsMap[name] = (attemptsMap[name] || 0) + 1
const irrecoverable = isAppStateSyncIrrecoverable(error, attemptsMap[name])
const logData = { const logData = {
name, name,
attempt: attemptsMap[name], attempt: attemptsMap[name],
@@ -705,7 +717,23 @@ export const makeChatsSocket = (config: SocketConfig) => {
error: error.stack error: error.stack
} }
if (irrecoverable) { if (isMissingKeyError(error) && attemptsMap[name] >= MAX_SYNC_ATTEMPTS) {
// WA Web treats missing keys as "Blocked" — park the collection
// until the key arrives via APP_STATE_SYNC_KEY_SHARE.
logger.warn(
logData,
`${name} blocked on missing key from v${states[name].version}, parking after ${attemptsMap[name]} attempts`
)
blockedCollections.add(name)
collectionsToHandle.delete(name)
} else if (isMissingKeyError(error)) {
// Retry with a snapshot which may use a different key.
logger.info(
logData,
`${name} blocked on missing key from v${states[name].version}, retrying with snapshot`
)
forceSnapshotCollections.add(name)
} else if (isAppStateSyncIrrecoverable(error, attemptsMap[name])) {
logger.warn(logData, `failed to sync ${name} from v${states[name].version}, giving up`) logger.warn(logData, `failed to sync ${name} from v${states[name].version}, giving up`)
// reset persisted version to null so the next resyncAppState call // reset persisted version to null so the next resyncAppState call
// requests a full snapshot instead of reusing the stale version that caused the error // requests a full snapshot instead of reusing the stale version that caused the error
@@ -1230,6 +1258,56 @@ export const makeChatsSocket = (config: SocketConfig) => {
PROCESSABLE_HISTORY_TYPES.includes(historyMsg.syncType! as proto.HistorySync.HistorySyncType) PROCESSABLE_HISTORY_TYPES.includes(historyMsg.syncType! as proto.HistorySync.HistorySyncType)
: false : false
if (historyMsg && shouldProcessHistoryMsg) {
const syncType = historyMsg.syncType as proto.HistorySync.HistorySyncType
// INITIAL_BOOTSTRAP — fire immediately, no progress check (same as WA Web K function)
if (
syncType === proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP &&
!historySyncStatus.initialBootstrapComplete
) {
historySyncStatus.initialBootstrapComplete = true
ev.emit('messaging-history.status', {
syncType,
status: 'complete',
explicit: true
})
}
// RECENT with progress === 100 — explicit completion
if (
syncType === proto.HistorySync.HistorySyncType.RECENT &&
historyMsg.progress === 100 &&
!historySyncStatus.recentSyncComplete
) {
historySyncStatus.recentSyncComplete = true
clearTimeout(historySyncPausedTimeout)
historySyncPausedTimeout = undefined
ev.emit('messaging-history.status', {
syncType,
status: 'complete',
explicit: true
})
}
// Reset 120s paused timeout on any RECENT chunk (like WA Web's handleChunkProgress)
if (syncType === proto.HistorySync.HistorySyncType.RECENT && !historySyncStatus.recentSyncComplete) {
clearTimeout(historySyncPausedTimeout)
historySyncPausedTimeout = setTimeout(() => {
if (!historySyncStatus.recentSyncComplete) {
historySyncStatus.recentSyncComplete = true
ev.emit('messaging-history.status', {
syncType: proto.HistorySync.HistorySyncType.RECENT,
status: 'paused',
explicit: false
})
}
historySyncPausedTimeout = undefined
}, HISTORY_SYNC_PAUSED_TIMEOUT_MS)
}
}
// State machine: decide on sync and flush // State machine: decide on sync and flush
if (historyMsg && syncState === SyncState.AwaitingInitialSync) { if (historyMsg && syncState === SyncState.AwaitingInitialSync) {
if (awaitingSyncTimeout) { if (awaitingSyncTimeout) {
@@ -1250,6 +1328,8 @@ export const makeChatsSocket = (config: SocketConfig) => {
const doAppStateSync = async () => { const doAppStateSync = async () => {
if (syncState === SyncState.Syncing) { if (syncState === SyncState.Syncing) {
// All collections will be synced, so clear any blocked ones
blockedCollections.clear()
logger.info('Doing app state sync') logger.info('Doing app state sync')
await resyncAppState(ALL_WA_PATCH_NAMES, true) await resyncAppState(ALL_WA_PATCH_NAMES, true)
@@ -1330,6 +1410,9 @@ export const makeChatsSocket = (config: SocketConfig) => {
// Clean up app state sync key cache on connection close // Clean up app state sync key cache on connection close
if (connection === 'close') { if (connection === 'close') {
blockedCollections.clear()
clearTimeout(historySyncPausedTimeout)
historySyncPausedTimeout = undefined
appStateSyncKeyCache.clear() appStateSyncKeyCache.clear()
logger.debug('App state sync key cache cleared on connection close') logger.debug('App state sync key cache cleared on connection close')
} }
@@ -1338,6 +1421,11 @@ export const makeChatsSocket = (config: SocketConfig) => {
return return
} }
historySyncStatus.initialBootstrapComplete = false
historySyncStatus.recentSyncComplete = false
clearTimeout(historySyncPausedTimeout)
historySyncPausedTimeout = undefined
syncState = SyncState.AwaitingInitialSync syncState = SyncState.AwaitingInitialSync
logger.info('Connection is now AwaitingInitialSync, buffering events') logger.info('Connection is now AwaitingInitialSync, buffering events')
ev.buffer() ev.buffer()
@@ -1355,7 +1443,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
return return
} }
logger.info('History sync is enabled, awaiting notification with a 20s timeout.') logger.info('First connection, awaiting history sync notification with a 20s timeout.')
if (awaitingSyncTimeout) { if (awaitingSyncTimeout) {
clearTimeout(awaitingSyncTimeout) clearTimeout(awaitingSyncTimeout)
@@ -1367,10 +1455,37 @@ export const makeChatsSocket = (config: SocketConfig) => {
logger.warn('Timeout in AwaitingInitialSync, forcing state to Online and flushing buffer') logger.warn('Timeout in AwaitingInitialSync, forcing state to Online and flushing buffer')
syncState = SyncState.Online syncState = SyncState.Online
ev.flush() ev.flush()
// Increment so subsequent reconnections skip the 20s wait.
// Late-arriving history is still processed via processMessage
// regardless of the state machine phase.
const accountSyncCounter = (authState.creds.accountSyncCounter || 0) + 1
ev.emit('creds.update', { accountSyncCounter })
} }
}, 20_000) }, 20_000)
}) })
// When an app state sync key arrives (myAppStateKeyId is set) and there are
// collections blocked on a missing key, trigger a re-sync for just those collections.
// This mirrors WA Web's Blocked → retry-on-key-arrival behavior.
ev.on('creds.update', ({ myAppStateKeyId }) => {
if (!myAppStateKeyId || blockedCollections.size === 0) {
return
}
// If we're in the middle of a full sync, doAppStateSync handles all collections
if (syncState === SyncState.Syncing) {
blockedCollections.clear()
return
}
const collections = [...blockedCollections] as WAPatchName[]
blockedCollections.clear()
logger.info({ collections }, 'app state sync key arrived, re-syncing blocked collections')
resyncAppState(collections, false).catch(error => onUnexpectedError(error, 'blocked collections resync'))
})
ev.on('lid-mapping.update', async mappings => { ev.on('lid-mapping.update', async mappings => {
try { try {
const result = await signalRepository.lidMapping.storeLIDPNMappings(mappings) const result = await signalRepository.lidMapping.storeLIDPNMappings(mappings)
+14
View File
@@ -30,8 +30,21 @@ export type BaileysEventMap = {
isLatest?: boolean isLatest?: boolean
progress?: number | null progress?: number | null
syncType?: proto.HistorySync.HistorySyncType | null syncType?: proto.HistorySync.HistorySyncType | null
chunkOrder?: number | null
peerDataRequestSessionId?: string | null peerDataRequestSessionId?: string | null
} }
/** signals history sync milestones (completion or stall) per sync type */
'messaging-history.status': {
/** which sync phase this status refers to */
syncType: proto.HistorySync.HistorySyncType
/** the status of this sync phase */
status: 'complete' | 'paused'
/**
* progress === 100 was received from the server.
* when false, completion was inferred via timeout (no more chunks arriving).
*/
explicit: boolean
}
/** upsert chats */ /** upsert chats */
'chats.upsert': Chat[] 'chats.upsert': Chat[]
/** update the given chats */ /** update the given chats */
@@ -176,6 +189,7 @@ export type BufferedEventData = {
isLatest: boolean isLatest: boolean
progress?: number | null progress?: number | null
syncType?: proto.HistorySync.HistorySyncType syncType?: proto.HistorySync.HistorySyncType
chunkOrder?: number | null
peerDataRequestSessionId?: string peerDataRequestSessionId?: string
} }
chatUpserts: { [jid: string]: Chat } chatUpserts: { [jid: string]: Chat }
+18 -17
View File
@@ -144,19 +144,21 @@ export const ensureLTHashStateVersion = (state: LTHashState): LTHashState => {
export const MAX_SYNC_ATTEMPTS = 2 export const MAX_SYNC_ATTEMPTS = 2
/** /**
* Matches WA Web's SyncdFatalError classification: * Check if an error is a missing app state sync key.
* XMPP 400/404/405/406 are fatal, TypeError indicates WASM crash. * WA Web treats these as "Blocked" (waits for key arrival), not fatal.
* In Baileys we retry with a snapshot which may use a different key.
*/
export const isMissingKeyError = (error: any): boolean => {
return error?.data?.isMissingKey === true
}
/**
* Determines if an app state sync error is unrecoverable.
* TypeError indicates a WASM crash; otherwise we give up after MAX_SYNC_ATTEMPTS.
* Missing keys are NOT checked here — they are handled separately as "Blocked".
*/ */
export const isAppStateSyncIrrecoverable = (error: any, attempts: number): boolean => { export const isAppStateSyncIrrecoverable = (error: any, attempts: number): boolean => {
const statusCode = error?.output?.statusCode return attempts >= MAX_SYNC_ATTEMPTS || error?.name === 'TypeError'
return (
attempts >= MAX_SYNC_ATTEMPTS ||
statusCode === 400 ||
statusCode === 404 ||
statusCode === 405 ||
statusCode === 406 ||
error?.name === 'TypeError'
)
} }
export const encodeSyncdPatch = async ( export const encodeSyncdPatch = async (
@@ -167,7 +169,7 @@ export const encodeSyncdPatch = async (
) => { ) => {
const key = !!myAppStateKeyId ? await getAppStateSyncKey(myAppStateKeyId) : undefined const key = !!myAppStateKeyId ? await getAppStateSyncKey(myAppStateKeyId) : undefined
if (!key) { if (!key) {
throw new Boom(`myAppStateKey ("${myAppStateKeyId}") not present`, { statusCode: 404 }) throw new Boom(`myAppStateKey ("${myAppStateKeyId}") not present`, { data: { isMissingKey: true } })
} }
const encKeyId = Buffer.from(myAppStateKeyId, 'base64') const encKeyId = Buffer.from(myAppStateKeyId, 'base64')
@@ -310,8 +312,7 @@ export const decodeSyncdMutations = async (
const keyEnc = await getAppStateSyncKey(base64Key) const keyEnc = await getAppStateSyncKey(base64Key)
if (!keyEnc) { if (!keyEnc) {
throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { throw new Boom(`failed to find key "${base64Key}" to decode mutation`, {
statusCode: 404, data: { isMissingKey: true, msgMutations }
data: { msgMutations }
}) })
} }
@@ -341,7 +342,7 @@ export const decodeSyncdPatch = async (
const base64Key = Buffer.from(msgKeyId).toString('base64') const base64Key = Buffer.from(msgKeyId).toString('base64')
const mainKeyObj = await getAppStateSyncKey(base64Key) const mainKeyObj = await getAppStateSyncKey(base64Key)
if (!mainKeyObj) { if (!mainKeyObj) {
throw new Boom(`failed to find key "${base64Key}" to decode patch`, { statusCode: 404, data: { msg } }) throw new Boom(`failed to find key "${base64Key}" to decode patch`, { data: { isMissingKey: true, msg } })
} }
const mainKeyData = mainKeyObj.keyData const mainKeyData = mainKeyObj.keyData
@@ -513,7 +514,7 @@ export const decodeSyncdSnapshot = async (
const base64Key = Buffer.from(snapKeyId).toString('base64') const base64Key = Buffer.from(snapKeyId).toString('base64')
const keyEnc = await getAppStateSyncKey(base64Key) const keyEnc = await getAppStateSyncKey(base64Key)
if (!keyEnc) { if (!keyEnc) {
throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { statusCode: 404 }) throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { data: { isMissingKey: true } })
} }
const snapKeyData = keyEnc.keyData const snapKeyData = keyEnc.keyData
@@ -602,7 +603,7 @@ export const decodePatches = async (
const base64Key = Buffer.from(patchKeyId).toString('base64') const base64Key = Buffer.from(patchKeyId).toString('base64')
const keyEnc = await getAppStateSyncKey(base64Key) const keyEnc = await getAppStateSyncKey(base64Key)
if (!keyEnc) { if (!keyEnc) {
throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { statusCode: 404 }) throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { data: { isMissingKey: true } })
} }
const patchKeyData = keyEnc.keyData const patchKeyData = keyEnc.keyData
+2
View File
@@ -967,6 +967,7 @@ function append<E extends BufferableEvent>(
data.historySets.empty = false data.historySets.empty = false
data.historySets.syncType = eventData.syncType data.historySets.syncType = eventData.syncType
data.historySets.progress = eventData.progress data.historySets.progress = eventData.progress
data.historySets.chunkOrder = eventData.chunkOrder
data.historySets.peerDataRequestSessionId = eventData.peerDataRequestSessionId data.historySets.peerDataRequestSessionId = eventData.peerDataRequestSessionId
data.historySets.isLatest = eventData.isLatest || data.historySets.isLatest data.historySets.isLatest = eventData.isLatest || data.historySets.isLatest
@@ -1291,6 +1292,7 @@ function consolidateEvents(data: BufferedEventData) {
syncType: data.historySets.syncType, syncType: data.historySets.syncType,
progress: data.historySets.progress, progress: data.historySets.progress,
isLatest: data.historySets.isLatest, isLatest: data.historySets.isLatest,
chunkOrder: data.historySets.chunkOrder,
peerDataRequestSessionId: data.historySets.peerDataRequestSessionId peerDataRequestSessionId: data.historySets.peerDataRequestSessionId
} }
} }
+1
View File
@@ -382,6 +382,7 @@ const processMessage = async (
ev.emit('messaging-history.set', { ev.emit('messaging-history.set', {
...data, ...data,
isLatest: histNotification.syncType !== proto.HistorySync.HistorySyncType.ON_DEMAND ? isLatest : undefined, isLatest: histNotification.syncType !== proto.HistorySync.HistorySyncType.ON_DEMAND ? isLatest : undefined,
chunkOrder: histNotification.chunkOrder,
peerDataRequestSessionId: histNotification.peerDataRequestSessionId peerDataRequestSessionId: histNotification.peerDataRequestSessionId
}) })
@@ -5,6 +5,7 @@ import {
decodeSyncdPatch, decodeSyncdPatch,
decodeSyncdSnapshot, decodeSyncdSnapshot,
isAppStateSyncIrrecoverable, isAppStateSyncIrrecoverable,
isMissingKeyError,
MAX_SYNC_ATTEMPTS, MAX_SYNC_ATTEMPTS,
newLTHashState newLTHashState
} from '../../Utils/chat-utils' } from '../../Utils/chat-utils'
@@ -12,8 +13,8 @@ import {
const missingKeyFn = async () => null const missingKeyFn = async () => null
describe('App State Sync', () => { describe('App State Sync', () => {
describe('missing key errors throw with statusCode 404', () => { describe('missing key errors are marked with isMissingKey (Blocked in WA Web)', () => {
it('decodeSyncdPatch throws 404 on missing key', async () => { it('decodeSyncdPatch throws with isMissingKey on missing key', async () => {
const msg: proto.ISyncdPatch = { const msg: proto.ISyncdPatch = {
keyId: { id: Buffer.from('missing-key') }, keyId: { id: Buffer.from('missing-key') },
mutations: [], mutations: [],
@@ -22,12 +23,15 @@ describe('App State Sync', () => {
patchMac: Buffer.alloc(32) patchMac: Buffer.alloc(32)
} }
await expect( try {
decodeSyncdPatch(msg, 'regular_low', newLTHashState(), missingKeyFn, () => {}, true) await decodeSyncdPatch(msg, 'regular_low', newLTHashState(), missingKeyFn, () => {}, true)
).rejects.toMatchObject({ output: { statusCode: 404 } }) fail('should have thrown')
} catch (error: any) {
expect(isMissingKeyError(error)).toBe(true)
}
}) })
it('decodeSyncdSnapshot throws 404 on missing snapshot key', async () => { it('decodeSyncdSnapshot throws with isMissingKey on missing snapshot key', async () => {
const snapshot: proto.ISyncdSnapshot = { const snapshot: proto.ISyncdSnapshot = {
version: { version: 1 as any }, version: { version: 1 as any },
records: [], records: [],
@@ -35,12 +39,15 @@ describe('App State Sync', () => {
mac: Buffer.alloc(32) mac: Buffer.alloc(32)
} }
await expect(decodeSyncdSnapshot('regular_low', snapshot, missingKeyFn, undefined, true)).rejects.toMatchObject({ try {
output: { statusCode: 404 } await decodeSyncdSnapshot('regular_low', snapshot, missingKeyFn, undefined, true)
}) fail('should have thrown')
} catch (error: any) {
expect(isMissingKeyError(error)).toBe(true)
}
}) })
it('decodeSyncdMutations throws 404 on missing mutation key', async () => { it('decodeSyncdMutations throws with isMissingKey on missing mutation key', async () => {
const records: proto.ISyncdRecord[] = [ const records: proto.ISyncdRecord[] = [
{ {
keyId: { id: Buffer.from('missing-key') }, keyId: { id: Buffer.from('missing-key') },
@@ -49,29 +56,36 @@ describe('App State Sync', () => {
} }
] ]
await expect(decodeSyncdMutations(records, newLTHashState(), missingKeyFn, () => {}, true)).rejects.toMatchObject( try {
{ output: { statusCode: 404 } } await decodeSyncdMutations(records, newLTHashState(), missingKeyFn, () => {}, true)
) fail('should have thrown')
} catch (error: any) {
expect(isMissingKeyError(error)).toBe(true)
}
}) })
it('decodeSyncdMutations throws 404 even with validateMacs=false', async () => { it('missing key errors are NOT irrecoverable on first attempt', async () => {
const records: proto.ISyncdRecord[] = [ const error = new Boom('missing key', { data: { isMissingKey: true } })
{ expect(isMissingKeyError(error)).toBe(true)
keyId: { id: Buffer.from('missing-key') }, expect(isAppStateSyncIrrecoverable(error, 1)).toBe(false)
value: { blob: Buffer.alloc(64) },
index: { blob: Buffer.alloc(32) }
}
]
await expect(
decodeSyncdMutations(records, newLTHashState(), missingKeyFn, () => {}, false)
).rejects.toMatchObject({ output: { statusCode: 404 } })
}) })
}) })
describe('isAppStateSyncIrrecoverable', () => { describe('isAppStateSyncIrrecoverable', () => {
it.each([400, 404, 405, 406])('should be irrecoverable for status %d on first attempt', statusCode => { it('should NOT be irrecoverable for status 400 (dead code path removed)', () => {
expect(isAppStateSyncIrrecoverable(new Boom('test', { statusCode }), 1)).toBe(true) expect(isAppStateSyncIrrecoverable(new Boom('test', { statusCode: 400 }), 1)).toBe(false)
})
it('should NOT be irrecoverable for status 404 (missing key is Blocked, not Fatal)', () => {
expect(isAppStateSyncIrrecoverable(new Boom('test', { statusCode: 404 }), 1)).toBe(false)
})
it('should NOT be irrecoverable for status 405', () => {
expect(isAppStateSyncIrrecoverable(new Boom('test', { statusCode: 405 }), 1)).toBe(false)
})
it('should NOT be irrecoverable for status 406', () => {
expect(isAppStateSyncIrrecoverable(new Boom('test', { statusCode: 406 }), 1)).toBe(false)
}) })
it('should be irrecoverable for TypeError', () => { it('should be irrecoverable for TypeError', () => {