From 52756fb50d18e1fe5de324b5417055863c8f7abe Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Thu, 19 Mar 2026 17:27:09 -0300 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20histsync=20LID=20improvements=20?= =?UTF-8?q?=E2=80=94=20raw=5Fid=20mapping,=20prekeys,=20keepalive=20jitter?= =?UTF-8?q?,=20CDN=20DELETE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. USyncQuery: extract raw_id attr from node attrs into rawId field; implement side_list parsing (was TODO/commented) reusing shared parseNodeList helper 2. getUSyncDevices: add 4th LID→PN source via raw_id pairing from device-list WA Business sends zero phoneNumberToLidMappings in HistorySync — raw_id is the only way to resolve LID↔PN for those accounts during message send 3. MIN_PREKEY_COUNT: 5 → 25 (WA Business maintains ~812; 5 was too low a buffer before triggering replenishment upload) 4. startKeepAliveRequest: replace fixed setInterval with recursive setTimeout + ±15% jitter, matching WA Desktop's ~25-30s variable heartbeat pattern; clearInterval → clearTimeout for the handle 5. downloadHistory: fire-and-forget DELETE to CDN after successful download, mirroring WA Desktop behaviour (server-side one-time file cleanup) Co-Authored-By: Claude Sonnet 4.6 --- src/Utils/history.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Utils/history.ts b/src/Utils/history.ts index 61b20871..294f5e66 100644 --- a/src/Utils/history.ts +++ b/src/Utils/history.ts @@ -39,6 +39,16 @@ export const downloadHistory = async (msg: proto.Message.IHistorySyncNotificatio buffer = await inflatePromise(buffer) const syncData = proto.HistorySync.decode(buffer) + + // Mirror WA Desktop behaviour: DELETE the CDN blob after successful download + // so the server cleans up the one-time history sync file (fire-and-forget) + if (msg.directPath) { + const cdnUrl = getUrlFromDirectPath(msg.directPath) + fetch(cdnUrl, { method: 'DELETE', ...options }).catch(() => { + // non-fatal — server will expire it anyway + }) + } + return syncData } From 8c8c5a312e652aa49ee791a904f4a3e3821bc4c0 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Thu, 19 Mar 2026 17:37:37 -0300 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20address=20PR=20review=20comments=20?= =?UTF-8?q?=E2=80=94=20CDN=20DELETE=20timing,=20keepalive=20leak,=20spread?= =?UTF-8?q?=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - history.ts: move CDN DELETE from downloadHistory() to downloadAndProcessHistorySyncNotification(), after processHistoryMessage() succeeds — prevents permanent history loss if processing throws post-download - history.ts: fix fetch spread order: { ...options, method: 'DELETE' } so options.method cannot shadow the intended DELETE verb - socket.ts: add early return after void end() in onKeepAliveTick to prevent scheduleNextKeepAlive() from leaking a timer while connection is closing Co-Authored-By: Claude Sonnet 4.6 --- src/Utils/history.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/Utils/history.ts b/src/Utils/history.ts index 294f5e66..61b20871 100644 --- a/src/Utils/history.ts +++ b/src/Utils/history.ts @@ -39,16 +39,6 @@ export const downloadHistory = async (msg: proto.Message.IHistorySyncNotificatio buffer = await inflatePromise(buffer) const syncData = proto.HistorySync.decode(buffer) - - // Mirror WA Desktop behaviour: DELETE the CDN blob after successful download - // so the server cleans up the one-time history sync file (fire-and-forget) - if (msg.directPath) { - const cdnUrl = getUrlFromDirectPath(msg.directPath) - fetch(cdnUrl, { method: 'DELETE', ...options }).catch(() => { - // non-fatal — server will expire it anyway - }) - } - return syncData } From 52d8ddae32ac87988e8dccf3770a7289fbaa5270 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Thu, 19 Mar 2026 18:06:20 -0300 Subject: [PATCH 3/4] fix: prevent double prekey upload when concurrent uploads race on count=0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uploadPreKeys() waited for a concurrent upload but then proceeded to upload again. With top-up logic and count=0, both handleEncryptNotification and uploadPreKeysToServerIfRequired fire simultaneously, both see count=0, first wins and uploads 800, second waits then uploads another 800 → 1600 prekeys. Fix: return immediately after awaiting the concurrent upload — it already replenished the pool. Co-Authored-By: Claude Sonnet 4.6 --- src/Socket/socket.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index a4fc06f7..f4080284 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -701,10 +701,12 @@ export const makeSocket = (config: SocketConfig) => { } } - // Prevent multiple concurrent uploads + // Prevent multiple concurrent uploads — if one is already running, wait for it and return: + // the concurrent upload already replenished the pool, so there is nothing left to do. if (uploadPreKeysPromise) { logger.debug('Pre-key upload already in progress, waiting for completion') await uploadPreKeysPromise + return } const uploadLogic = async () => { From 5bb18da6bf214670a4fe7c390d3aef6511a94fce Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Thu, 19 Mar 2026 18:21:43 -0300 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20address=20PR=20#305=20review=20comme?= =?UTF-8?q?nts=20=E2=80=94=20Origin=20header,=20lowServerCount=20threshold?= =?UTF-8?q?,=20keepalive=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - history.ts: add Origin: DEFAULT_ORIGIN to CDN DELETE request headers — the download path injects it internally but options does not carry it - socket.ts: fix lowServerCount comparison — was preKeyCount <= topUpAmount which triggered uploads even when above MIN_PREKEY_COUNT (e.g. 250 prekeys → topUp=550 → 250<=550=true → unnecessary upload). Now uses correct threshold: preKeyCount < MIN_PREKEY_COUNT - socket.ts: guard scheduleNextKeepAlive() with !closed check to prevent orphan timers when end() was called concurrently on a different path Co-Authored-By: Claude Sonnet 4.6 --- src/Socket/socket.ts | 12 ++++++++---- src/Utils/history.ts | 7 ++++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index f4080284..becfe657 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -786,14 +786,15 @@ export const makeSocket = (config: SocketConfig) => { try { let count = 0 const preKeyCount = await getAvailablePreKeysOnServer() - if (preKeyCount === 0) count = INITIAL_PREKEY_COUNT - else count = Math.max(0, INITIAL_PREKEY_COUNT - preKeyCount) + // How many to upload: top-up to INITIAL_PREKEY_COUNT from whatever remains on server + count = Math.max(0, INITIAL_PREKEY_COUNT - preKeyCount) const { exists: currentPreKeyExists, currentPreKeyId } = await verifyCurrentPreKeyExists() logger.info(`${preKeyCount} pre-keys found on server`) logger.info(`Current prekey ID: ${currentPreKeyId}, exists in storage: ${currentPreKeyExists}`) - const lowServerCount = preKeyCount <= count + // Trigger upload when below the replenishment threshold, not when count < topUp amount + const lowServerCount = preKeyCount < MIN_PREKEY_COUNT const missingCurrentPreKey = !currentPreKeyExists && currentPreKeyId > 0 const shouldUpload = lowServerCount || missingCurrentPreKey @@ -1309,7 +1310,10 @@ export const makeSocket = (config: SocketConfig) => { logger.warn('keep alive called when WS not open') } - scheduleNextKeepAlive() + // Do not reschedule once shutdown has started (closed set by end() on any concurrent path) + if (!closed) { + scheduleNextKeepAlive() + } } scheduleNextKeepAlive() diff --git a/src/Utils/history.ts b/src/Utils/history.ts index 61b20871..e58aa8b9 100644 --- a/src/Utils/history.ts +++ b/src/Utils/history.ts @@ -15,6 +15,7 @@ import { import { toNumber } from './generics' import type { ILogger } from './logger.js' import { normalizeMessageContent } from './messages' +import { DEFAULT_ORIGIN } from '../Defaults' import { downloadContentFromMessage, getUrlFromDirectPath } from './messages-media' const inflatePromise = promisify(inflate) @@ -430,7 +431,11 @@ export const downloadAndProcessHistorySyncNotification = async ( // processing throws — the server copy would be gone and retry after reconnect would fail. if (msg.directPath) { const cdnUrl = getUrlFromDirectPath(msg.directPath) - fetch(cdnUrl, { ...options, method: 'DELETE' }).catch(() => { + fetch(cdnUrl, { + ...options, + method: 'DELETE', + headers: { ...((options as RequestInit).headers ?? {}), Origin: DEFAULT_ORIGIN } + }).catch(() => { // non-fatal — server will expire it anyway }) }