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
This commit is contained in:
parent
648c01c693
commit
947fdeab3b
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue