O vitest-orphan-leak
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 a lição é mais larga que o bug: corrija a interação entre as causas, não só uma delas, e prove na fronteira do sistema operacional, não no "deveria funcionar".
alembic-vitest-orphan-leak — o registro do incidente
Esta lição destila esse registro + scripts/safe-test.mjs, vitest.config.ts e .factory/run.ts. Todos os números têm fonte (rodapé). Por que importa pra missão: a fusão Alembic × Hermes roda loops de teste automatizados (o .factory); um vazamento de processo aqui derruba a máquina que constrói tudo.
- Explicar por que nenhuma das duas causas (hang + orfanamento) produz o vazamento sozinha.
- Mapear a correção em duas camadas — uma por causa — e por que um patch único seria frágil.
- Ler o kill por PID negativo (grupo de processos) e por que
detached:trueé o que o habilita. - Aplicar a disciplina do Proof Gate: a tabela de processos vazia é a prova, não o "verde".
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.
01 · O incidente, peça por peça
Antes do "por quê", veja exatamente o que foi encontrado. Cada número aqui é um sintoma diagnóstico — e juntos eles apontam para uma assinatura específica: processos que perderam o pai e não vão morrer sozinhos.
02 · As duas causas — nenhuma fatal sozinha
Este é o coração da lição. O vazamento precisou de ambas verdadeiras ao mesmo tempo. Separadas, cada uma é um aborrecimento que você nota e resolve. Juntas, viram um processo que nada mata e nada cansa.
| Causa | O que é | Sozinha |
|---|---|---|
| 1 · Um teste que trava | Um teste que trava com nenhum teardown — um server/socket/MCP deixado aberto, um setInterval nunca limpo, uma promise não resolvida. | sobrevivível — 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 ao PID 1. | inofensivo — com testes que terminam, os forks saem |
Por que cada causa, sozinha, se cura
Veja as duas linhas do tempo separadas. Em cada uma falta uma metade — e por isso ambas terminam bem. Examples antes da abstração: primeiro o que acontece em cada caso isolado.
A interseção: onde mora o processo imortal
Agora junte. O vazamento não é "causa 1 OU causa 2" — é a interseção das duas. Só onde os dois círculos se cruzam existe um worker que nunca vai terminar sozinho (o hang) e que não tem pai que o ceife (o órfão). É essa célula, e só ela, que produz o processo imortal.
A árvore de processos: antes e depois do pai morrer
Concretamente, eis o que acontece com a árvore de processos. À esquerda, o estado saudável: uma árvore, um pai. À direita, o instante em que o pai é morto sem matar a árvore — e os forks travados reparenteiam ao PID 1.
03 · Preveja o custo de um único loop
Você sabe que 1 worker órfão trava ~1 core a ~95%. Antes de revelar: um loop automatizado de testes que gera 1 worker órfão a cada 2 minutos e roda por 30 minutos — quanta CPU total esses órfãos somam ao final? Chute a ordem de grandeza.
16 × ~95% ≈ 1520–1550%. Foi exatamente o observado: 16 órfãos, ~1550% total, ~11 h depois ainda girando. Se você chutou "uns 200%", subestimou o efeito cumulativo: como nenhum morre sozinho, eles empilham — cada run de teste adiciona mais um motor travado, e nada nunca os desliga. Esse "empilhamento entre runs" é justamente o que a varredura (camada 2) impede.Veja o empilhamento como gráfico: cada barra é um ponto no tempo, e a CPU total só sobe — porque nada nunca desce. É a assinatura visual de um vazamento cumulativo.
04 · 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 só o wrapper e um hang dentro do grupo ainda trava um core até o timeout de relógio. Ambas as camadas são entregues, e cada uma neutraliza um dos dois círculos da interseção.
05 · 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',
O que o timeout faz: hang infinito → falha em 15 s
Sem timeout, um teste travado é uma reta sem fim. Com testTimeout: 15_000, a mesma trava tem um teto: aos 15 segundos o Vitest aborta o teste e o marca como falha — barulhento e finito, em vez de silencioso e eterno.
Por que pool: 'forks' também é segurança (não só performance)
O pool decide como o Vitest isola cada arquivo de teste. Em threads, uma thread travada vive dentro do mesmo processo e é difícil de matar. Em forks, cada arquivo roda num processo filho separado que o Vitest mata à força no teardown — então um arquivo travado não consegue prender um core como uma thread não-matável conseguiria.
threads vs forks · isolamento de um arquivo travado — comparativo06 · 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 */ } };
A peça central: detached:true cria um grupo que um sinal alcança inteiro
A diferença entre matar e não matar os forks está num único sinal de menos. Sem detached:true, process.kill(child.pid, …) atinge só o vitest main e deixa os forks reparentearem — o bug. Com detached:true (que faz setsid, criando um novo grupo), process.kill(-child.pid, …) — PID negativo — sinaliza o grupo inteiro de uma vez.
As três técnicas e a rede tripla
detached:true→ o filho lidera um novo grupo de processos (POSIXsetsid), então um único sinal alcança todo descendente.process.kill(-child.pid, …)→ o PID negativo sinaliza o grupo inteiro, incluindo os forks do tinypool — nunca um PID nu.- A varredura
pkill -9 -f vitest→ pega qualquer coisa que já escapou para o PID 1 (o caso de orfanamento), então o vazamento não pode acumular entre runs. - Um
SAFE_TEST_TIMEOUT_MSrígido (padrão 600000 = 10 min) limita a run inteira; um timeout sai com 124. O mesmo kill+sweep roda no exit normal, emSIGINT/SIGTERM, e em erro de spawn.
As três camadas de defesa formam uma rede em série — cada uma pega o que a anterior pode deixar passar:
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.
07 · O ciclo de vida de um sinal (passo a passo → agora você)
Você viu o PID negativo no diagrama. Agora siga o caminho exato do sinal quando o safe-test.mjs precisa derrubar a suíte — devagar, um passo de cada vez. Recuperar o procedimento (não só ver o resultado) é o que fixa de verdade.
spawn(bin, args, { detached: true }) faz setsid: o filho vira líder de um novo grupo de processos. O PGID passa a ser o PID do filho.killGroup('SIGKILL'). (O mesmo handler roda em SIGINT/SIGTERM e em erro de spawn — não só no timeout.)process.kill(-child.pid, 'SIGKILL') — o menos transforma "este processo" em "todo o grupo": vitest main e todos os forks do tinypool morrem juntos.pkill -9 -f vitest pega qualquer vitest que já tivesse escapado para o PID 1 em runs anteriores. Sem match, pkill sai não-zero — capturado e ignorado.safe-test.mjs com código 124 — o sinal convencional de "estourou o tempo", para o loop automatizado distinguir timeout de falha de teste.kill(-child.pid) rodou, mas um fork já tinha reparenteado ao PID 1 antes do kill (caso de corrida). Esse fork morre no passo 3? Responda antes de revelar.
kill(-child.pid) só alcança quem ainda está no grupo. É exatamente para esse buraco que existe a varredura pkill -9 -f vitest (passo 4) — ela mata por nome do comando, não por grupo, então pega o fugitivo. Lição: o kill de grupo e a varredura não são redundância preguiçosa — cobrem casos diferentes (dentro vs fora do grupo).08 · Fluxograma diagnóstico — "achei processos vitest girando, e agora?"
Junte tudo numa decisão. Diante de processos vitest suspeitos, este fluxograma vai do sintoma à causa e à correção. Siga as setas — cada losango é uma pergunta que escolhe o caminho. O caminho do incidente real está anotado à direita.
09 · Como foi verificado — o Proof Gate
pnpm test:safe → 565 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.
Por que o "verde" sozinho mente aqui? Porque um vazamento é um bug de ciclo de vida de processo, não de correção de lógica. A suíte pode passar 565/565 e ainda assim deixar um worker girando. As duas checagens medem coisas diferentes:
E o safe-test.mjs deixa o veredito legível por código de saída, para o loop automatizado decidir o que fazer sem ler a saída inteira: 0 = passou, 1 = teste falhou, 124 = estourou o tempo. Três destinos, três decisões diferentes.
10 · Patch único vs duas camadas — o trade-off lado a lado
A tentação é corrigir "a" causa e seguir em frente. Veja por que isso falha: cada patch único deixa metade do buraco aberto. Primeiro em linguagem simples, depois o detalhe técnico.
testTimeout disparar ainda orfaniza os forks → vaza. Só a camada 2 (grupo + kill + sweep): um hang dentro do grupo ainda prende um core até o SAFE_TEST_TIMEOUT_MS (até 10 min) antes do kill chegar. As duas juntas: o hang vira falha rápida e o orfanamento é impossível — os destinos de falha não se sobrepõem, então nenhum deixa o buraco da outra aberto.| Abordagem | Cobre o hang? | Cobre o orfanamento? | Buraco que resta |
|---|---|---|---|
| Só timeout maior | ✗ piora (demora mais a falhar) | ✗ não | tudo — não ataca nenhuma causa de raiz |
| Só camada 1 (timeouts + forks) | ✓ sim | ✗ não | pai morto antes do timeout → órfão vaza |
| Só camada 2 (grupo + kill + sweep) | ✗ não | ✓ sim | hang prende um core até o timeout de relógio |
| As duas camadas | ✓ sim | ✓ sim | nenhum — defesa em profundidade |
11 · As regras de operação que decorreram disso
| Regra | Por 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". |
12 · Confusões comuns
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.pkill." Não — cobrem casos diferentes. kill(-pid) alcança quem ainda está no grupo; pkill -f vitest alcança quem já escapou para o PID 1. Tirar um deixa um buraco real.13 · Como isso se encaixa
Esta lição não é um bug isolado — é uma peça de infraestrutura de teste que sustenta a máquina inteira. O Alembic constrói tudo rodando loops de teste automatizados: um orquestrador gera builders, cada builder roda a suíte, e o veredito (verde + tabela de processos limpa) alimenta o próximo passo. O safe-test.mjs é a engrenagem que garante que esse loop nunca se envenene a si mesmo com um worker imortal. Veja onde ela mora no fluxo real:
Clique para acender cada etapa em sequência e seguir o caminho do veredito.
Onde você está na metodologia. O Alembic é uma máquina que se constrói rodando testes em loop (o .factory e o swarm geram builders que rodam pnpm test:safe a cada unidade). Esta lição é a garantia de higiene de processo dessa máquina: sem ela, um único teste travado num loop derruba a CPU inteira e congela toda a fábrica. É infraestrutura silenciosa — você só nota quando falta. Para ver o quadro inteiro em movimento, abra o hub do curso e a galeria de blocos e demos. [uncertain] o curso não tem um arquivo metodologia.html dedicado; o mapa navegável vive no hub (index.html) + na galeria.
- Lição 25 · Test-safety — a engenharia de segurança de teste de onde este wrapper nasceu; esta lição é o estudo de caso, a 25 é a disciplina geral.
- Lição 05 · Ports & injeção — os testes que o
test:safeprotege exercitam ports injetados; é o que torna a suíte determinística e rápida de rodar em loop. - Lição 19 · O swarm — o orquestrador a montante: ele gera os subprocessos cuja árvore precisa ser
detached:true+ morta por PID negativo — a regra generalizada desta lição. - Lição 17 · A pipeline de gates — a jusante: o Proof Gate consome o veredito (verde +
pgrepvazio) que este wrapper produz; é a disciplina "prove na fronteira real". - Lição 29 · Estendendo a fusão — ao adicionar um pacote novo, você herda esta rede: rode sempre via
pnpm test:safe, nuncapnpm -w testcru num loop.
14 · Na prática
Chega de teoria — rode a correção. A peça desta lição é exatamente um comando: pnpm test:safe. A prova de que ela funcionou também é um comando: pgrep -fl vitest retornando vazio logo depois. Esse par é o Proof Gate do incidente, em duas linhas.
# 1) rode a suíte completa pelo wrapper seguro (grupo destacado + kill de grupo + sweep) pnpm test:safe # … saída do vitest … # Test Files NN passed (NN) # Tests 565 passed (565) # exit 0 → passou · exit 1 → teste vermelho · exit 124 → estourou o tempo # 2) a PROVA: nenhum worker sobreviveu à run (a parte que "verde" sozinho não vê) pgrep -fl vitest # (sem saída) → a tabela de processos está limpa: nada orfanizou. ISSO é a prova.
Se em vez de vazio o pgrep listar PIDs node … vitest ainda girando, você reproduziu a assinatura do incidente — e a rede de operação entra: pgrep -f vitest | xargs -r kill -9. Para conferir o veredito por código de saída diretamente:
# o exit code é o que o loop automatizado lê para decidir (sem ler a saída inteira) pnpm test:safe; echo "exit=$?" # exit=0 → segue o loop # exit=1 → para e corrige o teste vermelho # exit=124 → estourou SAFE_TEST_TIMEOUT_MS (padrão 600000 ms): mata o grupo e caça o hang
cd para a raiz do monorepo Alembic (onde está o package.json com o script test:safe). Confirme: cat package.json | grep test:safe deve mostrar "test:safe": "node scripts/safe-test.mjs".pnpm test:safe. Acompanhe os testes passando — espere a linha Tests 565 passed (o número cresce conforme a fusão ganha pacotes).pgrep -fl vitest. O que procurar: nenhuma saída. Se aparecer qualquer node … vitest, um worker vazou — o que esta lição existe para impedir.echo "exit=$?" imediatamente após o passo 2 (antes de qualquer outro comando). 0 = passou; 1 = teste vermelho; 124 = timeout. É esse número que o loop fechado (Lição 17) consome.pnpm -w test? O comando cru roda a suíte (vitest run) sem o kill de grupo + sweep. Funciona uma vez à mão; num loop, um único hang reproduz o vazamento de 16 órfãos. A regra da Lição 25: em loop ou entregue a um builder, sempre pnpm test:safe.test:safe protege a etapa de teste; toda mudança de código no Alembic precisa manter verde a tríade completa: pnpm -r typecheck && pnpm -r build && pnpm -w test. Num loop automatizado, troque o último por pnpm test:safe — é a mesma suíte, mas com a rede anti-órfão. [uncertain] os números de saída do pnpm test:safe acima são ilustrativos do formato do vitest; o número exato de testes/arquivos varia com o estado do repositório — o invariante real é verde + pgrep vazio, não um total fixo.Fixe os conceitos (flashcards)
Clique pra virar. Tente lembrar a resposta antes de virar — recuperação ativa fixa mais que reler.
testTimeout/hookTimeout/teardownTimeout + pool:'forks' em vitest.config.ts: o hang vira falha em 15 s; o fork travado é morto no teardown.process.kill(-child.pid, …) sinaliza o grupo inteiro (habilitado por detached:true/setsid), alcançando os forks do tinypool — não só o vitest main.pnpm test:safe + pgrep -f vitest vazio depois. O verde prova a lógica; a tabela vazia prova que nada orfanizou.Revisão cumulativa — recupere de memória
Antes de clicar: responda de cabeça. As opções têm o mesmo tamanho de propósito — sem pista pela forma.
process.kill(-child.pid, 'SIGKILL') faz que process.kill(child.pid, …) não faria?detached:true (seu próprio grupo via setsid), um PID negativo alcança todo descendente. a é falso: o menos é semântica POSIX real (grupo), não ruído — o Node o repassa ao kill(2). c inventa um "duplo SIGKILL"; o menos é sobre alcance, não repetição. d inverte a direção: o negativo desce para o grupo de descendentes, não sobe para o shell. Um PID nu mataria só o vitest main e deixaria os forks reparentearem — exatamente o bug.vitest run via test:safe, não watch. c está invertido: um timeout vira o teste vermelho (falha visível), não mascara nada — mascarar seria o hang infinito de antes da correção.setsid e não só matar o filho?", "Como reproduzo o leak de propósito num teste?", "O .factory/run.ts faz o sweep antes ou depois da iteração?". É só dizer.