From 38bd0e50b88879bc1d621a6b625e44db8539f3ba Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Wed, 19 Aug 2026 06:05:11 -0300 Subject: [PATCH 1/2] fix: elimina flakiness de tests/collector/core.test.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/collector/core.ts | 19 +++++++++-- tests/collector/core.test.ts | 66 +++++++++++++++++++++++++----------- 2 files changed, 63 insertions(+), 22 deletions(-) diff --git a/src/collector/core.ts b/src/collector/core.ts index 7b814cf..6a96a4e 100644 --- a/src/collector/core.ts +++ b/src/collector/core.ts @@ -139,6 +139,12 @@ export function startCollectorCore(deps: CollectorCoreDeps): CollectorCoreHandle let stopped = false let sock: ReturnType | null = null + // aponta pro connect() em voo mais recente (inicial ou reconexão via 'close'). stop() espera essa + // promise antes de terminar — sem isso, um connect() já passado dos awaits de auth/versão no + // momento do stop() ainda abriria socket e registraria handlers depois que o chamador já + // considerava o coletor parado (produção: reconecta ao WhatsApp mesmo "parado"; testes: o + // connect() de um caso que já terminou seguia mexendo no mesmo authDir do caso seguinte). + let connectPromise: Promise = Promise.resolve() async function fetchGroupsMetadata(activeSock: ReturnType) { const cache = loadGroupCache() @@ -176,6 +182,11 @@ export function startCollectorCore(deps: CollectorCoreDeps): CollectorCoreHandle const { version } = await fetchLatestBaileysVersion() + // stop() pode ter sido chamado enquanto ainda esperávamos o auth state / a versão mais + // recente — sem este guard o connect() abandonado abriria socket e registraria ev.process + // mesmo assim, "reconectando" depois que o coletor já era pra estar parado. + if (stopped) return + const activeSock = makeSocket({ version, logger: deps.baileysLogger, @@ -324,7 +335,7 @@ export function startCollectorCore(deps: CollectorCoreDeps): CollectorCoreHandle } } deps.onStatus?.('connecting') - connect() + connectPromise = connect() } if (qr) { deps.onQr?.(qr) @@ -333,7 +344,7 @@ export function startCollectorCore(deps: CollectorCoreDeps): CollectorCoreHandle }) } - connect() + connectPromise = connect() return { async stop() { @@ -341,6 +352,10 @@ export function startCollectorCore(deps: CollectorCoreDeps): CollectorCoreHandle stopSender() stopHeartbeat() stopRetention() + // espera o connect() em voo terminar (ou abortar pelo guard acima) antes de fechar outbox + // e encerrar o socket — sem isso, stop() "concluía" enquanto uma conexão/reconexão ainda + // em andamento seguia criando authDir/socket por trás. + await connectPromise.catch(() => {}) outbox?.close() sock?.end(undefined) }, diff --git a/tests/collector/core.test.ts b/tests/collector/core.test.ts index 7fbab1b..6fb659a 100644 --- a/tests/collector/core.test.ts +++ b/tests/collector/core.test.ts @@ -78,8 +78,9 @@ function makeFakeSocket(opts: { } // emit: dispara o loop de ev.process com o lote de eventos informado. const emit = async (events: Record) => { - // connect() é async (await useMultiFileAuthState + fetchLatestBaileysVersion); damos uns ticks. - for (let i = 0; i < 100 && !processCb; i++) await new Promise((r) => setImmediate(r)) + // connect() é async (await useMultiFileAuthState + fetchLatestBaileysVersion) — espera por + // tempo real, não por ticks (ver waitUntil). + await waitUntil(() => processCb !== null) assert.ok(processCb, 'ev.process não foi registrado') await processCb!(events) } @@ -90,11 +91,35 @@ const makeFakeMakeSocket = (sock: any) => (() => sock) as any const flush = async (n = 4) => { for (let i = 0; i < n; i++) await new Promise((r) => setImmediate(r)) } +// espera até `cond()` virar true, por até `timeoutMs` de tempo REAL (não um número fixo de voltas +// do event loop). connect() faz I/O de disco de verdade (useMultiFileAuthState: stat/mkdir/readFile +// via libuv); sob carga — a suíte inteira rodando junto, ~20+ processos disputando disco/CPU — essa +// I/O pode demorar mais do que 100 `setImmediate` levam para passar (às vezes só ~2ms de relógio +// quando o event loop está ocioso). Contar tempo real em vez de tick corrige a flakiness raiz deste +// arquivo (evidência no relatório da correção: 100 ticks/2ms decorridos com useMultiFileAuthState +// ainda em voo). +async function waitUntil(cond: () => boolean, timeoutMs = 5000): Promise { + const start = Date.now() + while (!cond()) { + if (Date.now() - start > timeoutMs) return false + await new Promise((r) => setTimeout(r, 1)) + } + return true +} + +// cada teste ganha sua PRÓPRIA subpasta de authDir — nunca a mesma de outro teste. Motivo: todos +// os testes usavam o mesmo 'baileys_auth_info' relativo (cwd fixo no arquivo todo), então uma +// reconexão que sobrevive ao teste que a originou (ver correção em src/collector/core.ts) ficava +// batendo no MESMO diretório/mutex de arquivo (mutex é module-level no baileys) que o teste seguinte +// — ruído de I/O concorrente que também alimentava a flakiness. +let authDirSeq = 0 +const nextAuthDir = () => `auth-${++authDirSeq}/baileys_auth_info` + test('repassa cada evento p/ saveEvent (trilha NDJSON), router e onEvent', async () => { const fake = makeFakeSocket() const seen: Array<[string, unknown]> = [] const handle = startCollectorCore({ - authDir: 'baileys_auth_info', + authDir: nextAuthDir(), outboxPath: 'outbox.db', logger: silentLogger, baileysLogger: silentLogger, @@ -128,7 +153,7 @@ test('connection.update: open dispara onStatus(connected,{me}) e busca metadata const statuses: Array<{ s: string; me?: string }> = [] const events: string[] = [] const handle = startCollectorCore({ - authDir: 'baileys_auth_info', + authDir: nextAuthDir(), outboxPath: 'outbox.db', logger: silentLogger, baileysLogger: silentLogger, @@ -158,7 +183,7 @@ test('connection.update: connecting e qr propagam via onStatus/onQr', async () = const statuses: string[] = [] let qr: string | null = null const handle = startCollectorCore({ - authDir: 'baileys_auth_info', + authDir: nextAuthDir(), outboxPath: 'outbox.db', logger: silentLogger, baileysLogger: silentLogger, @@ -179,10 +204,11 @@ test('connection.update: connecting e qr propagam via onStatus/onQr', async () = test('loggedOut limpa o authDir e reconecta', async () => { const fake = makeFakeSocket() - const authDir = path.join(tmpDir, 'baileys_auth_info') + const authRel = nextAuthDir() + const authDir = path.join(tmpDir, authRel) const statuses: string[] = [] const handle = startCollectorCore({ - authDir: 'baileys_auth_info', + authDir: authRel, outboxPath: 'outbox.db', logger: silentLogger, baileysLogger: silentLogger, @@ -191,8 +217,8 @@ test('loggedOut limpa o authDir e reconecta', async () => { makeSocket: makeFakeMakeSocket(fake.sock), }) - // useMultiFileAuthState cria o dir na conexão inicial — espera-o existir. - for (let i = 0; i < 100 && !fs.existsSync(authDir); i++) await new Promise((r) => setImmediate(r)) + // useMultiFileAuthState cria o dir na conexão inicial — espera-o existir (tempo real, não ticks). + await waitUntil(() => fs.existsSync(authDir)) assert.ok(fs.existsSync(authDir), 'authDir deveria ter sido criado pelo connect inicial') // DisconnectReason.loggedOut === 401 @@ -209,7 +235,7 @@ test('webhook != null liga o coletor: cria o outbox.db', async () => { const fake = makeFakeSocket() const outboxPath = path.join(tmpDir, 'collector-on.db') const handle = startCollectorCore({ - authDir: 'baileys_auth_info', + authDir: nextAuthDir(), outboxPath: 'collector-on.db', logger: silentLogger, baileysLogger: silentLogger, @@ -240,7 +266,7 @@ test('webhook == null mantém o coletor desligado (sem outbox.db)', async () => const fake = makeFakeSocket() const outboxPath = path.join(tmpDir, 'collector-off.db') const handle = startCollectorCore({ - authDir: 'baileys_auth_info', + authDir: nextAuthDir(), outboxPath: 'collector-off.db', logger: silentLogger, baileysLogger: silentLogger, @@ -258,7 +284,7 @@ test('webhook == null mantém o coletor desligado (sem outbox.db)', async () => test('stop() encerra o socket e sendMessage delega ao socket', async () => { const fake = makeFakeSocket() const handle = startCollectorCore({ - authDir: 'baileys_auth_info', + authDir: nextAuthDir(), outboxPath: 'outbox.db', logger: silentLogger, baileysLogger: silentLogger, @@ -287,7 +313,7 @@ test('messages.upsert com /ban de admin aciona a remoção (fiação core → ba }, }) const handle = startCollectorCore({ - authDir: 'baileys_auth_info', + authDir: nextAuthDir(), outboxPath: 'outbox.db', logger: silentLogger, baileysLogger: silentLogger, @@ -323,7 +349,7 @@ test('messages.upsert com /admin on de admin aciona groupSettingUpdate (fiação }, }) const handle = startCollectorCore({ - authDir: 'baileys_auth_info', + authDir: nextAuthDir(), outboxPath: 'outbox.db', logger: silentLogger, baileysLogger: silentLogger, @@ -361,7 +387,7 @@ test('messages.upsert com /ban por telefone remove da COMUNIDADE (fiação core }, }) const handle = startCollectorCore({ - authDir: 'baileys_auth_info', + authDir: nextAuthDir(), outboxPath: 'outbox.db', logger: silentLogger, baileysLogger: silentLogger, @@ -395,7 +421,7 @@ test('group-participants.update invalida o diretório (próximo comando refaz o }, }) const handle = startCollectorCore({ - authDir: 'baileys_auth_info', + authDir: nextAuthDir(), outboxPath: 'outbox.db', logger: silentLogger, baileysLogger: silentLogger, @@ -440,7 +466,7 @@ test('messages.upsert com /kick remove só do grupo (fiação core → kick-comm }, }) const handle = startCollectorCore({ - authDir: 'baileys_auth_info', + authDir: nextAuthDir(), outboxPath: 'outbox.db', logger: silentLogger, baileysLogger: silentLogger, @@ -478,7 +504,7 @@ test('group-participants.update add de quem foi banido dispara a remoção (fia }, }) const handle = startCollectorCore({ - authDir: 'baileys_auth_info', + authDir: nextAuthDir(), outboxPath: 'outbox.db', logger: silentLogger, baileysLogger: silentLogger, @@ -529,7 +555,7 @@ test('/unban tira da denylist e a reentrada volta a ser permitida (fiação core }, }) const handle = startCollectorCore({ - authDir: 'baileys_auth_info', + authDir: nextAuthDir(), outboxPath: 'outbox.db', logger: silentLogger, baileysLogger: silentLogger, @@ -586,7 +612,7 @@ test('comando em grupo comum é apagado e reportado no grupo de log (fiação co }, }) const handle = startCollectorCore({ - authDir: 'baileys_auth_info', + authDir: nextAuthDir(), outboxPath: 'outbox.db', logger: silentLogger, baileysLogger: silentLogger, From db885de1033fb36af8499078a1dc695d87cebe40 Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Wed, 19 Aug 2026 06:07:41 -0300 Subject: [PATCH 2/2] fix: limita a espera do stop() pelo connect() em voo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/collector/core.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/collector/core.ts b/src/collector/core.ts index 6a96a4e..e6850a4 100644 --- a/src/collector/core.ts +++ b/src/collector/core.ts @@ -54,6 +54,13 @@ export interface CollectorCoreHandle { const msgRetryCounterCache = new NodeCache() as CacheStore +// Teto da espera do stop() pelo connect() em voo. O connect() contém uma chamada HTTP +// (fetchLatestBaileysVersion) que numa rede ruim fica pendurada — e o stop() não pode ficar preso +// atrás dela, porque é depois dele que o outbox é fechado (checkpoint do WAL do SQLite). Sem este +// teto, um restart durante reconexão cairia no forceMs do shutdown (saída com código 1) sem nunca +// fechar o outbox. Em teste o connect() resolve em milissegundos: quem ganha a corrida é a promise. +const STOP_CONNECT_WAIT_MS = 2_000 + // converte a metadata crua do baileys no shape persistido em cache (group-metadata.json). function toGroupInfo(meta: import('@whiskeysockets/baileys').GroupMetadata): GroupInfo { return { @@ -354,8 +361,14 @@ export function startCollectorCore(deps: CollectorCoreDeps): CollectorCoreHandle stopRetention() // espera o connect() em voo terminar (ou abortar pelo guard acima) antes de fechar outbox // e encerrar o socket — sem isso, stop() "concluía" enquanto uma conexão/reconexão ainda - // em andamento seguia criando authDir/socket por trás. - await connectPromise.catch(() => {}) + // em andamento seguia criando authDir/socket por trás. Com teto: ver STOP_CONNECT_WAIT_MS. + await Promise.race([ + connectPromise.catch(() => {}), + new Promise((resolve) => { + const timer = setTimeout(resolve, STOP_CONNECT_WAIT_MS) + timer.unref?.() + }), + ]) outbox?.close() sock?.end(undefined) },