From c6958fc5bb9a553276fa837ef003f9d5aa313411 Mon Sep 17 00:00:00 2001 From: Adriano Rodrigues Date: Thu, 15 Oct 2026 19:45:31 -0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(baileys):=20impede=20sockets=20duplicad?= =?UTF-8?q?as=20da=20mesma=20inst=C3=A2ncia=20na=20reconex=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O createClient criava uma socket nova sem encerrar a anterior, e o connectionUpdate reconectava em qualquer 'close' (inclusive o 440, conflict/replaced) sem conferir de qual socket o evento vinha. Bastava surgir uma segunda socket com a mesma sessão (ex.: o fallback do /instance/restart reconectando junto com a reconexão automática) para as duas se derrubarem pelo WhatsApp até o restart da task. Em prod isso gerava de 4 a 12 mil erros conflict/replaced por hora. - createClient desliga os eventos e encerra a socket anterior antes de criar a nova (unsubscribe antes do end, para o 'close' dela não reconectar de novo). - Eventos de uma socket que já não é a atual são descartados. - connectToWhatsapp reaproveita uma conexão em andamento em vez de abrir outra socket em paralelo. - No 440, loga em nível de erro com o nome da instância e reconecta depois de 10s, só se ninguém tiver reaberto a conexão nesse intervalo. --- .../whatsapp/whatsapp.baileys.service.ts | 68 ++++++++++++++++++- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 0f71639608..58d33a29b4 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -257,6 +257,12 @@ export class BaileysStartupService extends ChannelStartupService { private eventProcessingQueue: Promise = Promise.resolve(); private presenceInterval: NodeJS.Timeout | null = null; + // Uma instância só pode ter uma socket viva. Duas sockets com a mesma sessão se derrubam pelo WhatsApp + // (stream:error conflict/replaced, 440) e, como o 'close' de cada uma reconectava, o loop só parava no restart. + private unsubscribeClientEvents: (() => void) | null = null; + private connectInFlight: Promise | null = null; + private static readonly CONNECTION_REPLACED_RECONNECT_DELAY_MS = 10_000; + // Erro 463 (LID addressing): cache negativo de PNs sem mapeamento LID, para não // repetir USync a cada envio — getLIDForPN consulta a rede quando não há mapping local. private readonly lidUnmappedPns = new Map(); @@ -440,7 +446,22 @@ export class BaileysStartupService extends ChannelStartupService { const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode; const codesToNotReconnect = [DisconnectReason.loggedOut, DisconnectReason.forbidden, 402, 406]; const shouldReconnect = !codesToNotReconnect.includes(statusCode); - if (shouldReconnect) { + if (shouldReconnect && statusCode === DisconnectReason.connectionReplaced) { + // 440: outra socket abriu com a mesma sessão. Reconectar na hora derrubaria a outra, que reconectaria + // e derrubaria esta de volta. Espera um pouco e só reconecta se ninguém reabriu a conexão nesse meio-tempo. + const delayMs = BaileysStartupService.CONNECTION_REPLACED_RECONNECT_DELAY_MS; + this.logger.error( + `[${this.instance.name}] Conexão substituída por outra sessão (440 conflict/replaced); reconectando em ${delayMs / 1000}s`, + ); + setTimeout(() => { + if (this.stateConnection.state !== 'close' || this.endSession) { + return; + } + this.connectToWhatsapp(this.phoneNumber).catch((error) => + this.logger.error(`[${this.instance.name}] Falha ao reconectar após 440: ${error?.message}`), + ); + }, delayMs); + } else if (shouldReconnect) { await this.connectToWhatsapp(this.phoneNumber); } else { this.sendDataWebhook(Events.STATUS_INSTANCE, { @@ -713,6 +734,7 @@ export class BaileysStartupService extends ChannelStartupService { this.endSession = false; + this.endPreviousClient(); this.client = makeWASocket(socketConfig); if (this.localSettings.wavoipToken && this.localSettings.wavoipToken.length > 0) { @@ -738,6 +760,25 @@ export class BaileysStartupService extends ChannelStartupService { return this.client; } + // Desliga os eventos e encerra a socket anterior antes de criar outra. O unsubscribe vem antes do end() + // de propósito: o 'close' que o end() emite não pode disparar mais uma reconexão. + private endPreviousClient() { + const previous = this.client; + + this.unsubscribeClientEvents?.(); + this.unsubscribeClientEvents = null; + + if (!previous) { + return; + } + + try { + previous.end(undefined); + } catch (error) { + this.logger.warn(`[${this.instance.name}] Erro ao encerrar a socket anterior: ${error?.message}`); + } + } + private startPresenceInterval() { // Limpa interval anterior se existir this.stopPresenceInterval(); @@ -775,6 +816,20 @@ export class BaileysStartupService extends ChannelStartupService { } public async connectToWhatsapp(number?: string): Promise { + // A reconexão automática do 'close' e uma chamada da API (/instance/restart, /instance/connect) podem chegar + // juntas; a segunda reaproveita a conexão em andamento em vez de abrir outra socket. + if (this.connectInFlight) { + return this.connectInFlight; + } + + this.connectInFlight = this.openConnection(number).finally(() => { + this.connectInFlight = null; + }); + + return this.connectInFlight; + } + + private async openConnection(number?: string): Promise { try { this.loadChatwoot(); this.loadSettings(); @@ -1927,10 +1982,17 @@ export class BaileysStartupService extends ChannelStartupService { }; private eventHandler() { - this.client.ev.process(async (events) => { + const socket = this.client; + + this.unsubscribeClientEvents = socket.ev.process(async (events) => { + // Eventos de uma socket já substituída são descartados: um 'close' atrasado dela reabriria a conexão. + if (socket !== this.client) { + return; + } + this.eventProcessingQueue = this.eventProcessingQueue.then(async () => { try { - if (!this.endSession) { + if (!this.endSession && socket === this.client) { const database = this.configService.get('DATABASE'); const settings = await this.findSettings(); From 2a1507a626c3a3d78f4bc1b82b7a1bd6e80079f2 Mon Sep 17 00:00:00 2001 From: Adriano Rodrigues Date: Mon, 19 Oct 2026 12:10:47 -0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(baileys):=20status=20real=20no=20banco?= =?UTF-8?q?=20e=20reconex=C3=A3o=20com=20espera?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Queda com reconexão grava connectionStatus=close (com motivo) e emite CONNECTION_UPDATE antes de reconectar. Antes o banco seguia 'open' e quem lê o banco (fetchInstances) via a instância viva enquanto os envios falhavam. - Reconexão que falha agenda nova tentativa com espera crescente (5s até 5min) em vez de largar a instância fechada. - 440 (conflict/replaced) reconecta com espera crescente (10s, 20s, 40s... até 5min), zerada depois de 2min de conexão estável; o log traz a instância e quantos 440 seguidos. - reloadConnection (privacidade/foto de perfil) usa a mesma trava do connectToWhatsapp. - logout cancela reconexões agendadas. - Limite de QR (no.connection) grava close no banco; antes a instância ficava close em memória e connecting/open no banco. Simulado local com sessão real (flags de prod, sessão no Redis): o gatilho do painel (queda + 2 connect em 1s) gera 112 replaced/min e loop permanente no código atual; 0 com a correção. Dois processos com a mesma sessão: 51 replaced/min no atual, 4 com a correção. --- .../whatsapp/whatsapp.baileys.service.ts | 137 +++++++++++++++--- src/api/services/monitor.service.ts | 10 ++ 2 files changed, 129 insertions(+), 18 deletions(-) diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 58d33a29b4..3cf4e8db11 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -261,7 +261,19 @@ export class BaileysStartupService extends ChannelStartupService { // (stream:error conflict/replaced, 440) e, como o 'close' de cada uma reconectava, o loop só parava no restart. private unsubscribeClientEvents: (() => void) | null = null; private connectInFlight: Promise | null = null; - private static readonly CONNECTION_REPLACED_RECONNECT_DELAY_MS = 10_000; + + // Reconexão com espera: um timer por instância, e a espera cresce a cada queda seguida do mesmo tipo. + // As sequências zeram quando a conexão fica aberta por STABLE_CONNECTION_MS. + private reconnectTimer: NodeJS.Timeout | null = null; + private stableConnectionTimer: NodeJS.Timeout | null = null; + private replacedStreak = 0; + private reconnectFailureStreak = 0; + // logout/exclusão: nenhuma reconexão agendada pode reabrir a instância depois disso. + private disposed = false; + private static readonly REPLACED_BACKOFF_BASE_MS = 10_000; + private static readonly RECONNECT_FAILURE_BACKOFF_BASE_MS = 5_000; + private static readonly RECONNECT_BACKOFF_MAX_MS = 5 * 60_000; + private static readonly STABLE_CONNECTION_MS = 2 * 60_000; // Erro 463 (LID addressing): cache negativo de PNs sem mapeamento LID, para não // repetir USync a cada envio — getLIDForPN consulta a rede quando não há mapping local. @@ -282,6 +294,9 @@ export class BaileysStartupService extends ChannelStartupService { } public async logoutInstance() { + this.disposed = true; + this.clearReconnectTimer(); + this.clearStableConnectionTimer(); this.stopPresenceInterval(); this.messageProcessor.onDestroy(); await this.client?.logout('Log out instance: ' + this.instanceName); @@ -446,23 +461,24 @@ export class BaileysStartupService extends ChannelStartupService { const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode; const codesToNotReconnect = [DisconnectReason.loggedOut, DisconnectReason.forbidden, 402, 406]; const shouldReconnect = !codesToNotReconnect.includes(statusCode); - if (shouldReconnect && statusCode === DisconnectReason.connectionReplaced) { - // 440: outra socket abriu com a mesma sessão. Reconectar na hora derrubaria a outra, que reconectaria - // e derrubaria esta de volta. Espera um pouco e só reconecta se ninguém reabriu a conexão nesse meio-tempo. - const delayMs = BaileysStartupService.CONNECTION_REPLACED_RECONNECT_DELAY_MS; - this.logger.error( - `[${this.instance.name}] Conexão substituída por outra sessão (440 conflict/replaced); reconectando em ${delayMs / 1000}s`, - ); - setTimeout(() => { - if (this.stateConnection.state !== 'close' || this.endSession) { - return; - } - this.connectToWhatsapp(this.phoneNumber).catch((error) => - this.logger.error(`[${this.instance.name}] Falha ao reconectar após 440: ${error?.message}`), + if (shouldReconnect) { + this.clearStableConnectionTimer(); + // O banco passa a refletir a queda antes de reconectar. Sem isso, uma reconexão que falhava ou demorava + // deixava a instância 'close' em memória e 'open' no banco: o painel mostrava conectado e os envios falhavam. + await this.persistConnectionDrop(statusCode, lastDisconnect); + + if (statusCode === DisconnectReason.connectionReplaced) { + // 440: outra socket abriu com a mesma sessão. Reconectar na hora derrubaria a outra, que reconectaria + // e derrubaria esta de volta. A espera dobra a cada 440 seguido (10s, 20s, 40s... até 5min), então uma + // sessão aberta em outro servidor deixa de brigar a cada segundo e o log mostra qual instância é. + const delayMs = this.nextBackoffMs('replaced'); + this.logger.error( + `[${this.instance.name}] Conexão substituída por outra sessão (440 conflict/replaced, ${this.replacedStreak}ª seguida); reconectando em ${delayMs / 1000}s`, ); - }, delayMs); - } else if (shouldReconnect) { - await this.connectToWhatsapp(this.phoneNumber); + this.scheduleReconnect(delayMs); + } else { + await this.reconnectNow(); + } } else { this.sendDataWebhook(Events.STATUS_INSTANCE, { instance: this.instance.name, @@ -499,6 +515,7 @@ export class BaileysStartupService extends ChannelStartupService { } if (connection === 'open') { + this.markConnectionOpened(); this.instance.wuid = this.client.user.id.replace(/:\d+/, ''); try { const profilePic = await this.profilePicture(this.instance.wuid); @@ -760,6 +777,82 @@ export class BaileysStartupService extends ChannelStartupService { return this.client; } + private async persistConnectionDrop(statusCode: number, lastDisconnect: any) { + try { + await this.prismaRepository.instance.update({ + where: { id: this.instanceId }, + data: { + connectionStatus: 'close', + disconnectionAt: new Date(), + disconnectionReasonCode: statusCode, + disconnectionObject: JSON.stringify(lastDisconnect), + }, + }); + } catch (error) { + this.logger.warn(`[${this.instance.name}] Não foi possível gravar a queda da conexão: ${error?.message}`); + } + this.sendDataWebhook(Events.CONNECTION_UPDATE, { instance: this.instance.name, ...this.stateConnection }); + } + + private nextBackoffMs(kind: 'replaced' | 'failure') { + const streak = kind === 'replaced' ? ++this.replacedStreak : ++this.reconnectFailureStreak; + const base = + kind === 'replaced' + ? BaileysStartupService.REPLACED_BACKOFF_BASE_MS + : BaileysStartupService.RECONNECT_FAILURE_BACKOFF_BASE_MS; + return Math.min(base * 2 ** (streak - 1), BaileysStartupService.RECONNECT_BACKOFF_MAX_MS); + } + + // Reconecta já; se a tentativa falhar, agenda outra com espera em vez de largar a instância fechada para sempre. + private async reconnectNow() { + try { + await this.connectToWhatsapp(this.phoneNumber); + } catch (error) { + const delayMs = this.nextBackoffMs('failure'); + this.logger.error( + `[${this.instance.name}] Falha ao reconectar (${this.reconnectFailureStreak}ª seguida): ${error?.message}; nova tentativa em ${delayMs / 1000}s`, + ); + this.scheduleReconnect(delayMs); + } + } + + private scheduleReconnect(delayMs: number) { + this.clearReconnectTimer(); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + // Alguém já reabriu (API, outra reconexão) ou a instância foi encerrada: não abre outra socket. + if (this.disposed || this.endSession || this.stateConnection.state !== 'close') { + return; + } + this.reconnectNow(); + }, delayMs); + } + + private clearReconnectTimer() { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } + + private clearStableConnectionTimer() { + if (this.stableConnectionTimer) { + clearTimeout(this.stableConnectionTimer); + this.stableConnectionTimer = null; + } + } + + // Conexão aberta: cancela reconexão pendente e, se ela ficar de pé por um tempo, zera as sequências de espera. + private markConnectionOpened() { + this.clearReconnectTimer(); + this.reconnectFailureStreak = 0; + this.clearStableConnectionTimer(); + this.stableConnectionTimer = setTimeout(() => { + this.stableConnectionTimer = null; + this.replacedStreak = 0; + }, BaileysStartupService.STABLE_CONNECTION_MS); + } + // Desliga os eventos e encerra a socket anterior antes de criar outra. O unsubscribe vem antes do end() // de propósito: o 'close' que o end() emite não pode disparar mais uma reconexão. private endPreviousClient() { @@ -850,7 +943,15 @@ export class BaileysStartupService extends ChannelStartupService { public async reloadConnection(): Promise { try { - return await this.createClient(this.phoneNumber); + // Mesma trava do connectToWhatsapp: privacidade/foto de perfil chamando junto de uma reconexão não abre + // uma segunda socket. + if (this.connectInFlight) { + return await this.connectInFlight; + } + this.connectInFlight = this.createClient(this.phoneNumber).finally(() => { + this.connectInFlight = null; + }); + return await this.connectInFlight; } catch (error) { this.logger.error(error); throw new InternalServerErrorException(error?.toString()); diff --git a/src/api/services/monitor.service.ts b/src/api/services/monitor.service.ts index 01cfa8736c..97bfb0184b 100644 --- a/src/api/services/monitor.service.ts +++ b/src/api/services/monitor.service.ts @@ -483,6 +483,16 @@ export class WAMonitoringService { }); } finally { this.logger.warn(`Instance "${instanceName}" - NOT CONNECTION`); + // Limite de QR atingido: a instância fica fechada em memória, e o banco precisa dizer o mesmo. Antes ele + // ficava com o último status gravado ('connecting'/'open'), e quem lê o banco via a instância como viva. + await this.prismaRepository.instance + .updateMany({ + where: { name: instanceName }, + data: { connectionStatus: 'close', disconnectionAt: new Date() }, + }) + .catch((error) => + this.logger.error(`Instance "${instanceName}" - falha ao gravar close no banco: ${error?.message}`), + ); } }); }