From 947fdeab3bdef53bb3ff856a4bce0e6e6235122a Mon Sep 17 00:00:00 2001 From: Anthony Tran Date: Sun, 2 Aug 2026 23:03:59 -0400 Subject: [PATCH] fix(whatsapp): guard bridge reconnect against hangs and unhandled rejections startSocket() awaits useMultiFileAuthState() and fetchLatestBaileysVersion() before it creates a socket or registers event handlers, and the close handler re-entered it via a bare setTimeout(startSocket, ...). That leaves two unrecoverable failure modes on a reconnect: - a rejection is an unhandled promise rejection (fatal on modern Node) - a hang leaves the bridge permanently disconnected with nothing left to retry, while its HTTP server keeps answering 503 to the gateway The second mode was observed in the field: fetchLatestBaileysVersion() is a plain fetch to raw.githubusercontent.com with no AbortSignal, and after a stream:error 503 disconnect the bridge logged 'Reconnecting in 3s...' once and then sat silent and disconnected for 27+ hours until manually restarted. Fix, as two pure helpers in bridge_helpers.js (keeping bridge.js side-effect free to test): - createReconnectScheduler(): every (re)connect entry point now catches a failed startSocket() and reschedules it instead of dying or going silent - createVersionResolver(): bounds the version fetch with a 15s timeout and falls back to the last known-good version (or the Baileys default before first success) instead of pending forever --- scripts/whatsapp-bridge/bridge.js | 13 +- .../whatsapp-bridge/bridge.reconnect.test.mjs | 150 ++++++++++++++++++ scripts/whatsapp-bridge/bridge_helpers.js | 57 +++++++ 3 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 scripts/whatsapp-bridge/bridge.reconnect.test.mjs diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index fbacb6b43f8a9..234cbef279314 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -35,6 +35,8 @@ import { createOutboundIdTracker } from './outbound_ids.js'; import { classifyOwnerMessageGate } from './owner_message_gate.js'; import { buildPollPayload, + createReconnectScheduler, + createVersionResolver, buildLocationPayload, buildTextSendPayload, createBoundedMessageStore, @@ -393,12 +395,15 @@ function emitPairEvent(event) { } catch {} } +const scheduleReconnect = createReconnectScheduler(() => startSocket()); +const getWAVersion = createVersionResolver(fetchLatestBaileysVersion); + async function startSocket() { const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR); - const { version } = await fetchLatestBaileysVersion(); + const version = await getWAVersion(); sock = makeWASocket({ - version, + ...(version ? { version } : {}), auth: state, logger, printQRInTerminal: false, @@ -449,7 +454,7 @@ async function startSocket() { console.log(`⚠️ Connection closed (reason: ${reason}). Reconnecting in 3s...`); } } - setTimeout(startSocket, reason === 515 ? 1000 : 3000); + scheduleReconnect(reason === 515 ? 1000 : 3000); } } else if (connection === 'open') { connectionState = 'connected'; @@ -1145,6 +1150,6 @@ if (PAIR_ONLY) { console.log(`👤 WHATSAPP_FORWARD_OWNER_MESSAGES=true — owner-typed messages will be forwarded with fromOwner:true`); } console.log(); - startSocket(); + scheduleReconnect(0); }); } diff --git a/scripts/whatsapp-bridge/bridge.reconnect.test.mjs b/scripts/whatsapp-bridge/bridge.reconnect.test.mjs new file mode 100644 index 0000000000000..70f53751cf95c --- /dev/null +++ b/scripts/whatsapp-bridge/bridge.reconnect.test.mjs @@ -0,0 +1,150 @@ +/** + * Unit tests for the reconnect scheduling and version resolution guards. + * + * Regression tests for the reconnect-wedge trap: startSocket() awaits + * network I/O (fetchLatestBaileysVersion has no AbortSignal) before it + * creates a socket, and the close handler used to re-enter it via a bare + * `setTimeout(startSocket, ...)`. A rejection was unhandled and a stalled + * fetch left the bridge permanently disconnected while its HTTP server + * kept answering 503 — observed in the field as a bridge that logged + * "Reconnecting in 3s..." once and then went silent for 27+ hours. + * + * These tests avoid importing bridge.js because that file starts an HTTP + * server and Baileys socket at module load. Keep the helper module pure. + */ + +import { strict as assert } from 'node:assert'; + +import { + createReconnectScheduler, + createVersionResolver, +} from './bridge_helpers.js'; + +const tick = () => new Promise(resolve => setImmediate(resolve)); +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + +// -- createReconnectScheduler --------------------------------------------- + +// A rejecting start function is caught and rescheduled at the retry delay; +// a subsequent success stops the retry chain. +{ + const timers = []; + const logs = []; + let attempts = 0; + const startFn = async () => { + attempts += 1; + if (attempts === 1) throw new Error('boom'); + }; + + const schedule = createReconnectScheduler(startFn, { + retryDelayMs: 5000, + log: line => logs.push(line), + setTimeoutFn: (fn, ms) => timers.push({ fn, ms }), + }); + + schedule(3000); + assert.equal(timers.length, 1); + assert.equal(timers[0].ms, 3000); + + timers[0].fn(); + await tick(); + await tick(); + + assert.equal(attempts, 1); + assert.equal(logs.length, 1); + assert.match(logs[0], /Reconnect failed \(boom\)/); + assert.equal(timers.length, 2, 'rejection must schedule a retry'); + assert.equal(timers[1].ms, 5000); + + timers[1].fn(); + await tick(); + await tick(); + + assert.equal(attempts, 2); + assert.equal(timers.length, 2, 'success must not schedule another attempt'); + assert.equal(logs.length, 1); +} + +// A synchronous throw from the start function is contained the same way as +// an async rejection. +{ + const timers = []; + const logs = []; + const schedule = createReconnectScheduler( + () => { throw new Error('sync boom'); }, + { + retryDelayMs: 1000, + log: line => logs.push(line), + setTimeoutFn: (fn, ms) => timers.push({ fn, ms }), + }, + ); + + schedule(0); + timers[0].fn(); + await tick(); + await tick(); + + assert.equal(logs.length, 1); + assert.match(logs[0], /sync boom/); + assert.equal(timers.length, 2); +} + +// -- createVersionResolver ------------------------------------------------ + +// A successful fetch returns and caches the version. +{ + const resolveVersion = createVersionResolver( + async () => ({ version: [2, 3000, 99] }), + { log: () => {} }, + ); + assert.deepEqual(await resolveVersion(), [2, 3000, 99]); +} + +// A fetch that never settles resolves within the timeout bound instead of +// pending forever; before any success there is no cache, so the resolver +// yields null (callers fall back to the Baileys default). +{ + const logs = []; + const resolveVersion = createVersionResolver( + () => new Promise(() => {}), + { timeoutMs: 20, log: line => logs.push(line) }, + ); + assert.equal(await resolveVersion(), null); + assert.equal(logs.length, 1); + assert.match(logs[0], /version fetch timed out/); + assert.match(logs[0], /library default/); +} + +// After one success, later failures fall back to the cached version. +{ + const logs = []; + let calls = 0; + const resolveVersion = createVersionResolver( + async () => { + calls += 1; + if (calls === 1) return { version: [2, 3000, 42] }; + throw new Error('network down'); + }, + { timeoutMs: 20, log: line => logs.push(line) }, + ); + assert.deepEqual(await resolveVersion(), [2, 3000, 42]); + assert.deepEqual(await resolveVersion(), [2, 3000, 42]); + assert.equal(logs.length, 1); + assert.match(logs[0], /network down/); + assert.match(logs[0], /cached version/); +} + +// The losing timeout timer is cleared after a fast success, so the resolver +// does not hold the event loop open for the full timeout window. +{ + const resolveVersion = createVersionResolver( + async () => ({ version: [2, 3000, 1] }), + { timeoutMs: 60_000, log: () => {} }, + ); + const before = Date.now(); + await resolveVersion(); + await sleep(10); + assert.ok(Date.now() - before < 1000); +} + +console.log('bridge.reconnect.test.mjs: all assertions passed'); diff --git a/scripts/whatsapp-bridge/bridge_helpers.js b/scripts/whatsapp-bridge/bridge_helpers.js index c0618cd2bd6e8..398521feee7cc 100644 --- a/scripts/whatsapp-bridge/bridge_helpers.js +++ b/scripts/whatsapp-bridge/bridge_helpers.js @@ -567,3 +567,60 @@ export function pollCreationMessageFromPayload(payload) { }; return message; } + +/** + * Reconnect scheduling guard. startSocket() awaits network I/O before it + * creates a socket or registers event handlers, so a bare + * `setTimeout(startSocket, ...)` has two unrecoverable failure modes: a + * rejection is unhandled (crashes the process on modern Node), and a hang + * leaves the bridge permanently disconnected with nothing left to retry. + * Every (re)connect must go through the scheduler this returns. + */ +export function createReconnectScheduler(startFn, { + retryDelayMs = 5000, + log = console.log, + setTimeoutFn = setTimeout, +} = {}) { + function scheduleReconnect(delayMs) { + setTimeoutFn(() => { + Promise.resolve() + .then(startFn) + .catch((err) => { + log(`⚠️ Reconnect failed (${err?.message || err}). Retrying in ${Math.round(retryDelayMs / 1000)}s...`); + scheduleReconnect(retryDelayMs); + }); + }, delayMs); + } + return scheduleReconnect; +} + +/** + * Version resolution guard. fetchLatestBaileysVersion() is a plain fetch to + * raw.githubusercontent.com with no AbortSignal; a stalled connection can + * pend forever and wedge the reconnect path (the scheduler above cannot + * retry past an await that never settles). Bound the fetch and fall back to + * the last known-good version, or the Baileys default before first success. + */ +export function createVersionResolver(fetchVersionFn, { + timeoutMs = 15000, + log = console.log, +} = {}) { + let cachedVersion = null; + return async function resolveVersion() { + let timer = null; + try { + const { version } = await Promise.race([ + fetchVersionFn(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('version fetch timed out')), timeoutMs); + }), + ]); + cachedVersion = version; + } catch (err) { + log(`⚠️ Baileys version fetch failed (${err?.message || err}); using ${cachedVersion ? 'cached version' : 'library default'}.`); + } finally { + if (timer) clearTimeout(timer); + } + return cachedVersion; + }; +}