Um incidente real da construção desta fusão. Dois fatos de aparência inofensiva se combinaram em 16 processos imortais travando a máquina. A correção é um pequeno clássico de engenharia de sistemas defensiva — e uma lição sobre corrigir a interação, não só uma causa.
16 workers node (vitest) órfãos. PPID = 1 (reparentados ao init), cada um a ~90–96% de CPU, vivos por ~11 horas, cwd neste repositório. Foram gerados cerca de um a cada 2 minutos ao longo de ~30 minutos por execuções repetidas de pnpm test no host.
Este é o coração da lição. O vazamento precisou de ambas verdadeiras ao mesmo tempo:
| Causa | O que é |
|---|---|
| 1 · Um teste que trava | Um teste que trava com nenhum teardown — um server/socket/MCP deixado aberto, um setInterval nunca limpo, uma promise não resolvida. Sozinho: irritante, mas você notaria e daria Ctrl-C. |
| 2 · Pai morto sem matar a árvore | O processo pai (um timeout de Bash/sessão) é morto sem matar a árvore de workers. Os forks do tinypool do Vitest então reparenteiam ao PID 1 e continuam girando. Sozinho (com testes que terminam): inofensivo. |
Um único patch não seria robusto: endureça a config e um hang diferente ainda orfaniza; adicione o wrapper e um hang dentro do grupo ainda trava um core até o timeout de relógio. Ambas as camadas são entregues.
Timeouts limitados em vitest.config.ts (aplicados a ambos os projetos raiz via extends) transformam um hang infinito em uma falha limitada, e o pool forks isola um arquivo travado num processo filho que o Vitest mata à força no teardown — então um arquivo travado não pode travar um core indefinidamente.
// vitest.config.ts:20-27 // Endurecimento anti-órfão: um teste travado (server/socket/MCP/interval/promise // não resolvida sem teardown) DEVE FALHAR num timeout limitado, nunca travar um // worker pra sempre. O pool `forks` isola hangs em processos filhos que o Vitest // mata à força no teardown, então um arquivo travado não pode travar um core. testTimeout: 15_000, hookTimeout: 15_000, teardownTimeout: 10_000, pool: 'forks',
scripts/safe-test.mjs (exposto como pnpm test:safe) roda a suíte em seu próprio grupo de processos, sob um timeout de relógio rígido, então mata o grupo inteiro — não só o PID pai — e varre qualquer vitest perdido como rede de último recurso. Três técnicas, cada uma carregando peso:
// scripts/safe-test.mjs:34-44 — grupo destacado + kill por PID negativo // detached:true => POSIX setsid => o filho lidera um NOVO grupo de processos, // então `kill(-pid)` alcança todo descendente (vitest main + todos os forks). const child = spawn(bin, args, { stdio: 'inherit', detached: true }); const killGroup = (signal) => { try { process.kill(-child.pid, signal); } // pid NEGATIVO = o grupo inteiro catch { /* grupo já se foi */ } };
// scripts/safe-test.mjs:26-32 — a varredura de último recurso const sweep = () => { try { execFileSync('pkill', ['-9', '-f', 'vitest'], { stdio: 'ignore' }); } catch { /* nada a matar — pkill sai não-zero quando não há match */ } };
detached:true → o filho lidera um novo grupo de processos (POSIX setsid), então um único sinal alcança todo descendente.process.kill(-child.pid, …) → o PID negativo sinaliza o grupo inteiro, incluindo os forks do tinypool — nunca um PID nu.pkill -9 -f vitest → pega qualquer coisa que já escapou para o PID 1 (o caso de orfanamento), então o vazamento não pode acumular entre runs.SAFE_TEST_TIMEOUT_MS rígido (padrão 600000 = 10 min) limita a run inteira; um timeout sai com 124. O mesmo kill+sweep roda no exit normal, em SIGINT/SIGTERM, e em erro de spawn.Uma terceira medida de cinto-e-suspensório: .factory/run.ts varre vitest perdido no host no início e no fim de cada iteração — então um loop automatizado nunca herda o vazamento de uma run anterior.
pnpm test:safe → 565 testes verdes, e imediatamente depois, pgrep -f vitest retorna vazio. Verde sozinho não basta; a tabela de processos vazia é a parte que prova que nenhum worker vazou.# a verificação, conceitualmente pnpm test:safe # run limitada no próprio grupo → 565 passaram pgrep -f vitest # → (sem saída): nada vazou. ISSO é a prova.
| Regra | Por quê |
|---|---|
Nunca rode pnpm -w test cru num loop ou entregue a um builder automatizado — use pnpm test:safe. | O comando cru não tem kill-de-grupo; um único hang num loop reproduz o vazamento. |
Após cada iteração de loop, varra: pgrep -f vitest | xargs -r kill -9. | Uma rede permanente mesmo que o wrapper já faça isso. |
Sempre vitest run, nunca vitest / modo watch. | Modo watch é um processo de vida longa — o oposto do que você quer em CI/loops. |
Todo subprocesso que um orquestrador gera: {detached:true} + matar o PGID negativo, nunca um PID nu; sempre um timeout rígido. | Generaliza a correção: a classe do vazamento é "uma árvore filha sobrevivendo a quem a mata". |
process.kill(-child.pid, 'SIGKILL') faz que process.kill(child.pid, …) não faria?detached:true (seu próprio grupo via setsid), um PID negativo alcança todo descendente. Um PID nu mataria só o vitest main e deixaria os forks reparentearem — exatamente o bug.forks vs threads é só performance." Aqui também é segurança: o pool forks isola um hang num processo filho que o Vitest mata à força no teardown, então um arquivo travado não pode travar um core como uma thread não-matável poderia.