Fix unbounded inbound-message dedup set and un-unref'd timeout timer

inboundMessageIds was only ever cleared whole (on disconnect), so it grew
without bound proportional to total inbound message volume over a
connection's lifetime. Now capped at 5000 entries, evicting the oldest
(Set preserves insertion order) once exceeded.

withTimeout's internal setTimeout wasn't unref'd, unlike every other timer
in this file (qrTimer, reconnectTimer, the lease timer) — a pending
lookup/send timeout could keep the event loop alive if the process ever
tried to exit gracefully without an explicit process.exit().

Found via a full-project audit.
This commit is contained in:
Gustavo
2026-08-01 01:23:31 -03:00
parent 3e55b843e2
commit 8ce2fcdfea
2 changed files with 45 additions and 1 deletions
+13 -1
View File
@@ -46,6 +46,7 @@ function withTimeout(promise, timeoutMs, createError) {
const provider = Promise.resolve(promise);
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(createError()), timeoutMs);
timer.unref?.();
});
provider.catch(() => {});
return Promise.race([provider, timeout]).finally(() => clearTimeout(timer));
@@ -101,6 +102,17 @@ export function createWhatsAppManager({
const pendingMessageStatuses = new Map();
const messageStatusInFlight = new Map();
const inboundMessageIds = new Set();
// Set nunca esvaziado individualmente (so no disconnect inteiro): sem um
// teto, cresce sem limite proporcional ao volume total de mensagens
// recebidas ao longo da vida da conexao. Descarta o id mais antigo (Set
// preserva ordem de insercao) ao ultrapassar o teto.
const MAX_INBOUND_DEDUP_IDS = 5000;
const rememberInboundMessageId = (messageId) => {
inboundMessageIds.add(messageId);
if (inboundMessageIds.size > MAX_INBOUND_DEDUP_IDS) {
inboundMessageIds.delete(inboundMessageIds.values().next().value);
}
};
const lockPath = path.join(path.dirname(authDir), `${path.basename(authDir)}.lock`);
const recoveryPath = `${lockPath}.recovery`;
@@ -359,7 +371,7 @@ export function createWhatsAppManager({
const timestampMs = Number.isFinite(rawTimestamp)
? rawTimestamp * (rawTimestamp < 1e12 ? 1_000 : 1)
: Date.now();
inboundMessageIds.add(String(messageId));
rememberInboundMessageId(String(messageId));
Promise.resolve().then(() => onInboundMessage({
messageId: String(messageId),
jid,
+32
View File
@@ -349,6 +349,38 @@ test('messages.upsert publica uma vez somente mensagens inbound novas de convers
await fs.rm(f.authDir, { recursive: true, force: true });
});
// O Set de dedup so era esvaziado inteiro no disconnect: sem um teto,
// crescia sem limite proporcional ao volume total de mensagens recebidas
// durante a vida da conexao. Envia 1 mensagem a mais que o teto interno
// (5000) e confirma que a mais antiga foi descartada (tratada como nova de
// novo), provando que o Set nao cresce indefinidamente.
test('dedup de mensagens inbound tem teto e descarta a mais antiga', async () => {
const inbound = [];
const f = await fixture({ onInboundMessage: (event) => inbound.push(event) });
await f.manager.connect();
const socket = f.sockets[0];
socket.ev.emit('connection.update', { connection: 'open' });
const TOTAL = 5001;
for (let i = 0; i < TOTAL; i++) {
socket.ev.emit('messages.upsert', {
type: 'notify',
messages: [{ key: { id: `msg-${i}`, remoteJid: '5575999091780@s.whatsapp.net', fromMe: false }, messageTimestamp: 1785326400 }]
});
}
await waitFor(() => inbound.length === TOTAL, 5000);
socket.ev.emit('messages.upsert', {
type: 'notify',
messages: [{ key: { id: 'msg-0', remoteJid: '5575999091780@s.whatsapp.net', fromMe: false }, messageTimestamp: 1785326400 }]
});
await waitFor(() => inbound.length === TOTAL + 1, 2000);
assert.equal(inbound.filter((e) => e.messageId === 'msg-0').length, 2);
await f.manager.close();
await fs.rm(f.authDir, { recursive: true, force: true });
});
test('falha ao persistir inbound nao escapa do EventEmitter nem gera resposta automatica', async () => {
const logs = [];
const f = await fixture({