Skip to content

fix(testes): elimina a flakiness de core.test.ts (espera por tempo real e isolamento de authDir) - #28

Open
Clintonrocha98 wants to merge 3 commits into
mainfrom
fix/testes-core-flaky
Open

fix(testes): elimina a flakiness de core.test.ts (espera por tempo real e isolamento de authDir)#28
Clintonrocha98 wants to merge 3 commits into
mainfrom
fix/testes-core-flaky

Conversation

@Clintonrocha98

Copy link
Copy Markdown
Member

Contexto

tests/collector/core.test.ts falhava de forma intermitente, em testes diferentes a cada rodada:
loggedOut limpa o authDir e reconecta, webhook != null liga o coletor, repassa cada evento p/ saveEvent, connection.update: connecting e qr propagam, group-participants.update invalida o diretório, entre outros. Medido antes do conserto: 2 de 5 rodadas da suíte completa falhavam.

Rodando só esse arquivo, 15/15 passavam — o que explica por que o problema parecia aleatório.

Causa raiz

O helper emit() esperava o ev.process do socket falso ser registrado contando 100 voltas do
event loop
, como se isso fosse proxy de tempo:

for (let i = 0; i < 100 && !processCb; i++) await new Promise((r) => setImmediate(r))
assert.ok(processCb, 'ev.process não foi registrado')

Mas connect() faz I/O de disco real (useMultiFileAuthState: stat/mkdir/readFile via libuv). Com a
suíte inteira rodando (~24 arquivos em processos concorrentes disputando disco e CPU), 100
setImmediate passam em ~2ms de relógio — tempo insuficiente para a I/O terminar. Evidência
capturada com instrumentação temporária:

connect() start authDir=/tmp/core-test-.../baileys_auth_info
emit: esperou 100 ticks / 2ms, processCb=false     ← orçamento estourado
TEST START "webhook != null liga o coletor..."
✖ loggedOut limpa o authDir e reconecta
useMultiFileAuthState done +25ms                   ← terminou depois da falha

Falha por relógio, sem nenhum bug de lógica. UV_THREADPOOL_SIZE=64 não resolveu, descartando
"threadpool saturado" como causa isolada.

Agravante: todos os testes usavam o mesmo authDir relativo ('baileys_auth_info', com o cwd
fixado uma vez no topo do arquivo). Como stop() não esperava o connect() em voo, uma conexão
sobrevivia ao teste que a originou e ia disputar o mesmo diretório — e o mutex de arquivo do Baileys,
que é module-level por caminho — com o teste seguinte.

O que muda

tests/collector/core.test.ts

  • waitUntil(cond, timeoutMs = 5000): espera por tempo real (Date.now()), não por ticks.
  • nextAuthDir(): cada teste ganha a própria subpasta (auth-N/baileys_auth_info).
  • Nenhuma asserção foi enfraquecida, pulada ou removida — a cobertura é idêntica.

src/collector/core.ts (código de produção, então justificando)

  • connectPromise aponta para o connect() em voo mais recente (inicial ou reconexão via close).
  • Guard if (stopped) return em connect(), após os awaits de auth state e versão, antes de criar o
    socket e registrar ev.process.
  • stop() aguarda esse connect(), com teto de 2s.

O motivo de mexer em produção: sem isso, stop() retornava enquanto um connect() (ou uma
reconexão pós-close) seguia por trás criando authDir, abrindo socket e registrando handlers
depois que o chamador já considerava o coletor parado. Não é só ruído de teste — um stop() durante
ciclo de reconexão não garantia que o coletor parasse de tentar falar com o WhatsApp.

O teto de 2s existe porque connect() contém uma chamada HTTP (fetchLatestBaileysVersion).
Numa rede ruim ela fica pendurada, e um stop() preso atrás dela nunca chegaria ao
outbox.close() — o checkpoint do WAL do SQLite —, caindo no forceMs do shutdown.ts (saída com
código 1). Seria pior que o comportamento anterior, em que o outbox fechava imediatamente. Com o
teto, a garantia de quiescência vale no caso normal (o connect() só terminando de resolver, que é
o caso dos testes) e o shutdown não fica hostage de rede.

Verificação

  • 25 rodadas consecutivas da suíte completa após o conserto dos testes: 25/25 passaram.
  • 10 rodadas adicionais após o teto no stop(): 10/10 passaram (246 testes).
  • tsc --noEmit -p tsconfig.test.json: limpo.
  • Antes: 2/5, 2/10, 2/20 rodadas falhavam, sempre com a assinatura ev.process não foi registrado.

Limitação conhecida

O teto de 2s no stop() não tem teste: fetchLatestBaileysVersion é importado direto no módulo e
não é injetável, então simular a chamada pendurada exigiria um refactor de injeção fora do escopo
deste conserto. O caminho normal (connect resolvendo antes do teto) está coberto pelos 15 testes que
chamam startCollectorCore e stop().

O waitUntil tem timeout finito (5s): numa máquina absurdamente sobrecarregada a falha ainda seria
teoricamente possível — mas por tempo real esgotado, não por um número arbitrário de ticks.

Causa raiz: o helper emit() dos testes esperava o ev.process registrar
usando um número fixo de voltas do event loop (100 setImmediate), como
proxy pra "connect() já teve tempo de terminar". connect() faz I/O real
de disco (useMultiFileAuthState via libuv). Sob carga — a suíte inteira
rodando em paralelo, ~20+ processos disputando disco/CPU — 100 ticks
podem passar em ~2ms de relógio, tempo real insuficiente pra essa I/O
terminar, disparando "ev.process não foi registrado" à toa. Confirmado
instrumentando connect() e o loop de espera: o timeout de 100
ticks/2ms bateu com useMultiFileAuthState ainda em voo.

O mesmo authDir relativo ('baileys_auth_info') era reusado por todos os
testes do arquivo (cwd fixo uma vez no topo do arquivo), então um
connect() que sobrevive ao teste que o originou — caso comum, já que
stop() nunca esperava o connect()/reconexão em voo — ficava brigando
pelo mesmo diretório/mutex de arquivo do teste seguinte, um ruído de
I/O concorrente a mais alimentando a flakiness.

Testes: troca a espera por ticks por um waitUntil() com orçamento de
tempo real, e dá a cada teste sua própria subpasta de authDir.

src/collector/core.ts: stop() agora espera de fato o connect() em voo
(inicial ou reconexão) terminar antes de fechar outbox/socket, e
connect() aborta cedo se stopped virou true enquanto ele ainda esperava
auth state/versão. Sem isso um connect() abandonado continuava abrindo
socket e registrando handlers depois do stop() "concluído" — em
produção isso significa reconectar ao WhatsApp mesmo com o coletor
supostamente parado.
O connect() aguardado pelo stop() contém uma chamada HTTP
(fetchLatestBaileysVersion). Numa rede ruim ela fica pendurada, e o stop() ficava
preso atrás dela — justamente antes de fechar o outbox. O efeito prático seria um
restart durante reconexão cair no forceMs do shutdown (saída com código 1) sem
nunca dar checkpoint no WAL do SQLite: pior que o comportamento anterior, em que
o outbox fechava na hora.

Com o teto de 2s, o stop() mantém a garantia de quiescência quando o connect()
está apenas terminando (o caso normal, e o dos testes: resolve em milissegundos)
e desiste de esperar quando ele está travado em rede.
@Clintonrocha98
Clintonrocha98 requested a review from a team August 19, 2026 09:29
gvieira18
gvieira18 previously approved these changes Aug 19, 2026
Conflito em tests/collector/core.test.ts: a branch trocava o authDir de todos
os testes por nextAuthDir() (isolamento de I/O), incluindo dois casos que a main
apagou no refactor "remoção é ato, não estado" — a asserção do enforcer que
removia de novo na reentrada e o teste inteiro do /unban.

Resolvido pelo lado da main: o comportamento novo vale (reentrada não dispara
remoção; não há denylist nem /unban). O que a branch contribui na região —
nextAuthDir() no teste que sobrou — o merge já tinha preservado fora do conflito.

269 testes passando; 5 rodadas da suíte e 6 instâncias paralelas de
core.test.ts sem nenhuma falha.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants