Curso / Lição 25  ·  EN
Lição 25 · Avançado · aprofunda a Lição 06

Engenharia de segurança de testes: o kill que não erra

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 problema raiz: órfãos escapam do pai

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.

kill(pid) — só o pai pai ✗ morto worker ⟳ worker ⟳ → workers reparentam ao PID 1, seguem fixando cores kill(-pgid) — o grupo todo grupo de processo (um líder setsid) pai ✗ worker ✗ worker ✗ → o PID negativo endereça todo membro de uma vez; nada sobrevive defesa em profundidade = config (falha no timeout) + kill-do-grupo + varredura (pega o fugitivo) pkill -9 -f vitest — último recurso para o que já escapou ao PID 1

Camada 1 — faça um travamento FALHAR em vez de travar (vitest.config.ts)

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.

Camada 2 — o wrapper que possui um grupo de processo

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 */
  }
};
A ideia para internalizar: um PID negativo é um grupo de processo

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".

O hard timeout: SIGTERM, depois SIGKILL, depois varredura

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

Camada 3 — a varredura: pega o que já escapou

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.

Por que três camadas e não uma

CamadaPegaDeixa passar (entrega à próxima)
timeouts da config + forksa maioria dos travamentos — falham rápido e o Vitest mata o fork à forçaum pai morto externamente no meio; um handle nativo que o Vitest não consegue ceifar
kill-do-grupo (detached)a árvore viva inteira numa syscallum worker que reparentou ao PID 1 antes do kill
varredura pkillqualquer 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.

1. Por que safe-test.mjs cria a suíte com detached:true?
Correto: b. detached:truesetsid ⇒ 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.
2. Um worker reparenta ao PID 1 antes do kill-do-grupo disparar. Qual camada o pega?
Correto: d. Uma vez reparentado ao PID 1, o worker está fora do grupo de processo original, então 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.
3. Por que definir testTimeout/teardownTimeout limitados e usar pool:'forks' em vez de confiar só no wrapper que mata o grupo?
Correto: c. Defesa em profundidade começa a montante: previne o travamento de fixar qualquer coisa falhando rápido num fork isolado. O wrapper + varredura são os backstops para os casos que a config não cobre (kill externo no meio, handles nativos).

Confusões comuns

"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.
"A varredura é exagero se o kill-do-grupo funciona." O kill-do-grupo não alcança um processo que já saiu do grupo (reparentou ao PID 1). A varredura não é redundante — cobre um caso que o kill-do-grupo estruturalmente não consegue. A verificação (Lição 6) confirma ambos: 565 verdes e pgrep -f vitest vazio.