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[]
/** 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
// 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 { LRUCache } from 'lru-cache'
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 {
BotListInfo,
CacheStore,
@@ -42,6 +42,8 @@ import {
generateProfilePicture,
getHistoryMsg,
isAppStateSyncIrrecoverable,
isMissingKeyError,
MAX_SYNC_ATTEMPTS,
newLTHashState,
processSyncAction
} from '../Utils'
@@ -106,6 +108,17 @@ export const makeChatsSocket = (config: SocketConfig) => {
// Timeout for AwaitingInitialSync state
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 =
config.placeholderResendCache ||
(new NodeCache<number>({
@@ -695,7 +708,6 @@ export const makeChatsSocket = (config: SocketConfig) => {
} catch (error: any) {
attemptsMap[name] = (attemptsMap[name] || 0) + 1
const irrecoverable = isAppStateSyncIrrecoverable(error, attemptsMap[name])
const logData = {
name,
attempt: attemptsMap[name],
@@ -705,7 +717,23 @@ export const makeChatsSocket = (config: SocketConfig) => {
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`)
// reset persisted version to null so the next resyncAppState call
// 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)
: 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
if (historyMsg && syncState === SyncState.AwaitingInitialSync) {
if (awaitingSyncTimeout) {
@@ -1250,6 +1328,8 @@ export const makeChatsSocket = (config: SocketConfig) => {
const doAppStateSync = async () => {
if (syncState === SyncState.Syncing) {
// All collections will be synced, so clear any blocked ones
blockedCollections.clear()
logger.info('Doing app state sync')
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
if (connection === 'close') {
blockedCollections.clear()
clearTimeout(historySyncPausedTimeout)
historySyncPausedTimeout = undefined
appStateSyncKeyCache.clear()
logger.debug('App state sync key cache cleared on connection close')
}
@@ -1338,6 +1421,11 @@ export const makeChatsSocket = (config: SocketConfig) => {
return
}
historySyncStatus.initialBootstrapComplete = false
historySyncStatus.recentSyncComplete = false
clearTimeout(historySyncPausedTimeout)
historySyncPausedTimeout = undefined
syncState = SyncState.AwaitingInitialSync
logger.info('Connection is now AwaitingInitialSync, buffering events')
ev.buffer()
@@ -1355,7 +1443,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
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) {
clearTimeout(awaitingSyncTimeout)
@@ -1367,10 +1455,37 @@ export const makeChatsSocket = (config: SocketConfig) => {
logger.warn('Timeout in AwaitingInitialSync, forcing state to Online and flushing buffer')
syncState = SyncState.Online
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)
})
// 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 => {
try {
const result = await signalRepository.lidMapping.storeLIDPNMappings(mappings)
+14
View File
@@ -30,8 +30,21 @@ export type BaileysEventMap = {
isLatest?: boolean
progress?: number | null
syncType?: proto.HistorySync.HistorySyncType | null
chunkOrder?: number | 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 */
'chats.upsert': Chat[]
/** update the given chats */
@@ -176,6 +189,7 @@ export type BufferedEventData = {
isLatest: boolean
progress?: number | null
syncType?: proto.HistorySync.HistorySyncType
chunkOrder?: number | null
peerDataRequestSessionId?: string
}
chatUpserts: { [jid: string]: Chat }
+18 -17
View File
@@ -144,19 +144,21 @@ export const ensureLTHashStateVersion = (state: LTHashState): LTHashState => {
export const MAX_SYNC_ATTEMPTS = 2
/**
* Matches WA Web's SyncdFatalError classification:
* XMPP 400/404/405/406 are fatal, TypeError indicates WASM crash.
* Check if an error is a missing app state sync key.
* 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 => {
const statusCode = error?.output?.statusCode
return (
attempts >= MAX_SYNC_ATTEMPTS ||
statusCode === 400 ||
statusCode === 404 ||
statusCode === 405 ||
statusCode === 406 ||
error?.name === 'TypeError'
)
return attempts >= MAX_SYNC_ATTEMPTS || error?.name === 'TypeError'
}
export const encodeSyncdPatch = async (
@@ -167,7 +169,7 @@ export const encodeSyncdPatch = async (
) => {
const key = !!myAppStateKeyId ? await getAppStateSyncKey(myAppStateKeyId) : undefined
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')
@@ -310,8 +312,7 @@ export const decodeSyncdMutations = async (
const keyEnc = await getAppStateSyncKey(base64Key)
if (!keyEnc) {
throw new Boom(`failed to find key "${base64Key}" to decode mutation`, {
statusCode: 404,
data: { msgMutations }
data: { isMissingKey: true, msgMutations }
})
}
@@ -341,7 +342,7 @@ export const decodeSyncdPatch = async (
const base64Key = Buffer.from(msgKeyId).toString('base64')
const mainKeyObj = await getAppStateSyncKey(base64Key)
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
@@ -513,7 +514,7 @@ export const decodeSyncdSnapshot = async (
const base64Key = Buffer.from(snapKeyId).toString('base64')
const keyEnc = await getAppStateSyncKey(base64Key)
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
@@ -602,7 +603,7 @@ export const decodePatches = async (
const base64Key = Buffer.from(patchKeyId).toString('base64')
const keyEnc = await getAppStateSyncKey(base64Key)
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
+2
View File
@@ -967,6 +967,7 @@ function append<E extends BufferableEvent>(
data.historySets.empty = false
data.historySets.syncType = eventData.syncType
data.historySets.progress = eventData.progress
data.historySets.chunkOrder = eventData.chunkOrder
data.historySets.peerDataRequestSessionId = eventData.peerDataRequestSessionId
data.historySets.isLatest = eventData.isLatest || data.historySets.isLatest
@@ -1291,6 +1292,7 @@ function consolidateEvents(data: BufferedEventData) {
syncType: data.historySets.syncType,
progress: data.historySets.progress,
isLatest: data.historySets.isLatest,
chunkOrder: data.historySets.chunkOrder,
peerDataRequestSessionId: data.historySets.peerDataRequestSessionId
}
}
+1
View File
@@ -382,6 +382,7 @@ const processMessage = async (
ev.emit('messaging-history.set', {
...data,
isLatest: histNotification.syncType !== proto.HistorySync.HistorySyncType.ON_DEMAND ? isLatest : undefined,
chunkOrder: histNotification.chunkOrder,
peerDataRequestSessionId: histNotification.peerDataRequestSessionId
})
@@ -5,6 +5,7 @@ import {
decodeSyncdPatch,
decodeSyncdSnapshot,
isAppStateSyncIrrecoverable,
isMissingKeyError,
MAX_SYNC_ATTEMPTS,
newLTHashState
} from '../../Utils/chat-utils'
@@ -12,8 +13,8 @@ import {
const missingKeyFn = async () => null
describe('App State Sync', () => {
describe('missing key errors throw with statusCode 404', () => {
it('decodeSyncdPatch throws 404 on missing key', async () => {
describe('missing key errors are marked with isMissingKey (Blocked in WA Web)', () => {
it('decodeSyncdPatch throws with isMissingKey on missing key', async () => {
const msg: proto.ISyncdPatch = {
keyId: { id: Buffer.from('missing-key') },
mutations: [],
@@ -22,12 +23,15 @@ describe('App State Sync', () => {
patchMac: Buffer.alloc(32)
}
await expect(
decodeSyncdPatch(msg, 'regular_low', newLTHashState(), missingKeyFn, () => {}, true)
).rejects.toMatchObject({ output: { statusCode: 404 } })
try {
await decodeSyncdPatch(msg, 'regular_low', newLTHashState(), missingKeyFn, () => {}, true)
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 = {
version: { version: 1 as any },
records: [],
@@ -35,12 +39,15 @@ describe('App State Sync', () => {
mac: Buffer.alloc(32)
}
await expect(decodeSyncdSnapshot('regular_low', snapshot, missingKeyFn, undefined, true)).rejects.toMatchObject({
output: { statusCode: 404 }
})
try {
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[] = [
{
keyId: { id: Buffer.from('missing-key') },
@@ -49,29 +56,36 @@ describe('App State Sync', () => {
}
]
await expect(decodeSyncdMutations(records, newLTHashState(), missingKeyFn, () => {}, true)).rejects.toMatchObject(
{ output: { statusCode: 404 } }
)
try {
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 () => {
const records: proto.ISyncdRecord[] = [
{
keyId: { id: Buffer.from('missing-key') },
value: { blob: Buffer.alloc(64) },
index: { blob: Buffer.alloc(32) }
}
]
await expect(
decodeSyncdMutations(records, newLTHashState(), missingKeyFn, () => {}, false)
).rejects.toMatchObject({ output: { statusCode: 404 } })
it('missing key errors are NOT irrecoverable on first attempt', async () => {
const error = new Boom('missing key', { data: { isMissingKey: true } })
expect(isMissingKeyError(error)).toBe(true)
expect(isAppStateSyncIrrecoverable(error, 1)).toBe(false)
})
})
describe('isAppStateSyncIrrecoverable', () => {
it.each([400, 404, 405, 406])('should be irrecoverable for status %d on first attempt', statusCode => {
expect(isAppStateSyncIrrecoverable(new Boom('test', { statusCode }), 1)).toBe(true)
it('should NOT be irrecoverable for status 400 (dead code path removed)', () => {
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', () => {