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
+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)