Lição 6 · Curso de Fusão · Parte 1 · O motor · O vazamento de órfãos do Vitest
Alembic × Hermes · O Curso de Fusão · Estudo de caso de engenharia

O vazamento de órfãos do Vitest

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.

Leia primeiro (fonte primária)
Memória do projeto 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.

Leia a versão simples, ou abra a camada técnica em qualquer seção.
O que você vai conseguir fazer
  • Explicar por que nenhuma das duas causas — um teste pendurado e um pai morto sem matar a árvore — produz o vazamento sozinha.
  • Descrever a correção em duas camadas (timeouts + pool:'forks' em vitest.config.ts; detached:true + kill de grupo + sweep em safe-test.mjs) e dizer qual camada ataca qual causa.
  • Dizer por que process.kill(-child.pid, …) com PID negativo é o detalhe que mata todos os tinypool forks.
  • Justificar por que "565 tests green" não é prova suficiente — e por que pgrep -f vitest vazio é o que prova de verdade.
Suposições tolas (o que presumimos de você — bem pouco)
  • Você já rodou uma suíte de testes na linha de comando e sabe que ela "passa" ou "falha".
  • Você sabe que um programa pode iniciar outros programas (processos-filho). Não precisa saber POSIX — a gente constrói os termos.
  • Você não precisa saber o que é setsid, tinypool ou process group. Tudo isso é explicado do zero aqui.
1

O incidente — 2026-06-23


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.

O incidente

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.

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

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.

~30 min de execuções repetidas de pnpm test → órfãos acumulam +1 a cada ~2 min 16º ~11h girando a ~90–96% CPU nenhum sai por conta própria
Cada execução deixou um worker para trás; em ~30 min, 16 deles — e depois apenas girando.
load average — normal vs incidente normal ~2 incidente 34
Um load average de 34 numa máquina que fica abaixo de 2: dezenas de tarefas brigando pela CPU ao mesmo tempo.

Por baixo do capô

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.

2

As duas causas — nenhuma fatal sozinha


Este é o coração da lição. O vazamento precisou que as duas coisas fossem verdade ao mesmo tempo:

CausaO que éSozinha
1 · Um teste penduradoUm 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 á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 para PID 1 e continuam girando.Com testes que saem: inofensivo.
Causa 1 · hang sem teardown teste roda… socket/MCP aberto · setInterval nunca resolve → pendura ∞
Sem timeout, "pendura" é infinito.
Causa 2 · pai morto, árvore não pai ✗ morto PID 1 fork fork fork reparented → ninguém os reapeia
O tinypool sobrevive ao pai e é adotado pelo init.
a tabela-verdade do vazamento pai morto: NÃO pai morto: SIM hang: NÃO hang: SIM tudo bemsai normal sobrevivívelórfão que sai sobrevivívelCtrl-C resolve IMORTALtrava a CPU ∞
Três das quatro células são sobrevivíveis. Só a interseção (hang SIM × pai morto SIM) é imortal.
Jargão, traduzidotinypool é o pool de workers que o Vitest usa para rodar arquivos de teste em paralelo, em processos separados. Reapear (reap) é o pai recolher o filho que terminou; se o pai morre antes, ninguém recolhe — e o filho vira órfão.
3

A interação — onde mora o bug


Preveja antes de revelar

Cada causa, sozinha, é sobrevivível. Então por que a combinação das duas produz um processo que nunca morre? Pense um instante.

Porque o processo imortal é a interseção: um worker que nunca vai sair por conta própria (o hang) e que está destacado de qualquer pai que o reapearia (o órfão). Um hang que você mata limpo leva os workers junto; um órfão que ia sair de qualquer jeito sai. Só o hang + o órfão nunca morre.
Por que "qualquer uma sozinha é sobrevivível" importa. Um teste pendurado que você mata de forma limpa leva os workers junto. Um worker órfão que ia sair de qualquer jeito sai. É a combinação — um worker que nunca vai sair sozinho, destacado de qualquer pai que o recolheria — que produz um processo imortal travando a CPU. Correções robustas miram a interação.
Diagrama de Venn de duas causas — teste pendurado e pai morto sem matar a árvore — cuja interseção em vermelho é o processo imortal que trava a CPU; faixa de telemetria do incidente real com 16 workers, ~1550% CPU, ~11h

A interseção das duas causas sobrevivíveis é o estado imortal. Telemetria: o incidente real de 2026-06-23.

ANTES (pai vivo): pnpm test (pai) vitest main fork fork fork uma árvore — matar o pai recolhe todos DEPOIS (pai morto, árvore não): pai ✗ morto PID 1 (init) fork 96% fork 94% fork 90% reparented para PID 1 — ninguém os reapeia + eles penduram → giram para sempre
Antes: uma árvore, matar o pai recolhe tudo. Depois: forks adotados pelo init, sem reaper — e penduram.
kill LIMPO (a árvore toda) pai ✗ todos descem juntos kill SÓ o pai (PID nu) pai ✗ vive vive vive forks reparenteiam → órfãos
O mesmo "matar" tem dois resultados opostos: depende de você mirar a árvore (limpo) ou só o pai (deixa órfãos).
Retrieval
Por que o órfão tem PPID = 1?
clique para virar
Quando o pai morre sem reapear os filhos, o init (PID 1) os adota. PPID 1 é a assinatura de um processo que perdeu o pai.
Retrieval
O que torna o processo imortal?
clique para virar
A interseção: um hang (nunca sai sozinho) + órfão (sem pai que o reape). Tira uma das duas e o processo eventualmente morre.
Retrieval
O que é tinypool?
clique para virar
O pool de workers do Vitest que roda arquivos de teste em processos-filho paralelos. São esses forks que reparenteiam quando o pai morre cedo.
4

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


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.

Infográfico em camadas da correção: camada 1 (vitest.config.ts com testTimeout/hookTimeout/teardownTimeout/pool forks) anti-hang; camada 2 (safe-test.mjs com detached true, process.kill PID negativo e pkill sweep) anti-órfão; coluna Proof Gate com pnpm test:safe 565 green e pgrep vazio

Duas camadas, uma por causa, mais o Proof Gate: 565 green e pgrep vazio.

Camada 1 — fazer o hang FALHAR, não pendurar (causa 1)

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.

vitest.config.ts
// 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',
com timeout de 15s, o hang vira falha — não infinito teste rodando… 15s FAIL (limitado) pool:'forks' → filho travado é force-killed no teardown
Camada 1 ataca a causa 1: nada pendura "para sempre"; no máximo até o timeout, e o fork some no teardown.
pool: 'forks' (escolhido) proc A proc B ✗ proc C B travado? Vitest força matar só B isolado — não prende o resto pool: 'threads' (contraste) um processo, threads partilhadas thread travada ≈ não-matável pode prender um núcleo
Aqui forks não é só performance: é segurança — um processo é force-killável; uma thread travada, não tanto.
Camada 2 — tornar o órfão IMPOSSÍVEL (causa 2)

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:

scripts/safe-test.mjs — grupo destacado + kill de PID negativo
// 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 */ }
};
kill(-child.pid) → alcança TODO o grupo safe-test (líder) fork fork fork um sinal, PID negativo, mata os três e se um já escapou para PID 1… órfão (PID 1) pkill -9 -f vitest
Camada 2 ataca a causa 2: o PID negativo mata o grupo todo; o sweep cobre o que já tinha escapado para o init.
timeout duro (SAFE_TEST_TIMEOUT_MS, default 600000 = 10 min) SIGTERM (grupo) espera 5s SIGKILL + sweep exit 124
Educado primeiro (SIGTERM), implacável depois (SIGKILL+sweep). O mesmo kill+sweep roda também na saída normal e em SIGINT/SIGTERM.
o que o código de saída te conta 0passou (ecoa o código do teste) 124timeout duro estourou 130SIGINT/SIGTERM (Ctrl-C)
Três códigos, três histórias — úteis num loop para saber se a execução passou, estourou o relógio, ou foi interrompida.
  • 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.
  • Um 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.
Guarde istoUm PID nu (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.
Uma terceira medida de cinto-e-suspensório: .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).

5

Como foi provado — 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 inteira via pnpm test:safe565 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.
pnpm test:safe 565 tests green prova: correção pgrep -f vitest (vazio) prova: nada vazou ✓
São duas provas distintas. A segunda é a que importa para um bug de ciclo de vida de processo.

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

✗ "deveria estar corrigido" uma afirmação — não é prova ✓ fronteira observada pgrep -f vitest → (vazio)
A regra do Proof Gate: substituir a afirmação pela observação no limite real.
Exemplo resolvido — verificar um wrapper anti-órfão
1
Rode a suíte pelo wrapper, nunca cru: pnpm test:safe. Anote o resultado — espere 565 passed.
2
Imediatamente depois, audite a tabela de processos: pgrep -f vitest. Saída vazia = nenhum worker sobreviveu.
3
Só então declare "corrigido". As duas condições juntas são o done-condition; uma sozinha não é.
4
Agora você: simule a causa 2 — rode 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.
6

As regras de operação que sobraram disso


RegraPor 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".
kill(child.pid) — PID nu mata só o pai fork vive fork vive fork vive kill(-child.pid) — PID negativo mata o grupo inteiro
O único caractere de diferença — o sinal de menos — é o que separa "o bug" de "a correção".
CuidadoNas memórias do projeto, pnpm test:safe é obrigatório em loops. A regra existe justamente porque o comando cru já reproduziu o incidente uma vez.
7

Confusões comuns


"É só aumentar o timeout." Um timeout maior faz o hang demorar mais para falhar — não faz nada quanto à orfanação. A correção em duas camadas é deliberada: os timeouts cuidam do hang, o kill-de-grupo+sweep cuidam da orfanação.
"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.
"PID 1 mata os órfãos." Não. O init adota os órfãos (vira o novo pai) e só os recolhe quando eles terminam. Um órfão que pendura nunca termina — por isso ele gira por horas.
8

Recapitulando em 6 slides


O incidente

16 processos imortais

Workers do Vitest com PPID=1, ~90–96% de CPU cada, vivos por ~11h. Load avg 34. Não morreriam sozinhos.

A causa-raiz

Duas causas, nenhuma fatal

1 teste pendurado sem teardown · 2 pai morto sem matar a árvore. Cada uma, sozinha, é sobrevivível.

Onde mora o bug

É a interseção

Imortal = hang (nunca sai) + órfão (sem reaper). Correções robustas miram a interação, não uma causa só.

A correção

Duas camadas

Camada 1: timeouts + pool:'forks' fazem o hang FALHAR. Camada 2: detached:true + kill de PID negativo + sweep tornam o órfão impossível.

A prova

Verde não basta

565 green prova correção; pgrep -f vitest vazio prova que nada vazou. Bug de processo → prova na fronteira do processo.

A generalização

Nunca um PID nu

Todo subprocesso que um orquestrador cria: {detached:true} + matar o PGID negativo + timeout duro. A classe do bug é "uma árvore sobrevivendo ao matador".

1 / 6setas
9

Verifique seu entendimento


Quiz cumulativo

Três perguntas. O placar acompanha seus acertos.

1. Por que nenhuma das causas produziu o vazamento sozinha?
Correto: c. O processo imortal é a interseção: um worker que nunca vai sair sozinho (o hang) destacado de qualquer pai que o reapearia (a orfanação). Por isso a correção tem de atacar a interação — duas camadas, uma por causa. (a) o Vitest não re-spawna assim; (b) o init adota mas só recolhe quando o filho termina; (d) os timeouts foram parte da correção, não pré-existentes.
2. O que process.kill(-child.pid, 'SIGKILL') faz que process.kill(child.pid, …) não faria?
Correto: b. Como o filho foi criado com 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.
3. Por que "os 565 tests passam" não é prova suficiente de que o vazamento foi corrigido?
Correto: d. Verde prova correção; a tabela de processos vazia prova que nenhum worker sobreviveu à execução. O vazamento é um bug de ciclo de vida de processo, então a prova tem de ser observada na fronteira do processo — a disciplina do Proof Gate.
Acertos: 0/3
Recapitulando em uma linha: o vazamento foi a interseção de um hang e um órfão; a correção tem duas camadas (timeouts+forks; detached+kill-de-grupo+sweep); e a prova é verde + pgrep vazio, observada na fronteira do processo.