Notas de desenvolvimento · Confiabilidade

A sessão do WhatsApp que se desconectou 377 vezes

· server.log, 1–2 de julho de 2026 · Fundador, Klaros

Uma sessão do WhatsApp Web no nosso produto desktop passou uma noite emitindo códigos QR. Trezentos e setenta e sete deles. Não havia ninguém ali para escanear nenhum, e cada ciclo tornava o seguinte mais provável.

A leitura óbvia, e a que qualquer artigo sobre bibliotecas não oficiais do WhatsApp vai lhe entregar, é que a Meta detectou automação e puniu. Não foi isso que aconteceu. O dispositivo foi desconectado porque onze processos Chromium nossos estavam vivos ao mesmo tempo, todos segurando o mesmo diretório de autenticação, todos educadamente tomando a sessão uns dos outros. A Meta fez a única coisa sensata disponível a ela.

Resumo

O SIGKILL é assíncrono. Sinalizar um processo de navegador e imediatamente subir o substituto roda os dois ao mesmo tempo com frequência, porque o sistema operacional ainda não coletou o primeiro nem liberou seus bloqueios de arquivo.

Navegadores vazados dividem um diretório de autenticação. O whatsapp-web.js enxerga uma sessão em conflito e invoca takeoverOnConflict. Com vários clientes seus vivos, eles tomam a sessão uns dos outros em loop.

A Meta responde desconectando o dispositivo. A autenticação some, e só uma pessoa escaneando um QR code restaura, então um loop de reconexão sem supervisão gera códigos para sempre e nunca se recupera.

Três correções: um encerramento que consulta os PIDs até confirmarem que sumiram, um mutex por sessão para que a limpeza sempre termine antes do próximo start, e uma política que estaciona a sessão em vez de gerar códigos QR para ninguém.

A cascata

Relendo o log, a falha tem uma ordem rígida. Cada passo é razoável isoladamente e a sequência é fatal.

1Uma sessão é derrubada e outra é iniciada. O Chromium antigo recebe o sinal mas não tem a morte confirmada, então por algumas centenas de milissegundos existem dois navegadores.
2Sob reconexões repetidas isso se acumula. No pior momento encontramos onze processos Chromium esquecidos vivos ao mesmo tempo, todos no meio da inicialização, todos vazados pelo mesmo padrão.
3Cada um deles segura o mesmo diretório LocalAuth, porque todos são o mesmo identificador de sessão.
4O whatsapp-web.js está configurado para tomar uma sessão que parece estar presa em outro lugar. Cada cliente invoca obedientemente takeoverOnConflict contra o que são, na verdade, os nossos próprios zumbis.
5Do lado da Meta, isso é uma credencial de dispositivo sendo reivindicada repetidamente por algo que não consegue segurá-la. Ela desconecta o dispositivo à força.
6A limpeza tenta apagar o diretório de autenticação. Falha com EBUSY, porque um navegador sinalizado mas ainda vivo o mantém aberto. Um diretório apagado pela metade é pior que qualquer um dos dois resultados.
7O loop de reconexão, sem encontrar autenticação, gera um QR code. Ninguém escaneia. Ele gera outro. 377 vezes.

O ponto que vale parar para pensar é o passo 5. Passamos um tempo procurando o que tínhamos feito para acionar a detecção de automação da Meta, e a resposta era nada. A plataforma se comportou corretamente. A desconexão foi inteiramente autoinfligida, e nenhum padrão de envio mais suave ou atraso aleatório teria evitado.

Por que o encerramento não encerrava

A causa raiz é uma linha de suposição. Chamar process.kill(pid, 'SIGKILL') retorna imediatamente, e é tentador ler esse retorno como "o processo acabou". Não é. Significa que o sinal foi entregue. O sistema operacional pode levar centenas de milissegundos para coletar o processo, e até lá ele ainda segura seus descritores de arquivo, seu SingletonLock e sua parte do diretório de autenticação.

Então a correção não é um sinal melhor, é uma confirmação. Sinalize todos os PIDs candidatos e depois consulte até todos terem realmente sumido.

src/local-modules/wa-session-supervisor.mjs

const deadline = Date.now() + Math.max(pollIntervalMs, timeoutMs);
let survivors = targets;
while (Date.now() < deadline) {
  survivors = survivors.filter((pid) => checkAlive(pid));
  if (!survivors.length) return { confirmed: true, survivors: [] };
  await sleep(pollIntervalMs);
}

Um detalhe em checkAlive é fácil de inverter. Sondar um processo com o sinal 0 lança erro quando o processo sumiu, mas também lança EPERM quando o processo existe e você não tem permissão para sinalizá-lo. Tratar todo erro como "morto" reintroduz exatamente o vazamento, então EPERM precisa ser lido como vivo:

try {
  processKill(pid, 0);
  return true;
} catch (error) {
  // EPERM = existe mas não é sinalizável (ainda vivo); qualquer outro = sumiu.
  return Boolean(error && error.code === 'EPERM');
}

Serializando o ciclo de vida

Um encerramento verificado só elimina o vazamento se nada iniciar enquanto ele roda. A invariante de que realmente precisávamos era mais forte que "encerre direito": no máximo um cliente vivo e um Chromium por identificador de sessão, e nenhum start novo até o navegador anterior estar confirmadamente morto.

Isso é um problema de exclusão mútua, com chave por sessão. Uma cadeia de promises simples é o atalho tentador e tem uma propriedade ruim: uma tarefa rejeitada contamina todas as tarefas na fila atrás dela, então uma única limpeza com falha travaria aquela sessão permanentemente. O lock, portanto, engole a falha da tarefa anterior mas ainda espera ela terminar:

await previous.catch(() => undefined);
try {
  return await task();
} finally {
  release();
  if (chains.get(k) === gate) chains.delete(k);
}

As duas últimas linhas importam mais do que parecem. Sem remover a entrada quando a tarefa atual é a última da fila, o mapa de cadeias por sessão cresce por toda a vida do processo, o que é um vazamento lento introduzido pela correção de um rápido.

Recusando gerar códigos QR para ninguém

A última correção é uma política, não um mecanismo, e é a que teria nos ajudado primeiro. Depois que a Meta desconecta um dispositivo, a autenticação salva some. Nenhuma quantidade de tentativas reconecta. Só uma pessoa escaneando um QR code consegue, e se não houver ninguém presente o loop simplesmente roda até alguém perceber.

Então o supervisor agora estaciona sessões em vez de tentar para sempre. Duas regras, ambas derivadas do que o log de fato mostrou:

A propriedade importante é que needs_relink é um estado visível, e não uma parada silenciosa. Uma sessão que parou de tentar e diz isso se recupera em um minuto. Uma sessão gerando o seu 300º QR code parece ocupada e não está.

O que isso custa de verdade

Operamos os dois lados do mundo WhatsApp em um só produto. O aplicativo desktop conduz sessões whatsapp-web.js contra números pessoais, e o aplicativo em nuvem fala com a Cloud API oficial da Meta para números comerciais. Isso torna a comparação concreta em vez de retórica.

Nada da falha acima é possível na Cloud API. Não há navegador, nem diretório de autenticação, nem sessão de dispositivo, nem QR code, então não há o que vazar e nenhum dispositivo para desconectar. A troca é direta quando dita com clareza: as bibliotecas não oficiais não custam nada por mensagem e carregam uma superfície operacional que fica invisível até falhar às duas da manhã. A API oficial elimina essa superfície inteira e cobra por mensagem no lugar.

A discussão pública sobre bibliotecas não oficiais do WhatsApp é quase toda sobre risco de banimento, que é real. O que fica de fora é o custo corrente comum, e esta nota é uma noite dele. Se você está pesando os dois caminhos, calcule a supervisão que vai ter de escrever, não só as mensagens que não vai ter de pagar.

Usa whatsapp-web.js e está pensando na API oficial?

A Klaros opera os dois lados. O lado desktop mantém vivas as sessões de números pessoais, e o lado em nuvem envia pela Cloud API da Meta na sua própria conta do WhatsApp Business, cobrada diretamente pela Meta, sem margem por mensagem no meio do caminho. Seus contatos e grupos vão junto, porque a própria Klaros sincroniza isso. Seu histórico de conversas não vai, e nenhum fornecedor pode prometer o contrário com honestidade: a Meta não permite importar conversas anteriores para a Cloud API.

Perguntas que as pessoas fazem sobre isso

Por que o whatsapp-web.js fica mostrando um novo QR code em vez de reconectar?

Um QR code que se repete significa que a autenticação salva foi perdida, não que a conexão está lenta. A causa habitual é mais de um processo de navegador segurando o mesmo diretório LocalAuth: o whatsapp-web.js enxerga uma sessão em conflito, invoca takeoverOnConflict, e os clientes brigam entre si até a Meta desconectar o dispositivo. Depois disso, só uma pessoa escaneando um QR code resolve, então um loop de reconexão automática vai gerar códigos indefinidamente sem nunca ter sucesso.

Por que a Meta desconectou meu dispositivo se eu não estava enviando nada?

Uma desconexão forçada nem sempre é a Meta detectando automação. Se dois ou mais processos Chromium seus estão vivos sobre um mesmo diretório de autenticação, eles tomam a sessão uns dos outros em sequência, e a Meta trata esse padrão como um dispositivo em que não pode confiar. No nosso caso havia onze navegadores vazados vivos ao mesmo tempo, e a desconexão foi inteiramente autoinfligida.

Chamar process.kill basta para encerrar um Chromium vazado?

Não. O SIGKILL é assíncrono: o sistema operacional pode levar centenas de milissegundos para coletar o processo e liberar seus bloqueios de arquivo, incluindo o diretório LocalAuth e o SingletonLock. Um código que sinaliza um processo e imediatamente sobe outro no lugar vai rodar os dois ao mesmo tempo com frequência. O encerramento precisa ser verificado, consultando o PID até ele realmente ter sumido.

O que significa um erro EBUSY no diretório de autenticação do WhatsApp?

Significa que algo ainda tem o diretório aberto, o que no Windows quase sempre é um processo de navegador que recebeu o sinal mas ainda não encerrou. Apagar o diretório de autenticação enquanto ele está preso ou falha, ou tem sucesso pela metade, e um diretório apagado pela metade produz exatamente o loop de QR code que a limpeza deveria resolver. Tentar a remoção de novo com um pequeno intervalo, depois de confirmar que os processos morreram, é a ordem confiável.

A API oficial do WhatsApp Cloud tem esse mesmo problema?

Não. A Cloud API é hospedada pela Meta e não mantém navegador, diretório de autenticação nem sessão de dispositivo, então não há nada para vazar e nenhum dispositivo para desconectar. Essa é a troca que se faz: as bibliotecas não oficiais não custam nada por mensagem e carregam essa superfície operacional, enquanto a Cloud API elimina a superfície e cobra por mensagem. A falha documentada aqui é um custo corrente do caminho não oficial que raramente entra na conta.

Escrito em 3 de setembro de 2026 a partir do server.log de 1–2 de julho de 2026. Atualizamos quando os fatos mudam.