Um incidente real de quando construíamos esta fusão. Dois fatos inofensivos 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 consertar a interação, não apenas uma causa.
alembic-vitest-orphan-leak + scripts/safe-test.mjs + vitest.config.ts
Esta lição destila o registro do incidente na memória do projeto (16 workers, PPID=1, ~1550% de CPU, ~11h), as duas causas combinadas, as correções que foram para o repo e as regras obrigatórias de loop. Cada número aqui é citado de arquivo real — nada é inventado.
pool:'forks' em vitest.config.ts; detached:true + kill de grupo + sweep em safe-test.mjs) e dizer qual camada ataca qual causa.process.kill(-child.pid, …) com PID negativo é o detalhe que mata todos os tinypool forks.pgrep -f vitest vazio é o que prova de verdade.setsid, tinypool ou process group. Tudo isso é explicado do zero aqui.Numa noite de construção, a máquina ficou lenta de repente. O culpado: 16 workers órfãos do Vitest, cada um girando perto de 100% de CPU, vivos havia ~11 horas — e que continuariam vivos para sempre se ninguém os matasse à mão.
16 workers órfãos node (vitest). PPID = 1 (reparented para o init), cada um a ~90–96% de CPU, vivos por ~11 horas, com cwd neste repositório. Foram criados a um ritmo de cerca de um a cada 2 minutos ao longo de ~30 minutos, por execuções repetidas de pnpm test no host.
Repare na assinatura de um órfão: PPID = 1. Em sistemas tipo Unix, quando o processo-pai morre sem reapear os filhos, esses filhos são reparented (adotados) pelo processo init, cujo PID é 1. Ver vários node (vitest) com PPID 1 é o sintoma exato de que algo perdeu o controle da própria árvore de processos.
load average de 34 numa máquina que fica abaixo de 2: dezenas de tarefas brigando pela CPU ao mesmo tempo.Um load average de 34 numa máquina que normalmente fica abaixo de 2 significa dezenas de tarefas competindo pela CPU ao mesmo tempo. Dezesseis workers a ~96% cada somam algo perto de ~1550% de CPU (cada núcleo conta como 100%), o que basta para tornar o resto do sistema arrastado. O cwd apontando para o repo confirma a origem: foram os tinypool forks do próprio Vitest, não algum outro processo.
Este é o coração da lição. O vazamento precisou que as duas coisas fossem verdade ao mesmo tempo:
| Causa | O que é | Sozinha |
|---|---|---|
| 1 · Um teste pendurado | Um teste que pendura sem teardown — um server/socket/MCP deixado aberto, um setInterval nunca limpo, uma promise não resolvida. | Chato, mas você nota e dá 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 para PID 1 e continuam girando. | Com testes que saem: inofensivo. |
tinypool sobrevive ao pai e é adotado pelo init.Cada causa, sozinha, é sobrevivível. Então por que a combinação das duas produz um processo que nunca morre? Pense um instante.
A interseção das duas causas sobrevivíveis é o estado imortal. Telemetria: o incidente real de 2026-06-23.
tinypool?Um patch único não seria robusto: endureça só a config e um hang diferente ainda orfana; adicione só o wrapper e um hang dentro do grupo ainda trava um núcleo até o timeout de relógio. As duas camadas vão para o repo.
Duas camadas, uma por causa, mais o Proof Gate: 565 green e pgrep vazio.
Timeouts limitados no vitest.config.ts transformam um hang infinito numa falha limitada, e o pool forks isola um arquivo travado num processo-filho que o Vitest força a morrer no teardown — então um arquivo travado não consegue prender um núcleo indefinidamente.
// Anti-orphan hardening: a hung test (server/socket/MCP/interval/unresolved // promise with no teardown) must FAIL on a bounded timeout, never hang a // worker forever. The `forks` pool isolates hangs in child processes that // Vitest force-kills on teardown, so a stuck file cannot pin a CPU core. testTimeout: 15_000, hookTimeout: 15_000, teardownTimeout: 10_000, pool: 'forks',
forks não é só performance: é segurança — um processo é force-killável; uma thread travada, não tanto.scripts/safe-test.mjs (exposto como pnpm test:safe) roda a suíte no próprio process group, sob um timeout de relógio duro, e então mata o grupo inteiro — não só o PID do pai — e varre qualquer vitest perdido como rede de último recurso. Três técnicas, cada uma carregando peso:
// detached:true => POSIX setsid => the child leads a NEW process group, so // `kill(-pid)` reaches every descendant (vitest main + all tinypool 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 — a varredura de último recurso
const sweep = () => { try { execFileSync('pkill', ['-9', '-f', 'vitest'], { stdio: 'ignore' }); } catch { /* nada para matar — pkill sai != 0 quando não há match */ } };
detached:true → o filho lidera um novo process group (POSIX setsid), então um único sinal alcança todos os descendentes.process.kill(-child.pid, …) → o PID negativo sinaliza o grupo inteiro, incluindo os tinypool forks — nunca um PID nu.pkill -9 -f vitest (sweep) → pega qualquer coisa que já tenha escapado para o PID 1 (o caso de orfanação), então o vazamento não acumula entre execuções.SAFE_TEST_TIMEOUT_MS duro (default 600000 = 10 min) limita a execução inteira; um timeout sai 124. O mesmo kill+sweep roda na saída normal, em SIGINT/SIGTERM (que saem 130) e em erro de spawn.kill(child.pid)) mataria só o vitest main e deixaria os forks reparentearem — exatamente o bug. O PID negativo (kill(-child.pid)) é o detalhe que muda tudo..factory/run.ts varre o vitest perdido do host no início e no fim de cada iteração — então um loop automatizado nunca herda o vazamento de uma execução anterior.Pense em duas redes de segurança. A primeira garante que nenhum teste "fica preso" para sempre — depois de 15 segundos, ele desiste e é marcado como falha. A segunda garante que, quando você desliga a chave geral, tudo que a execução acendeu se apaga junto — não sobra nenhuma lâmpada acesa num quarto esquecido. As duas redes juntas é que tornam impossível um processo ficar girando sozinho na máquina.
Camada 1 é configuração declarativa do runner: testTimeout/hookTimeout abortam uma execução que excede o orçamento, teardownTimeout limita a limpeza, e pool:'forks' roda cada arquivo num processo isolado que o Vitest sabe force-killar. Camada 2 é controle de ciclo de vida de processo: spawn(...,{detached:true}) cria um novo PGID via setsid, e process.kill(-pgid, sig) entrega o sinal a todo o grupo (semântica POSIX de "kill com PID negativo = process group"). O pkill -9 -f vitest é idempotente e tolerante a "nenhum match" (sai != 0, capturado). O timer (SAFE_TEST_TIMEOUT_MS) faz SIGTERM → espera 5s → SIGKILL + sweep → exit(124).
A correção só foi declarada pronta contra uma fronteira observável: rodar a suíte inteira via pnpm test:safe → 565 tests green, e logo em seguida, 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 # execução limitada no próprio grupo → 565 passed pgrep -f vitest # → (sem saída): nada vazou. ESTA é a prova.
Proof Gate · fronteira do processo O vazamento é um bug de processos persistentes — por isso a prova tem de ser observada na fronteira do processo, não só no resultado dos testes. Esta é a disciplina do Proof Gate em uma frase: prove no limite real, nunca em "parece correto".
pnpm test:safe. Anote o resultado — espere 565 passed.pgrep -f vitest. Saída vazia = nenhum worker sobreviveu.pnpm test:safe e, durante a execução, dê Ctrl-C. Depois rode pgrep -f vitest. Se voltar vazio, o handler de SIGINT (kill+sweep, exit 130) fez seu trabalho.| Regra | Por quê |
|---|---|
Nunca rode pnpm -w test cru num loop nem o 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. |
Depois de 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 / watch mode. | Watch mode é um processo de vida longa — o oposto do que você quer em CI/loops. |
Qualquer subprocesso que um orquestrador cria: {detached:true} + matar o PGID negativo, nunca um PID nu; sempre um timeout duro. | Generaliza a correção: a classe do vazamento é "uma árvore de filhos sobrevivendo ao matador". |
pnpm test:safe é obrigatório em loops. A regra existe justamente porque o comando cru já reproduziu o incidente uma vez.forks vs threads é só performance." Aqui é também segurança: o pool forks isola um hang num processo-filho que o Vitest force-killa no teardown, então um arquivo travado não consegue prender um núcleo como uma thread não-matável poderia.Três perguntas. O placar acompanha seus acertos.
process.kill(-child.pid, 'SIGKILL') faz que process.kill(child.pid, …) não faria?detached:true (próprio grupo via setsid), um PID negativo alcança todos os descendentes. Um PID nu mataria só o vitest main e deixaria os forks reparentearem — exatamente o bug.pgrep vazio, observada na fronteira do processo.