From 3dadec49003044663c29ab184d7d0015e395a1ef Mon Sep 17 00:00:00 2001 From: Neup123 <156241401+Neup123@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:36:49 +0200 Subject: [PATCH 01/13] fix(whatsapp): preserve paired sessions and bound reconnect/send lifecycle --- .../whatsapp/whatsapp.baileys.service.ts | 99 ++++++++++++++++--- 1 file changed, 84 insertions(+), 15 deletions(-) diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index e8f472783..b63ee8eae 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -309,6 +309,33 @@ export class BaileysStartupService extends ChannelStartupService { return this.stateConnection; } + // A stuck Baileys per-account key mutex must not park API send requests forever. + // This bounds the caller's wait; it does not cancel an operation already inside Baileys. + private async withSendTimeout(operation: () => Promise): Promise { + const parsed = Number(process.env.EVOLUTION_SEND_TIMEOUT_MS); + const timeoutMs = Number.isFinite(parsed) && parsed > 0 ? parsed : 60_000; + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`WhatsApp send timed out after ${timeoutMs}ms`)), timeoutMs); + timer.unref?.(); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + } + + private sendBaileysMessage(...args: Parameters) { + return this.withSendTimeout(() => this.client.sendMessage(...args)); + } + + private relayBaileysMessage(...args: Parameters) { + return this.withSendTimeout(() => this.client.relayMessage(...args)); + } + public async logoutInstance() { clearInterval(this.socketWatchdog); clearTimeout(this.reconnectTimer); @@ -382,6 +409,9 @@ export class BaileysStartupService extends ChannelStartupService { private reconnectTimer?: ReturnType; private reconnectAttempts = 0; + // A 515 restart handshake may produce a trailing 401 on the same session. + private lastStream515At = 0; + private readonly stream515GraceMs = 30_000; private reconnectInFlight = false; private readonly reconnectAttemptTimeoutMs = 30_000; private readonly reconnectBackoffBaseMs = 1_000; @@ -498,9 +528,22 @@ export class BaileysStartupService extends ChannelStartupService { } } + private shouldReconnectAfterClose(statusCode?: number): boolean { + // 408 is both Baileys connectionLost and timedOut. A paired session must + // survive a keep-alive gap; only an unregistered session stops retrying. + if (statusCode === 408) return this.instance.authState?.state?.creds?.registered === true; + const recent515 = this.lastStream515At > 0 && + Date.now() - this.lastStream515At <= this.stream515GraceMs; + if (statusCode === DisconnectReason.loggedOut && recent515) return true; + return ![DisconnectReason.loggedOut, DisconnectReason.forbidden, 402, 406].includes(statusCode); + } + private scheduleReconnect(statusCode?: number) { if (this.reconnectTimer || this.reconnectInFlight || this.stateConnection.state !== 'close') return; - const delay = Math.min(60_000, this.reconnectBackoffBaseMs * 2 ** Math.min(this.reconnectAttempts, 6)); + const ceilingMs = Math.min(60_000, this.reconnectBackoffBaseMs * 2 ** Math.min(this.reconnectAttempts, 16)); + // Full jitter spreads 428 storms across accounts; restartRequired (515) + // must reconnect immediately instead of using the backoff window. + const delay = statusCode === 515 ? 0 : Math.floor(Math.random() * ceilingMs); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = undefined; void this.runReconnectAttempt(statusCode).catch((error) => this.logger.error(error)); @@ -590,7 +633,7 @@ export class BaileysStartupService extends ChannelStartupService { if (connection === 'open') this.pendingReconnect?.settle('opened'); if (connection === 'close') { const code = (lastDisconnect?.error as Boom)?.output?.statusCode; - const terminal = [DisconnectReason.loggedOut, DisconnectReason.forbidden, 402, 406].includes(code); + const terminal = !this.shouldReconnectAfterClose(code); this.pendingReconnect?.settle(terminal ? 'terminal' : 'closed', code); } @@ -679,8 +722,7 @@ export class BaileysStartupService extends ChannelStartupService { clearInterval(this.socketWatchdog); this.stopOutboundQueueWorker(); const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode; - const codesToNotReconnect = [DisconnectReason.loggedOut, DisconnectReason.forbidden, 402, 406]; - const shouldReconnect = !codesToNotReconnect.includes(statusCode); + const shouldReconnect = this.shouldReconnectAfterClose(statusCode); // Invalidate generation-managed sockets after terminal closes. An // externally supplied socket has no build generation to invalidate. if (!shouldReconnect && this.activeSocketBuildRevision !== undefined) this.socketBuildRevision++; @@ -962,7 +1004,32 @@ export class BaileysStartupService extends ChannelStartupService { this.endSession = false; + // Never allow two sockets to use the same credential state. Do not call + // logout(): this is a reconnect, and logout would revoke paired creds. + const previousSocket = this.client; + if (previousSocket) { + try { + (previousSocket.ev as any)?.removeAllListeners?.(); + (previousSocket.ws as any)?.removeAllListeners?.(); + } catch (error) { + this.logger.warn({ message: 'Could not remove superseded socket listeners', error }); + } + try { + previousSocket.end(new Error('Superseded WhatsApp socket')); + } catch (error) { + this.logger.warn({ message: 'Could not end superseded socket', error }); + } + try { + previousSocket.ws?.close(); + } catch (error) { + this.logger.warn({ message: 'Could not close superseded WebSocket', error }); + } + } + this.client = makeWASocket(socketConfig); + this.client.ws.on('CB:stream:error', (node: { attrs?: { code?: string | number } }) => { + if (String(node?.attrs?.code) === '515') this.lastStream515At = Date.now(); + }); this.activeSocketBuildRevision = buildRevision; if (this.localSettings.wavoipToken && this.localSettings.wavoipToken.length > 0) { @@ -2336,7 +2403,7 @@ export class BaileysStartupService extends ChannelStartupService { if (call.from.endsWith('@lid')) { call.from = await this.client.signalRepository.lidMapping.getPNForLID(call.from as string); } - const msg = await this.client.sendMessage(call.from, { text: settings.msgCall }); + const msg = await this.sendBaileysMessage(call.from, { text: settings.msgCall }); this.client.ev.emit('messages.upsert', { messages: [msg], type: 'notify' }); } @@ -2699,7 +2766,7 @@ export class BaileysStartupService extends ChannelStartupService { messageId, quoted, }); - const id = await this.client.relayMessage(sender, message, { messageId }); + const id = await this.relayBaileysMessage(sender, message, { messageId }); m.key = { id: id, remoteJid: sender, participant: isPnUser(sender) ? sender : undefined, fromMe: true }; for (const [key, value] of Object.entries(m)) { if (!value || (isArray(value) && value.length) === 0) { @@ -2717,7 +2784,7 @@ export class BaileysStartupService extends ChannelStartupService { sender !== 'status@broadcast' ) { if (message['reactionMessage']) { - return await this.client.sendMessage( + return await this.sendBaileysMessage( sender, { react: { @@ -2735,7 +2802,7 @@ export class BaileysStartupService extends ChannelStartupService { } if (message['conversation']) { - return await this.client.sendMessage( + return await this.sendBaileysMessage( sender, { text: message['conversation'], @@ -2748,7 +2815,7 @@ export class BaileysStartupService extends ChannelStartupService { } if (!message['audio'] && !message['poll'] && !message['sticker'] && sender != 'status@broadcast') { - return await this.client.sendMessage( + return await this.sendBaileysMessage( sender, { forward: { key: { remoteJid: this.instance.wuid, fromMe: true }, message }, @@ -2784,7 +2851,7 @@ export class BaileysStartupService extends ChannelStartupService { const firstBatch = batches.shift(); if (firstBatch) { - firstMessage = await this.client.sendMessage( + firstMessage = await this.sendBaileysMessage( sender, message['status'].content as unknown as AnyMessageContent, { @@ -2801,7 +2868,7 @@ export class BaileysStartupService extends ChannelStartupService { await Promise.allSettled( batches.map(async (batch) => { - const messageSent = await this.client.sendMessage( + const messageSent = await this.sendBaileysMessage( sender, message['status'].content as unknown as AnyMessageContent, { @@ -2819,7 +2886,7 @@ export class BaileysStartupService extends ChannelStartupService { return firstMessage; } - return await this.client.sendMessage( + return await this.sendBaileysMessage( sender, message as unknown as AnyMessageContent, option as unknown as MiscMessageGenerationOptions, @@ -3158,7 +3225,7 @@ export class BaileysStartupService extends ChannelStartupService { let delivered = false; try { await this.applyOutboundPresence(recipient, safety.delayMs, safety.presence); - const result = await this.client.sendMessage(recipient, message, options); + const result = await this.sendBaileysMessage(recipient, message, options); delivered = true; try { await this.outboundSafety.sent(this.instanceId, safety.auditId); @@ -4766,7 +4833,7 @@ export class BaileysStartupService extends ChannelStartupService { let response; try { - response = await this.client.sendMessage( + response = await this.sendBaileysMessage( deleteKey.remoteJid, { delete: deleteKey }, { messageId: revokeMessageId }, @@ -5163,7 +5230,7 @@ export class BaileysStartupService extends ChannelStartupService { } } - const messageSent = await this.client.sendMessage(jid, { ...(options as any), edit: data.key }); + const messageSent = await this.sendBaileysMessage(jid, { ...(options as any), edit: data.key }); if (messageSent) { const editedMessage = messageSent?.message?.protocolMessage || messageSent?.message?.editedMessage?.message?.protocolMessage; @@ -6044,6 +6111,8 @@ export class BaileysStartupService extends ChannelStartupService { normalizedArgs[1] as AnyMessageContent, normalizedArgs[2], ); + } else if (method === 'relayMessage') { + result = await this.withSendTimeout(() => socketMethod.apply(this.client, normalizedArgs)); } else { result = await socketMethod.apply(this.client, normalizedArgs); } From 9d8bdc35b336155920c4db3f9181489f0956f9f2 Mon Sep 17 00:00:00 2001 From: Neup123 <156241401+Neup123@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:37:04 +0200 Subject: [PATCH 02/13] fix(instance): continue zombie cleanup after failed socket logout --- src/api/controllers/instance.controller.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/api/controllers/instance.controller.ts b/src/api/controllers/instance.controller.ts index 4828494fd..4935d45be 100644 --- a/src/api/controllers/instance.controller.ts +++ b/src/api/controllers/instance.controller.ts @@ -480,7 +480,17 @@ export class InstanceController { if (this.configService.get('CHATWOOT').ENABLED) waInstances?.clearCacheChatwoot(); if (instance.state === 'connecting' || instance.state === 'open') { - await this.logout({ instanceName }); + try { + await this.logout({ instanceName }); + } catch (logoutError) { + // A dead Baileys socket can reject logout. Do not strand a zombie + // instance: remove.instance below is the canonical cleanup path. + this.logger.warn({ + message: 'Zombie socket logout failed during deleteInstance; continuing cleanup', + instanceName, + error: String(logoutError), + }); + } } try { From 153d02a7c03eb141df3374d6fbd340923b1bccdf Mon Sep 17 00:00:00 2001 From: Neup123 <156241401+Neup123@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:38:23 +0200 Subject: [PATCH 03/13] fix(baileys): extend rc14 patch with monotonic persisted WA Web version (#2777) --- patches/baileys+7.0.0-rc14.patch | 465 +++++++++++++++++++++++++++++++ 1 file changed, 465 insertions(+) diff --git a/patches/baileys+7.0.0-rc14.patch b/patches/baileys+7.0.0-rc14.patch index 2ff68ff01..fee2cd26c 100644 --- a/patches/baileys+7.0.0-rc14.patch +++ b/patches/baileys+7.0.0-rc14.patch @@ -225,3 +225,468 @@ index f5edebd..5021c8f 100644 +}; //# sourceMappingURL=companion-reg-client-utils.js.map \ No newline at end of file +diff --git a/node_modules/baileys/lib/Utils/generics.js b/node_modules/baileys/lib/Utils/generics.js +index 1a2b3c4..5d6e7f8 100644 +--- a/node_modules/baileys/lib/Utils/generics.js ++++ b/node_modules/baileys/lib/Utils/generics.js +@@ -1,3 +1,7 @@ + import { Boom } from '@hapi/boom'; ++// WhiskeySockets/Baileys#2777 (chaeulso): monotonic, persisted WA Web version. ++import { resolveWaWebVersion as resolvePatchedWaWebVersion } from './wa-version.js'; ++export { resolveWaWebVersion } from './wa-version.js'; + import { createHash, randomBytes } from 'crypto'; + import { proto } from '../../WAProto/index.js'; +@@ -218,2 +222,5 @@ + export const fetchLatestWaWebVersion = async (options = {}) => { ++ // Preserve the existing API, but route all callers through the monotonic ++ // resolver. The original fetch code below is retained for rc14 patch context. ++ return resolvePatchedWaWebVersion(options); + try { +diff --git a/node_modules/baileys/lib/Utils/generics.d.ts b/node_modules/baileys/lib/Utils/generics.d.ts +index 1a2b3c4..5d6e7f8 100644 +--- a/node_modules/baileys/lib/Utils/generics.d.ts ++++ b/node_modules/baileys/lib/Utils/generics.d.ts +@@ -1,3 +1,5 @@ + import type Long from 'long'; ++// WhiskeySockets/Baileys#2777: persisted monotonic WA Web version. ++export { resolveWaWebVersion } from './wa-version.js'; + import { proto } from '../../WAProto/index.js'; +diff --git a/node_modules/baileys/lib/Utils/wa-version.js b/node_modules/baileys/lib/Utils/wa-version.js +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/node_modules/baileys/lib/Utils/wa-version.js +@@ -0,0 +1,340 @@ ++/** ++ * WhatsApp Web version resolution. ++ * ++ * This module exists because of the reconnect death-spiral reported in ++ * WhiskeySockets/Baileys#2777: a bot starts up, fetches the latest WA Web ++ * client revision (e.g. 2.3000.1045716975), pairs fine, then on the next ++ * reconnect the fetch fails (or the regex misses) and Baileys silently falls ++ * back to the *hardcoded* version baked into the package — which is older than ++ * the one the server already accepted. The server answers `408 Request ++ * Timeout`, the bot reconnects, falls back again, and loops forever. ++ * ++ * Rules enforced here: ++ * 1. a version is never allowed to go DOWN once we have seen a newer one ++ * (monotonic cache, process-wide); ++ * 2. a failed fetch reuses the last known good version instead of the ++ * hardcoded fallback; ++ * 3. the hardcoded fallback is bumped to the newest revision known at release ++ * time, so a cold start on a network with no access to web.whatsapp.com ++ * still has a fighting chance. ++ * ++ * Dependency-free leaf module — safe to import from anywhere (the lib has a ++ * zero-cycle policy). ++ */ ++import { Boom } from '@hapi/boom' ++ ++/** ++ * Newest client revision observed at the time this release was cut. ++ * Verified live against https://web.whatsapp.com/sw.js on 2026-08-29. ++ */ ++export const WA_WEB_VERSION_FALLBACK = [2, 3000, 1046341789] ++ ++/** Default network budget for one sw.js request. */ ++export const WA_WEB_VERSION_FETCH_TIMEOUT_MS = 15000 ++/** Extra attempts after the first failure. */ ++export const WA_WEB_VERSION_FETCH_RETRIES = 2 ++ ++const SW_URL = 'https://web.whatsapp.com/sw.js' ++const DEFAULT_HEADERS = { ++ 'sec-fetch-site': 'none', ++ 'user-agent': ++ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' ++} ++ ++// sw.js ships both `"client_revision\":1046341789` and `client_revision:1046341789` ++// depending on how it was minified; accept either. ++const CLIENT_REVISION_REGEXES = [ ++ /\\?"client_revision\\?":\s*(\d+)/, ++ /client_revision["']?\s*[:=]\s*["']?(\d+)/i, ++ /(\d{10})/ ++] ++ ++/** ++ * Coerce anything version-ish into a `[major, minor, revision]` tuple. ++ * Accepts arrays, "2.3000.1046341789", "2,3000,1046341789" and numbers. ++ * @returns {[number, number, number] | undefined} ++ */ ++export const normalizeWaVersion = version => { ++ if (!version) return undefined ++ ++ let parts ++ if (Array.isArray(version)) { ++ parts = version ++ } else if (typeof version === 'string') { ++ parts = version.split(/[.,\s]+/) ++ } else if (typeof version === 'number') { ++ parts = [2, 3000, version] ++ } else { ++ return undefined ++ } ++ ++ if (parts.length < 1) return undefined ++ ++ const nums = parts.map(p => Number(p)) ++ if (nums.some(n => !Number.isFinite(n) || n < 0)) return undefined ++ ++ // tolerate [2,3000] / "1046341789" / "2.3000.1046341789" ++ if (nums.length === 1) return [2, 3000, nums[0]] ++ if (nums.length === 2) return [nums[0], nums[1], 0] ++ return [nums[0], nums[1], nums[2]] ++} ++ ++/** -1 when a < b, 0 when equal, 1 when a > b. */ ++export const compareWaVersions = (a, b) => { ++ const left = normalizeWaVersion(a) ++ const right = normalizeWaVersion(b) ++ if (!left && !right) return 0 ++ if (!left) return -1 ++ if (!right) return 1 ++ ++ for (let i = 0; i < 3; i++) { ++ if (left[i] !== right[i]) return left[i] < right[i] ? -1 : 1 ++ } ++ ++ return 0 ++} ++ ++/** True when `candidate` is strictly newer than `current`. */ ++export const isWaVersionNewer = (candidate, current) => compareWaVersions(candidate, current) > 0 ++ ++/** Newest of the given versions (invalid entries ignored). */ ++export const pickNewestWaVersion = (...versions) => { ++ let best ++ for (const version of versions) { ++ const normalized = normalizeWaVersion(version) ++ if (!normalized) continue ++ if (!best || compareWaVersions(normalized, best) > 0) best = normalized ++ } ++ ++ return best ++} ++ ++/** ++ * Process-wide "last known good" version. ++ * Once we have successfully talked to the server with a version, we never ++ * advertise an older one again for the lifetime of the process. ++ */ ++let cachedWaWebVersion ++ ++/** The newest version this process has seen, if any. */ ++export const getCachedWaWebVersion = () => cachedWaWebVersion ++ ++/** ++ * Remember a version. Only ever moves forward unless `force` is set. ++ * @returns the version now held in the cache ++ */ ++export const rememberWaWebVersion = (version, { force = false } = {}) => { ++ const normalized = normalizeWaVersion(version) ++ if (!normalized) return cachedWaWebVersion ++ ++ if (force || !cachedWaWebVersion || isWaVersionNewer(normalized, cachedWaWebVersion)) { ++ cachedWaWebVersion = normalized ++ } ++ ++ return cachedWaWebVersion ++} ++ ++/** Drop the process-wide cache (used by tests and by `resolveWaWebVersion`'s force path). */ ++export const resetCachedWaWebVersion = () => { ++ cachedWaWebVersion = undefined ++} ++ ++const fetchOnce = async ({ timeoutMs, fetchImpl, headers, signal }) => { ++ const controller = new AbortController() ++ const timer = setTimeout(() => controller.abort(new Error('timeout')), timeoutMs) ++ // respect a caller-supplied signal as well as our own budget ++ const onAbort = () => controller.abort(signal?.reason ?? new Error('aborted')) ++ if (signal) { ++ if (signal.aborted) onAbort() ++ else signal.addEventListener('abort', onAbort, { once: true }) ++ } ++ ++ try { ++ const response = await fetchImpl(SW_URL, { ++ method: 'GET', ++ headers: { ...DEFAULT_HEADERS, ...headers }, ++ signal: controller.signal ++ }) ++ if (!response.ok) { ++ throw new Boom(`Failed to fetch sw.js: ${response.statusText}`, { statusCode: response.status }) ++ } ++ ++ const data = await response.text() ++ for (const regex of CLIENT_REVISION_REGEXES) { ++ const match = data.match(regex) ++ if (match?.[1]) { ++ return [2, 3000, Number(match[1])] ++ } ++ } ++ ++ throw new Boom('Could not find client revision in the fetched content', { statusCode: 500 }) ++ } finally { ++ clearTimeout(timer) ++ if (signal) signal.removeEventListener('abort', onAbort) ++ } ++} ++ ++/** ++ * Fetch the newest WA Web version. ++ * ++ * Improvements over the stock implementation: ++ * - per-request timeout (no more hangs on a black-holed connection) ++ * - bounded retries with a short linear backoff ++ * - **never downgrades**: a failed fetch, or a fetch that returns an older ++ * revision than one we already used, resolves to the cached/newest version ++ * instead of the stale hardcoded constant (see #2777) ++ * ++ * @param {object} [options] ++ * @param {number} [options.timeoutMs=15000] per-attempt budget ++ * @param {number} [options.retries=2] attempts after the first one ++ * @param {boolean} [options.allowDowngrade=false] return an older fetched version than the cached one ++ * @param {(url: string, init: object) => Promise} [options.fetchImpl] injectable fetch (tests/proxies) ++ * @param {AbortSignal} [options.signal] ++ * @param {Record} [options.headers] ++ */ ++export const fetchLatestWaWebVersion = async (options = {}) => { ++ const { ++ timeoutMs = WA_WEB_VERSION_FETCH_TIMEOUT_MS, ++ retries = WA_WEB_VERSION_FETCH_RETRIES, ++ allowDowngrade = false, ++ fetchImpl = globalThis.fetch, ++ signal, ++ headers, ++ ...rest ++ } = options ++ ++ const fallback = pickNewestWaVersion(cachedWaWebVersion, WA_WEB_VERSION_FALLBACK) ++ ++ if (typeof fetchImpl !== 'function') { ++ return { ++ version: fallback, ++ isLatest: false, ++ error: new Boom('No fetch implementation available', { statusCode: 500 }) ++ } ++ } ++ ++ let lastError ++ const attempts = Math.max(0, retries) + 1 ++ for (let attempt = 0; attempt < attempts; attempt++) { ++ if (signal?.aborted) break ++ try { ++ const fetched = await fetchOnce({ timeoutMs, fetchImpl, headers, signal, ...rest }) ++ const cached = rememberWaWebVersion(fetched) ++ ++ if (!allowDowngrade && compareWaVersions(fetched, cached) < 0) { ++ // server-side rollback / CDN serving a stale sw.js ++ return { ++ version: cached, ++ isLatest: false, ++ error: { ++ message: 'Fetched WA Web version is older than the cached one; keeping the newer version' ++ } ++ } ++ } ++ ++ return { version: cached, isLatest: true } ++ } catch (error) { ++ lastError = error ++ if (attempt < attempts - 1) { ++ await new Promise(resolve => setTimeout(resolve, 500 * (attempt + 1))) ++ } ++ } ++ } ++ ++ if (signal?.aborted) { ++ return { ++ version: fallback, ++ isLatest: false, ++ error: lastError ?? new Boom('aborted', { statusCode: 500 }) ++ } ++ } ++ ++ // All attempts failed — degrade to the best version we know about rather ++ // than to the (possibly months old) hardcoded constant. ++ return { version: fallback, isLatest: false, error: lastError } ++} ++ ++/** ++ * Resolve the version a bot should hand to `makeWASocket`. ++ * ++ * Combines, newest-first: the persisted cache file (if any), a live fetch, and ++ * the packaged fallback. Persisting across restarts matters: without it a ++ * process restart lands back on the hardcoded constant and can re-enter the ++ * 408 loop before the first fetch resolves. ++ * ++ * @param {object} [options] ++ * @param {string} [options.cachePath] path of a JSON file used to persist the last good version ++ * @param {boolean} [options.force] ignore the in-process cache ++ * @param {boolean} [options.skipFetch] use cache/fallback only (fully offline) ++ * @param {number} [options.maxAgeMs] reuse the cached file entry if younger than this ++ * @param {(options: object) => Promise<{version: any, isLatest: boolean, error?: any}>} [options.fetch] ++ */ ++export const resolveWaWebVersion = async (options = {}) => { ++ const { ++ cachePath, ++ force = false, ++ skipFetch = false, ++ maxAgeMs = 6 * 60 * 60 * 1000, // 6h ++ fetch: fetchFn = fetchLatestWaWebVersion, ++ ...fetchOptions ++ } = options ++ ++ if (force) resetCachedWaWebVersion() ++ ++ let persisted ++ if (cachePath) { ++ persisted = await readVersionCache(cachePath) ++ if (persisted) rememberWaWebVersion(persisted.version) ++ } ++ ++ const cached = getCachedWaWebVersion() ++ const freshEnough = ++ persisted && typeof maxAgeMs === 'number' && Date.now() - persisted.fetchedAt < maxAgeMs ++ ++ let fetched ++ let fetchError ++ if (!skipFetch && !(cached && freshEnough)) { ++ const result = await fetchFn(fetchOptions) ++ fetched = result?.version ++ fetchError = result?.error ++ if (fetched) rememberWaWebVersion(fetched) ++ } ++ ++ const version = pickNewestWaVersion(getCachedWaWebVersion(), fetched, persisted?.version, WA_WEB_VERSION_FALLBACK) ++ rememberWaWebVersion(version) ++ ++ if (cachePath && (!persisted || compareWaVersions(version, persisted.version) > 0)) { ++ await writeVersionCache(cachePath, version) ++ } ++ ++ return { ++ version, ++ isLatest: !!fetched && compareWaVersions(version, fetched) === 0 && !fetchError, ++ source: fetched ? 'fetch' : persisted ? 'cache' : 'fallback', ++ error: fetchError ++ } ++} ++ ++const readVersionCache = async cachePath => { ++ try { ++ const { readFile } = await import('fs/promises') ++ const raw = await readFile(cachePath, 'utf-8') ++ const parsed = JSON.parse(raw) ++ const version = normalizeWaVersion(parsed?.version) ++ if (!version) return undefined ++ return { version, fetchedAt: Number(parsed?.fetchedAt) || 0 } ++ } catch { ++ return undefined ++ } ++} ++ ++const writeVersionCache = async (cachePath, version) => { ++ try { ++ const { mkdir, writeFile } = await import('fs/promises') ++ const { dirname } = await import('path') ++ await mkdir(dirname(cachePath), { recursive: true }) ++ await writeFile(cachePath, JSON.stringify({ version, fetchedAt: Date.now() }, null, 2), 'utf-8') ++ } catch { ++ // a read-only filesystem must never break startup ++ } ++} +diff --git a/node_modules/baileys/lib/Utils/wa-version.d.ts b/node_modules/baileys/lib/Utils/wa-version.d.ts +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/node_modules/baileys/lib/Utils/wa-version.d.ts +@@ -0,0 +1,87 @@ ++import type { WAVersion } from '../Types/index.js' ++ ++/** ++ * Newest WA Web client revision known at the time this release was cut. ++ * Verified live against https://web.whatsapp.com/sw.js on 2026-08-29. ++ */ ++export declare const WA_WEB_VERSION_FALLBACK: WAVersion ++/** Default network budget for one sw.js request. */ ++export declare const WA_WEB_VERSION_FETCH_TIMEOUT_MS: number ++/** Extra attempts after the first failure. */ ++export declare const WA_WEB_VERSION_FETCH_RETRIES: number ++ ++/** Coerce anything version-ish into a `[major, minor, revision]` tuple. */ ++export declare const normalizeWaVersion: ( ++ version: WAVersion | string | number | readonly number[] | null | undefined ++) => WAVersion | undefined ++/** -1 when a < b, 0 when equal, 1 when a > b. */ ++export declare const compareWaVersions: ( ++ a: WAVersion | string | number | readonly number[] | null | undefined, ++ b: WAVersion | string | number | readonly number[] | null | undefined ++) => -1 | 0 | 1 ++/** True when `candidate` is strictly newer than `current`. */ ++export declare const isWaVersionNewer: ( ++ candidate: WAVersion | string | number | readonly number[] | null | undefined, ++ current: WAVersion | string | number | readonly number[] | null | undefined ++) => boolean ++/** Newest of the given versions (invalid entries ignored). */ ++export declare const pickNewestWaVersion: ( ++ ...versions: (WAVersion | string | number | readonly number[] | null | undefined)[] ++) => WAVersion | undefined ++ ++/** The newest version this process has seen, if any. */ ++export declare const getCachedWaWebVersion: () => WAVersion | undefined ++/** Remember a version. Only ever moves forward unless `force` is set. */ ++export declare const rememberWaWebVersion: ( ++ version: WAVersion | string | number | readonly number[] | null | undefined, ++ options?: { force?: boolean } ++) => WAVersion | undefined ++/** Drop the process-wide cache. */ ++export declare const resetCachedWaWebVersion: () => void ++ ++export type FetchWaWebVersionOptions = { ++ /** per-attempt network budget, defaults to 15s */ ++ timeoutMs?: number ++ /** attempts after the first one, defaults to 2 */ ++ retries?: number ++ /** return a fetched version that is older than the cached one (default false) */ ++ allowDowngrade?: boolean ++ /** injectable fetch — handy for proxies and tests */ ++ fetchImpl?: (url: string, init: Record) => Promise<{ ok: boolean; statusText?: string; text(): Promise }> ++ signal?: AbortSignal ++ headers?: Record ++ [key: string]: unknown ++} ++ ++export type FetchWaWebVersionResult = { ++ version: WAVersion ++ isLatest: boolean ++ error?: unknown ++} ++ ++/** ++ * Fetch the newest WA Web version. Never downgrades: a failed fetch, or a ++ * fetch returning an older revision, resolves to the newest version already ++ * known instead of the packaged constant (WhiskeySockets/Baileys#2777). ++ */ ++export declare const fetchLatestWaWebVersion: (options?: FetchWaWebVersionOptions) => Promise ++ ++export type ResolveWaWebVersionOptions = FetchWaWebVersionOptions & { ++ /** JSON file used to persist the last good version across restarts */ ++ cachePath?: string ++ /** ignore the in-process cache */ ++ force?: boolean ++ /** cache/fallback only — no network */ ++ skipFetch?: boolean ++ /** reuse a cached file entry younger than this (default 6h) */ ++ maxAgeMs?: number ++ fetch?: (options?: FetchWaWebVersionOptions) => Promise ++} ++ ++export type ResolveWaWebVersionResult = FetchWaWebVersionResult & { ++ /** where the version came from */ ++ source: 'fetch' | 'cache' | 'fallback' ++} ++ ++/** Resolve the version a bot should hand to `makeWASocket`. */ ++export declare const resolveWaWebVersion: (options?: ResolveWaWebVersionOptions) => Promise From 639c5a5fe7ca4e8e998889b4bad6352fd68fd416 Mon Sep 17 00:00:00 2001 From: Neup123 <156241401+Neup123@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:38:58 +0200 Subject: [PATCH 04/13] fix(whatsapp): use persisted Baileys WA Web version resolver --- .../channel/whatsapp/whatsapp.baileys.service.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index b63ee8eae..75601ce8c 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -109,7 +109,6 @@ import { Boom } from '@hapi/boom'; import { createId as cuid } from '@paralleldrive/cuid2'; import { Instance, Prisma } from '@prisma/client'; import { createJid } from '@utils/createJid'; -import { fetchLatestWaWebVersion } from '@utils/fetchLatestWaWebVersion'; import { makeProxyAgent, makeProxyAgentUndici } from '@utils/makeProxyAgent'; import { getOnWhatsappCache, saveOnWhatsappCache } from '@utils/onWhatsappCache'; import { status } from '@utils/renderStatus'; @@ -161,6 +160,7 @@ import makeWASocket, { prepareWAMessageMedia, Product, proto, + resolveWaWebVersion, UserFacingSocketConfig, WABrowserDescription, WAMediaUpload, @@ -899,7 +899,11 @@ export class BaileysStartupService extends ChannelStartupService { this.logger.info(`Browser: ${browser}`); } - const baileysVersion = await fetchLatestWaWebVersion({}); + // #2777: persist the last good WA Web revision on the existing + // /evolution/instances Docker volume, not in a disposable process cache. + const baileysVersion = await resolveWaWebVersion({ + cachePath: process.env.EVOLUTION_WA_VERSION_CACHE_PATH || join(process.cwd(), 'instances', 'wa-web-version.json'), + }); if (buildRevision !== this.socketBuildRevision) throw new Error('Superseded WhatsApp socket build'); const version = baileysVersion.version; const log = `Baileys version: ${version.join('.')}`; From 2d7a574d458f9e72f486fbcde3bd306dd7909373 Mon Sep 17 00:00:00 2001 From: Neup123 <156241401+Neup123@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:40:27 +0200 Subject: [PATCH 05/13] fix(patch): correct unified diff hunk counts for WA version cache --- patches/baileys+7.0.0-rc14.patch | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/patches/baileys+7.0.0-rc14.patch b/patches/baileys+7.0.0-rc14.patch index fee2cd26c..d01995082 100644 --- a/patches/baileys+7.0.0-rc14.patch +++ b/patches/baileys+7.0.0-rc14.patch @@ -229,14 +229,14 @@ diff --git a/node_modules/baileys/lib/Utils/generics.js b/node_modules/baileys/l index 1a2b3c4..5d6e7f8 100644 --- a/node_modules/baileys/lib/Utils/generics.js +++ b/node_modules/baileys/lib/Utils/generics.js -@@ -1,3 +1,7 @@ +@@ -1,3 +1,6 @@ import { Boom } from '@hapi/boom'; +// WhiskeySockets/Baileys#2777 (chaeulso): monotonic, persisted WA Web version. +import { resolveWaWebVersion as resolvePatchedWaWebVersion } from './wa-version.js'; +export { resolveWaWebVersion } from './wa-version.js'; import { createHash, randomBytes } from 'crypto'; import { proto } from '../../WAProto/index.js'; -@@ -218,2 +222,5 @@ +@@ -218,2 +221,5 @@ export const fetchLatestWaWebVersion = async (options = {}) => { + // Preserve the existing API, but route all callers through the monotonic + // resolver. The original fetch code below is retained for rc14 patch context. @@ -246,7 +246,7 @@ diff --git a/node_modules/baileys/lib/Utils/generics.d.ts b/node_modules/baileys index 1a2b3c4..5d6e7f8 100644 --- a/node_modules/baileys/lib/Utils/generics.d.ts +++ b/node_modules/baileys/lib/Utils/generics.d.ts -@@ -1,3 +1,5 @@ +@@ -1,2 +1,4 @@ import type Long from 'long'; +// WhiskeySockets/Baileys#2777: persisted monotonic WA Web version. +export { resolveWaWebVersion } from './wa-version.js'; From 7990bf7820b2cb5c5fd2bf4f0baa98195faf3d7a Mon Sep 17 00:00:00 2001 From: Neup123 <156241401+Neup123@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:41:51 +0200 Subject: [PATCH 06/13] test(connection): cover registered 408 and 515-to-401 grace handling --- test/connection-recovery.test.ts | 60 ++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/test/connection-recovery.test.ts b/test/connection-recovery.test.ts index 1b7a7e5aa..f0c9812f9 100644 --- a/test/connection-recovery.test.ts +++ b/test/connection-recovery.test.ts @@ -135,11 +135,71 @@ async function terminalDoesNotRetry() { assert.equal(attempts, 0); } +// A keep-alive 408 must never destroy credentials already paired to WhatsApp. +async function registered408Retries() { + const { service, socket, stored } = harness(); + service.instance.authState = { state: { creds: { registered: true } } }; + const reconnectCodes: number[] = []; + const events: string[] = []; + service.scheduleReconnect = (code: number) => { reconnectCodes.push(code); }; + service.eventEmitter.emit = (event: string) => { events.push(event); }; + await service.connectionUpdate({ connection: 'close', lastDisconnect: { + error: new Boom('Keep-alive timed out', { statusCode: 408 }), + } }, socket); + assert.deepEqual(reconnectCodes, [408]); + assert.equal(stored.connectionStatus, 'close'); + assert.ok(!events.includes('logout.instance'), '408 may not wipe paired credentials'); +} + +async function unregistered408Terminates() { + const { service, socket } = harness(); + service.instance.authState = { state: { creds: { registered: false } } }; + const reconnectCodes: number[] = []; + const events: string[] = []; + service.scheduleReconnect = (code: number) => { reconnectCodes.push(code); }; + service.eventEmitter.emit = (event: string) => { events.push(event); }; + await service.connectionUpdate({ connection: 'close', lastDisconnect: { + error: new Boom('Unpaired registration timed out', { statusCode: 408 }), + } }, socket); + assert.deepEqual(reconnectCodes, []); + assert.ok(events.includes('logout.instance'), 'Unregistered 408 must terminate'); +} + +async function trailing401After515Retries() { + const { service, socket } = harness(); + service.lastStream515At = Date.now(); + const reconnectCodes: number[] = []; + const events: string[] = []; + service.scheduleReconnect = (code: number) => { reconnectCodes.push(code); }; + service.eventEmitter.emit = (event: string) => { events.push(event); }; + await service.connectionUpdate({ connection: 'close', lastDisconnect: { + error: new Boom('Trailing loggedOut after restartRequired', { statusCode: DisconnectReason.loggedOut }), + } }, socket); + assert.deepEqual(reconnectCodes, [DisconnectReason.loggedOut]); + assert.ok(!events.includes('logout.instance'), 'Recent 515 must protect the session'); +} + +async function expired515DoesNotMaskLogout() { + const { service, socket } = harness(); + service.lastStream515At = Date.now() - 31_000; + const events: string[] = []; + service.eventEmitter.emit = (event: string) => { events.push(event); }; + service.scheduleReconnect = () => { throw new Error('401 outside grace must not reconnect'); }; + await service.connectionUpdate({ connection: 'close', lastDisconnect: { + error: new Boom('Real loggedOut', { statusCode: DisconnectReason.loggedOut }), + } }, socket); + assert.ok(events.includes('logout.instance')); +} + async function main() { await failedReconnectThenOpen(); await hangingReconnectThenOpen(); await delayedOpenCannotOverwriteClose(); await terminalDoesNotRetry(); + await registered408Retries(); + await unregistered408Terminates(); + await trailing401After515Retries(); + await expired515DoesNotMaskLogout(); console.log('Connection recovery tests passed'); } From 99b67c2dc9b1f859fdd801b2ff4bf8470bbd3e53 Mon Sep 17 00:00:00 2001 From: Neup123 <156241401+Neup123@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:42:47 +0200 Subject: [PATCH 07/13] docs: add connection-stability build plan and upstream removal gates --- docs/build-plan-connection-stability.md | 52 +++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/build-plan-connection-stability.md diff --git a/docs/build-plan-connection-stability.md b/docs/build-plan-connection-stability.md new file mode 100644 index 000000000..84483335f --- /dev/null +++ b/docs/build-plan-connection-stability.md @@ -0,0 +1,52 @@ +# Connection-stability bundle — build plan + +**Repository:** `Neup123/evolution-api` (Core fork, v5.5.3). +**Base:** `main` at `f1ccc8f3a0aabf62b4540dfc4cd87812d03a5d94` (includes #53). +**Tracking issue:** [#54](https://github.com/Neup123/evolution-api/issues/54). +**Scope:** source-side lifecycle/timeout/zombie cleanup plus one regenerated `baileys@7.0.0-rc14` patch. No deployment or merge. + +## Implementation sequence + +1. **Preserve credentials** — `shouldReconnectAfterClose()` treats 408 as retryable for `authState.state.creds.registered === true`; an unregistered 408 remains terminal. A 401 only receives a transient exception when a same-service `CB:stream:error` 515 was seen within 30,000 ms. Capture the stream event before registering other socket handlers. +2. **Bound reconnect** — reuse the fork's existing generation fence, single-flight retry, 30-second attempt deadline, and reset-on-open. Add full jitter with capped exponential delay (60,000 ms maximum) and immediate status 515 restart. Dispose previous socket listeners and then close/end it before calling `makeWASocket`; do not logout or delete credentials during reconnect. +3. **Bound sends** — route direct `sendMessage` and `relayMessage` calls, including the dynamic relay API path, through `withSendTimeout`. Default 60,000 ms; override with positive finite `EVOLUTION_SEND_TIMEOUT_MS`. A timeout bounds the caller's wait, **not** the in-progress Baileys mutex operation. Avoid blind automatic retry after a timeout. +4. **Allow zombie deletion** — if `deleteInstance` calls logout on a dead socket and that throws, warn and continue to the canonical `remove.instance` event. Do not swallow unrelated failures in the rest of the controller. +5. **Monotonic WA version** — keep #53's `patch-package --error-on-fail` integration and all QR/registration hunks. Add isolated #2777 hunks to the same `patches/baileys+7.0.0-rc14.patch`; the vendored `lib/Utils/wa-version.js` and declaration are adapted from [chaeulso/baileys](https://github.com/chaeulso/baileys/blob/master/docs/BUGFIXES.md). Use `resolveWaWebVersion()` in socket creation. The cache lives by default at `/instances/wa-web-version.json`, on the existing `/evolution/instances` Docker volume, with optional `EVOLUTION_WA_VERSION_CACHE_PATH` override. Fetch failures, non-200s, unparsable revisions and older CDN revisions never downgrade a cached version. Cache persists across restarts. +6. **Tests / evidence / review** — no merge or image publication until the clean install, build, executable tests and output inspection below pass in a dependency-equipped runner. + +## Dependencies and verification + +Use an environment with internet access to npm and the repository checked out at the PR head. The active Chat tool execution environment cannot reach the npm registry or github.com from its shell; connector-level source and patch inspections **do not substitute for running the checks**. + +```bash +rm -rf node_modules +npm ci 2>&1 | tee /tmp/evolution-npm-ci.log +grep -F 'baileys@7.0.0-rc14' /tmp/evolution-npm-ci.log +npm run build + +# Existing fork suites (only the first has a package.json script): +npm run test:connection-recovery +npx tsx test/socket-watchdog.test.ts +npx tsx test/disconnection-fields.test.ts + +# Compiled service/bundled source checks (actual paths may vary by tsup output): +grep -R -nE 'shouldReconnectAfterClose|registered|lastStream515At|stream515GraceMs' dist +grep -R -nE 'ceilingMs|Math.random|withSendTimeout|WhatsApp send timed out' dist +grep -R -nE 'remove.instance|Zombie socket logout failed' dist +grep -R -nE 'resolveWaWebVersion|readVersionCache|writeVersionCache|cachedWaWebVersion' node_modules/baileys/lib/Utils +grep -R -n 'companion_reg_refresh' node_modules/baileys/lib +``` + +The generated `dist` may minify symbols: compare emitted strings and inspect output chunks; missing exact identifiers due to bundling is not automatically a failure, but missing behavior is. Confirm that `npm ci` displays `patch-package` applying the complete patch to `baileys@7.0.0-rc14` and exits successfully. A failed patch must stop install. Verify both paid and unpaired 408 cases, 515 then 401 within/outside 30s, recovery from 428, one-socket teardown, timeout rejection, and zombie cleanup. Review CI on the PR directly; no checks is not success. + +## Provenance and intentional exclusions + +- FAFAzin / VENDORA stability work, referenced in [Evolution #2501](https://github.com/evolution-foundation/evolution-api/pull/2501): **do not** carry its unconditional 408 no-reconnect or unrelated logout reliability changes. The user's referenced `VENDORA-PATCHES.md` and `docs/brain/reconnect-freeze.md` paths were unavailable in the public FAFAzin default branch when inspected; use the #2501 discussion as supporting context and verify against private copies if supplied. +- [Evolution #2509](https://github.com/evolution-foundation/evolution-api/pull/2509): 30-second stream-515 / trailing-401 guard. +- [Evolution #2520](https://github.com/evolution-foundation/evolution-api/pull/2520): zombie deletion even when logout fails. Merged upstream but not present in this fork's base. +- [Baileys #2777](https://github.com/WhiskeySockets/Baileys/issues/2777) / [chaeulso BUGFIXES](https://github.com/chaeulso/baileys/blob/master/docs/BUGFIXES.md): monotonic resolver and persistent cache. The #53 QR fix (#2765/#2602/#2749-line) is preserved. +- **Skip #2784**: its `query()`/init-query and pre-key retry changes cut across unrelated socket/chats modules and are not required to resolve the four incident classes; carrying them without live verification would broaden this surgical PR. + +## Upstream removal gates + +Remove each Evolution workaround only when a tested **2.3.x** release has that precise behavior; #2520 on upstream `develop` alone is insufficient. Remove Baileys #2777 hunks only when an explicitly tested, compatible future Baileys version contains monotonic persisted version resolution. Independently keep #53's QR pairing patch until the upstream package contains it. The pin remains **exactly `7.0.0-rc14`** in this PR. From ba11ffb75ddfd623333d7e7c38e1e37c53239837 Mon Sep 17 00:00:00 2001 From: Neup123 <156241401+Neup123@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:44:12 +0200 Subject: [PATCH 08/13] fix(whatsapp): fence socket creation after asynchronous proxy setup --- .../integrations/channel/whatsapp/whatsapp.baileys.service.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 75601ce8c..2138ec5bd 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -1006,6 +1006,8 @@ export class BaileysStartupService extends ChannelStartupService { }, }; + // A slow proxy lookup may have been superseded after the earlier revision check. + if (buildRevision !== this.socketBuildRevision) throw new Error('Superseded WhatsApp socket build'); this.endSession = false; // Never allow two sockets to use the same credential state. Do not call From 6b6838c8553e9be7aeeceb15545010b442721aab Mon Sep 17 00:00:00 2001 From: Neup123 <156241401+Neup123@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:47:28 +0200 Subject: [PATCH 09/13] docs: clarify paired and unpaired 408 acceptance cases --- docs/build-plan-connection-stability.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/build-plan-connection-stability.md b/docs/build-plan-connection-stability.md index 84483335f..328b60857 100644 --- a/docs/build-plan-connection-stability.md +++ b/docs/build-plan-connection-stability.md @@ -37,7 +37,7 @@ grep -R -nE 'resolveWaWebVersion|readVersionCache|writeVersionCache|cachedWaWebV grep -R -n 'companion_reg_refresh' node_modules/baileys/lib ``` -The generated `dist` may minify symbols: compare emitted strings and inspect output chunks; missing exact identifiers due to bundling is not automatically a failure, but missing behavior is. Confirm that `npm ci` displays `patch-package` applying the complete patch to `baileys@7.0.0-rc14` and exits successfully. A failed patch must stop install. Verify both paid and unpaired 408 cases, 515 then 401 within/outside 30s, recovery from 428, one-socket teardown, timeout rejection, and zombie cleanup. Review CI on the PR directly; no checks is not success. +The generated `dist` may minify symbols: compare emitted strings and inspect output chunks; missing exact identifiers due to bundling is not automatically a failure, but missing behavior is. Confirm that `npm ci` displays `patch-package` applying the complete patch to `baileys@7.0.0-rc14` and exits successfully. A failed patch must stop install. Verify both paired and unpaired 408 cases, 515 then 401 within/outside 30s, recovery from 428, one-socket teardown, timeout rejection, and zombie cleanup. Review CI on the PR directly; no checks is not success. ## Provenance and intentional exclusions From 0476ab457c9b7de89c03e7d7ff440f909d586bbf Mon Sep 17 00:00:00 2001 From: Neup123 <156241401+Neup123@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:49:57 +0200 Subject: [PATCH 10/13] test(connection): mark simulated healthy sockets as registered --- test/connection-recovery.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/connection-recovery.test.ts b/test/connection-recovery.test.ts index f0c9812f9..2cba63741 100644 --- a/test/connection-recovery.test.ts +++ b/test/connection-recovery.test.ts @@ -31,6 +31,7 @@ function harness() { // status persistence and retry methods with controlled collaborators. const service: any = Object.create(BaileysStartupService.prototype); service.instance = { id: 'instance-1', name: 'test', qrcode: { count: 0 } }; + service.instance.authState = { state: { creds: { registered: true } } }; service.client = socket; service.stateConnection = { state: 'open' }; service.connectionRevision = 0; From f501ba7a117c90d5b65d70b1f3f4c5c3fe6e29e1 Mon Sep 17 00:00:00 2001 From: Neup123 <156241401+Neup123@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:49:59 +0200 Subject: [PATCH 11/13] test(connection): mark simulated healthy sockets as registered --- test/socket-watchdog.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/socket-watchdog.test.ts b/test/socket-watchdog.test.ts index 43339b5f2..fa389905c 100644 --- a/test/socket-watchdog.test.ts +++ b/test/socket-watchdog.test.ts @@ -13,6 +13,7 @@ function harness() { }; const service: any = Object.create(BaileysStartupService.prototype); service.instance = { id: 'instance-1', name: 'watchdog-test', qrcode: { count: 0 } }; + service.instance.authState = { state: { creds: { registered: true } } }; service.client = socket; service.stateConnection = { state: 'open' }; service.localChatwoot = { enabled: false }; From e8882eb4b915d2598a63e14cd9e25d10e3c19026 Mon Sep 17 00:00:00 2001 From: Neup123 <156241401+Neup123@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:40:32 +0200 Subject: [PATCH 12/13] test(whatsapp): assert recovery state at retry boundary despite full jitter --- test/connection-recovery.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test/connection-recovery.test.ts b/test/connection-recovery.test.ts index 2cba63741..542b894e7 100644 --- a/test/connection-recovery.test.ts +++ b/test/connection-recovery.test.ts @@ -74,20 +74,23 @@ function transientClose() { async function failedReconnectThenOpen() { const { service, socket, stored, logs } = harness(); let attempts = 0; + let statusAtRecoveryAttempt: string | undefined; service.connectToWhatsapp = async () => { service.activeSocketBuildRevision = ++service.socketBuildRevision; attempts++; if (attempts === 1) throw new Error('Simulated reconnect failure'); + // Snapshot the state *before* the retry opens. Full jitter can make the + // second attempt complete before a polling assertion after attempt one. + statusAtRecoveryAttempt = stored.connectionStatus; queueMicrotask(() => { void service.connectionUpdate({ connection: 'open' }, socket); }); return socket; }; await service.connectionUpdate(transientClose(), socket); assert.equal(stored.connectionStatus, 'close', 'fetchInstances must not report open after transient close'); - await until(() => attempts >= 1, 'First reconnect attempt did not start'); - assert.equal(stored.connectionStatus, 'close', 'Failed first attempt must leave a non-open stored state'); await until(() => stored.connectionStatus === 'open', 'A subsequent attempt did not recover'); assert.ok(attempts >= 2); + assert.equal(statusAtRecoveryAttempt, 'close', 'Failed first attempt must leave a non-open stored state'); assert.ok(logs.some((line) => line.includes('outcome=threw'))); assert.ok(logs.some((line) => line.includes('outcome=opened'))); } @@ -95,16 +98,20 @@ async function failedReconnectThenOpen() { async function hangingReconnectThenOpen() { const { service, socket, stored, logs } = harness(); let attempts = 0; + let statusAtRecoveryAttempt: string | undefined; service.connectToWhatsapp = () => { service.activeSocketBuildRevision = ++service.socketBuildRevision; attempts++; if (attempts === 1) return new Promise(() => {}); + // Check the state after the timed-out attempt, independent of retry jitter. + statusAtRecoveryAttempt = stored.connectionStatus; queueMicrotask(() => { void service.connectionUpdate({ connection: 'open' }, socket); }); return Promise.resolve(socket); }; await service.connectionUpdate(transientClose(), socket); await until(() => stored.connectionStatus === 'open', 'A hung reconnect did not time out and recover'); assert.ok(attempts >= 2); + assert.equal(statusAtRecoveryAttempt, 'close', 'Timed-out first attempt must leave a non-open stored state'); assert.ok(logs.some((line) => line.includes('outcome=timed_out'))); } From e6cd80b656f80b24ca5ab401b8927cffb6f80254 Mon Sep 17 00:00:00 2001 From: Neup123 <156241401+Neup123@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:00:25 +0200 Subject: [PATCH 13/13] test(connection): initialize 515 grace window in prototype harness --- test/connection-recovery.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/connection-recovery.test.ts b/test/connection-recovery.test.ts index 542b894e7..5b9bda424 100644 --- a/test/connection-recovery.test.ts +++ b/test/connection-recovery.test.ts @@ -38,6 +38,7 @@ function harness() { service.socketBuildRevision = 0; service.statusWriteQueue = Promise.resolve(); service.reconnectBackoffBaseMs = 5; + service.stream515GraceMs = 30_000; service.reconnectAttemptTimeoutMs = 40; service.reconnectAttempts = 0; service.reconnectInFlight = false;