A Lição 6 contou a história do vazamento de workers órfãos — 16 processos vitest perdidos fixando ~1550% de CPU. Esta lição é a engenharia por baixo da correção: como um grupo de processo UNIX funciona, por que detached:true cria um, por que kill(-pgid) alcança todo descendente onde kill(pid) não consegue, e como a defesa em duas camadas — um vitest.config.ts endurecido mais um wrapper safe-test.mjs que mata o grupo de processo mais uma varredura pós-execução — torna o vazamento estruturalmente impossível. É um arquivo pequeno, mas cada linha é sustentadora, e a lição generaliza para qualquer ferramenta que cria uma árvore de workers.
O tinypool do Vitest roda testes em processos filhos (workers). Se um teste trava sem teardown (um socket aberto, uma promise não resolvida, um interval vivo) e o pai é morto só por PID, os workers não morrem — eles são reparentados ao PID 1 (o processo init) e seguem rodando, cada um fixando um core. Matar o processo pai não basta: você tem que matar a árvore inteira, e uma árvore que já reparentou não é mais alcançável a partir do pai.
A primeira defesa impede o travamento de acontecer, para começar. A config compartilhada define timeouts limitados e o pool forks, para que um arquivo preso falhe rápido e seja morto à força no teardown em vez de girar para sempre:
// vitest.config.ts:13-28 — o endurecimento anti-órfão export default defineConfig({ test: { environment: 'node', // Um teste travado (server/socket/MCP/interval/promise não resolvida sem // teardown) deve FALHAR num timeout limitado, nunca travar um worker para sempre. testTimeout: 15_000, hookTimeout: 15_000, teardownTimeout: 10_000, pool: 'forks', // isola travamentos em processos filhos que o Vitest mata à força no teardown }, });
Por que pool:'forks' e não as worker threads padrão? Um travamento dentro de uma thread pode emperrar o processo host; um travamento dentro de um fork (um processo de SO separado) é isolado e "o Vitest mata à força no teardown, então um arquivo preso não pode fixar um core de CPU". Os timeouts transformam uma espera infinita numa falha de teste — visível, limitada e vermelha no CI.
A config sozinha não cobre toda fuga (um handle nativo, um pai morto com SIGKILL no meio da execução). Então scripts/safe-test.mjs roda a suíte inteira no seu próprio grupo de processo e mata o grupo, não o PID. A chave é detached:true:
// scripts/safe-test.mjs:34-44 // detached:true => POSIX setsid => o filho lidera um NOVO grupo de processo, então // kill(-pid) alcança todo descendente (vitest principal + todos os forks tinypool). 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 */ } };
No POSIX, kill(pid, sig) sinaliza um processo; kill(-pgid, sig) sinaliza todo processo daquele grupo. detached:true chama setsid() para que o filho se torne um líder de grupo — seu PID é o id do grupo. Então process.kill(-child.pid, …) alcança o processo vitest principal e todo fork tinypool numa syscall. É a diferença entre "matei o pai, orfanei os filhos" e "matei a família".
Um timer de wall-clock limitado escala educadamente-e-depois-à-força. SIGTERM primeiro (deixe limpar), SIGKILL 5 segundos depois (force), depois uma varredura, e sai com 124 (o código convencional de timeout):
// scripts/safe-test.mjs:46-58 const timer = setTimeout(() => { timedOut = true; process.stderr.write(`\n[safe-test] HARD TIMEOUT after ${TIMEOUT_MS}ms — killing process group -${child.pid}\n`); killGroup('SIGTERM'); // pede com jeito setTimeout(() => { killGroup('SIGKILL'); // depois força, 5s depois sweep(); process.exit(124); // código convencional de "deu timeout" }, 5_000); }, TIMEOUT_MS); timer.unref(); // não mantenha o event loop vivo só pelo timer
Se um worker reparentou ao PID 1 antes do kill-do-grupo, ele não está mais no grupo — o kill-do-grupo não o alcança. A varredura de último recurso alcança, espelhando a rede manual do operador pgrep -f vitest | kill -9:
// scripts/safe-test.mjs:24-32 const sweep = () => { try { execFileSync('pkill', ['-9', '-f', 'vitest'], { stdio: 'ignore' }); } catch { /* nada para matar — pkill sai com não-zero quando não há match */ } };
A varredura roda em todo caminho de saída — saída normal, sinal e timeout — para que o vazamento "não acumule" (safe-test.mjs:80). Numa saída limpa o wrapper ainda chama killGroup('SIGKILL') para "ceifar qualquer fork ainda persistindo no grupo", depois varre. Cinto e suspensório, porque o custo de um vazamento é horas de CPU fixada.
| Camada | Pega | Deixa passar (entrega à próxima) |
|---|---|---|
| timeouts da config + forks | a maioria dos travamentos — falham rápido e o Vitest mata o fork à força | um pai morto externamente no meio; um handle nativo que o Vitest não consegue ceifar |
| kill-do-grupo (detached) | a árvore viva inteira numa syscall | um worker que já reparentou ao PID 1 antes do kill |
| varredura pkill | qualquer vitest perdido por nome, incluindo órfãos do PID 1 | — (o piso) |
O que cada camada deixa passar é o trabalho da próxima. É defesa em profundidade: nenhum mecanismo único é confiado a ser perfeito, e o modo de falha (um core fixado por horas) é severo o bastante para justificar a redundância.
safe-test.mjs cria a suíte com detached:true?detached:true ⇒ setsid ⇒ o filho é um líder de grupo cujo PID é o id do grupo. Um PID negativo em kill endereça o grupo inteiro, então uma syscall alcança toda a árvore de workers — exatamente o que um kill(pid) simples não consegue.kill(-pgid) não o alcança. A varredura baseada em nome é precisamente a rede de último recurso para esse caso, e roda em todo caminho de saída.testTimeout/teardownTimeout limitados e usar pool:'forks' em vez de confiar só no wrapper que mata o grupo?kill(pid) mata os filhos também." Não — ele sinaliza um processo. Filhos sobrevivem e reparentam ao PID 1. Você precisa da forma de grupo de processo (kill(-pgid)) para alcançar a árvore, que é exatamente por que detached:true existe no wrapper.pgrep -f vitest vazio.