Curso / Lição 6  ·  EN
Lição 06 · Estudo de caso de engenharia

O vazamento de órfãos do vitest

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.

O incidente — 2026-06-23

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.

16
workers órfãos (PPID=1)
~1550%
CPU total (load avg 34)
~11h
vivos, girando

As duas causas — nenhuma fatal sozinha

Este é o coração da lição. O vazamento precisou de ambas verdadeiras ao mesmo tempo:

CausaO que é
1 · Um teste que travaUm 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 árvoreO 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.
Por que "nenhuma sozinha é fatal" importa. Um teste travado que você mata de forma limpa leva os workers junto. Um órfão que ia terminar de qualquer jeito termina. É a combinação — um worker que nunca vai terminar sozinho, desligado de qualquer pai que o ceifaria — que produz um processo imortal travando a CPU. Correções robustas atacam a interação.
ANTES (pai vivo): pnpm test (pai) vitest main fork fork fork uma árvore — matar o pai ceifa todos DEPOIS (pai morto, árvore não): pai ✗ morto PID 1 (init) fork 96% fork 94% fork 90% reparenteados ao PID 1 — ninguém os ceifa + travam → giram pra sempre

A correção — duas camadas, uma por causa

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.

Camada 1 — fazer um hang falhar, não travar (causa 1)

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',

Camada 2 — tornar o orfanamento impossível (causa 2)

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 */ }
};

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.

Como foi verificado — o Proof Gate

"Deveria estar corrigido" não é prova. A correção só foi declarada pronta contra uma fronteira observável: rodar a suíte completa via pnpm test:safe565 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.

As regras de operação que decorreram disso

RegraPor 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".
1. Por que nenhuma causa produziu o vazamento sozinha?
Correto: c. O processo imortal é a interseção: um worker que nunca vai terminar sozinho (o hang) desligado de qualquer pai que o ceifaria (o orfanamento). É por isso que a correção tem que atacar a interação — duas camadas, uma por causa.
2. O que process.kill(-child.pid, 'SIGKILL') faz que process.kill(child.pid, …) não faria?
Correto: b. Porque o filho foi gerado com 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.
3. Por que "os 565 testes passam" não é considerado prova suficiente de que o vazamento está corrigido?
Correto: d. Verde prova correção; a tabela de processos vazia prova que nenhum worker sobreviveu à run. O vazamento é um bug de ciclo-de-vida de processo, então a prova tem que ser observada na fronteira do processo — essa é a disciplina do Proof Gate.

Confusões comuns

"É só aumentar o timeout." Um timeout maior faz o hang demorar mais para falhar — não faz nada quanto ao orfanamento. A correção em duas camadas é deliberada: timeouts cuidam do hang, o kill-de-grupo+sweep cuidam do orfanamento.
"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.