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

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

Fonte primária (no repositório)
Memória de projeto 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.

Objetivos desta lição
  • 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".
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.

0
workers órfãos (PPID=1)
~0%
CPU total (load avg 34)
~0h
vivos, girando
0
testes verdes na verificação

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.

A ASSINATURA DO INCIDENTE · cada caixa é um sintoma observado no host
16 workers node (vitest) PPID = 1 reparentados ao init 90–96% CPU cada · girando ~11 h vivos, sem terminar Leitura: PPID=1 diz que perderam o pai (orfanados); ~95% por 11 h diz que não estão esperando nada — estão presos num loop quente. Um órfão e travado: a combinação imortal. 16 × ~95% ≈ ~1550% de CPU total (load avg 34) — a máquina inteira saturada por uma suíte de testes que já "terminou".
Em linguagem simples: imagine 16 motores de carro ligados a fundo numa garagem trancada, sem motorista e sem chave. Ninguém os desliga porque o dono (o processo pai) foi embora, e eles não desligam sozinhos porque o acelerador travou no fundo. É exatamente isso — só que com CPU em vez de gasolina.

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.

CausaO que éSozinha
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.sobrevivível — 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 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.

SÓ A CAUSA 1 (hang, mas o pai está vivo)
teste trava (hang) você dá Ctrl-C ✓ árvore ceifada pai vivo = um sinal alcança todos os filhos
SÓ A CAUSA 2 (órfão, mas os testes terminam)
pai morto → órfãos testes terminam ✓ forks saem sós processo que ia terminar termina, órfão ou não

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 INTERSEÇÃO · hang ∩ órfão = processo imortal (o diagrama da causa-raiz)
CAUSA 1 teste trava (nunca termina sozinho) só isto → Ctrl-C resolve CAUSA 2 órfão (PPID=1, ninguém o ceifa) só isto → sai sozinho IMORTAL ~95% CPU 11 h Correções robustas atacam a interação (a interseção), não um círculo isolado.
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. Por isso a correção precisa de duas camadas: uma para cada círculo.

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.

ÁRVORE DE PROCESSOS · ANTES (pai vivo) vs DEPOIS (pai morto, árvore não) — comparativo
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

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.

Preveja antes de continuar
Um worker órfão ≈ 1 core a ~95%. O loop gera 1 a cada 2 min por 30 min. Quanta CPU total no fim?
≈ 1550% de CPU (load avg 34). A conta: 30 min ÷ 2 min = ~15–16 workers acumulados, cada um a ~95% → 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.

EMPILHAMENTO CUMULATIVO · CPU total ao longo de ~30 min (1 órfão a cada 2 min)
~1550% (saturação observada) 0% 1550% 2' 6' 10' 14' 18' 22' 26' ~30' Monotônico = nada decresce. Um sistema saudável serrilharia (sobe na run, cai no fim); este só sobe — o leak.

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.

MAPEAMENTO CAUSA → CAMADA · cada camada tampa um círculo da interseção
CAUSA 1 · hang nunca termina sozinho tampa → CAMADA 1 · vitest.config.ts timeouts limitados + pool 'forks' hang vira FALHA limitada CAUSA 2 · órfão ninguém o ceifa tampa → CAMADA 2 · safe-test.mjs grupo destacado + kill(-pid) + sweep orfanamento vira IMPOSSÍVEL Defesa em profundidade: se um hang escapa da camada 1, a camada 2 ainda impede o órfão imortal — e vice-versa.

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.

SEM TIMEOUT vs COM TIMEOUT · o mesmo hang, dois destinos — comparativo
SEM TIMEOUT (o bug) teste girando… ∞ nunca falha, nunca termina COM testTimeout: 15_000 (a correção) ✗ FALHA aos 15 s girando… finito e barulhento → você corrige Um hang que vira falha é um bug visível; um hang sem teto é um worker invisível travando um core.

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 — comparativo
pool: 'threads' um processo, várias threads thread ok threadTRAVADA thread ok trava dentro do processo — difícil de matar ✗ pool: 'forks' filho ok filhoTRAVADO filho ok processo isolado → Vitest mata à força no teardown ✓ cada arquivo é um processo independente
Em uma frase: a camada 1 garante que um hang vire um problema finito. Mas ela não resolve o orfanamento — se o pai for morto antes do timeout disparar, o fork ainda reparenteia. Por isso existe a camada 2.

06 · 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.

PID NU vs PID NEGATIVO · o que cada kill alcança — o comparativo que define a correção
kill(child.pid) — PID nu vitest main ✗ morto fork ✓vivo fork ✓vivo fork ✓vivo forks sobrevivem → reparenteiam ao PID 1 EXATAMENTE o bug ✗ kill(-child.pid) — PID negativo vitest main ✗ morto fork ✗ fork ✗ fork ✗ um sinal alcança o grupo inteiro nada orfaniza ✓ A única diferença é o sinal de menos — habilitado por detached:true (setsid).

As três técnicas e a rede tripla

As três camadas de defesa formam uma rede em série — cada uma pega o que a anterior pode deixar passar:

A REDE TRIPLA DO safe-test.mjs · três redes em série (pipeline)
1 · TIMEOUT rígido SAFE_TEST_TIMEOUT_MS → exit 124 2 · KILL de GRUPO process.kill(-pid, SIGKILL) 3 · SWEEP (último recurso) pkill -9 -f vitest Em série: o timeout garante que a run acaba; o kill de grupo derruba a árvore viva; o sweep pega qualquer órfão de uma run anterior. Para vazar, um worker teria que furar as três — improvável por construção.

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.

O CAMINHO DO SINAL · de setsid ao grupo morto (a sequência que o worked descreve)
spawn(detached)→ novo grupo timeout / exit→ killGroup() kill(-pid, SIGKILL)→ grupo inteiro ✗ sweep pkill -9→ nada vazou ✓ Esse é o caminho que o exemplo resolvido abaixo percorre, passo a passo.
Exemplo resolvido · o que acontece quando o SAFE_TEST_TIMEOUT_MS estoura
1
O spawn cria o grupo. 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.
2
O relógio dispara. Aos 600000 ms (10 min) o timer rígido chama killGroup('SIGKILL'). (O mesmo handler roda em SIGINT/SIGTERM e em erro de spawn — não só no timeout.)
3
O PID negativo alcança o grupo. process.kill(-child.pid, 'SIGKILL') — o menos transforma "este processo" em "todo o grupo": vitest main e todos os forks do tinypool morrem juntos.
4
A varredura fecha a conta. 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.
5
O processo sai com 124. Um timeout encerra o safe-test.mjs com código 124 — o sinal convencional de "estourou o tempo", para o loop automatizado distinguir timeout de falha de teste.
Agora você: suponha que o 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.
Não no passo 3 — sim no passo 4. Um fork que já saiu do grupo (reparenteado ao PID 1) não recebe mais o sinal do grupo: 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.

FLUXOGRAMA · SINTOMA → CAUSA → CORREÇÃO (o caminho do incidente destacado)
processos vitest girando pgrep -f vitest → não-vazio PPID = 1? (reparenteado) NÃO ainda na árvore → Ctrl-C resolve SIM (órfão) CPU alta por muito tempo? (hang) NÃO → órfão ocioso (sai sozinho; sweep limpa) SIM INTERSEÇÃO: hang ∩ órfão o processo imortal — o incidente ✓ CORREÇÃO: as DUAS camadas C1: timeouts + forks · C2: grupo + kill(-pid) + sweep + rodar via pnpm test:safe daqui pra frente caminho do incidente: PPID=1 (sim) → CPU ~95% por 11 h (sim) → interseção → duas camadas + test:safe
Por que o losango "PPID=1" primeiro: ele separa órfão de não-órfão. Um processo ainda preso à árvore (PPID ≠ 1) você resolve com Ctrl-C no pai. Só um órfão exige o kill de grupo / a varredura — porque o pai que o ceifaria já se foi.
Por que o segundo losango "CPU alta": separa hang de ocioso. Um órfão ocioso some sozinho (e o sweep o pega de qualquer jeito). É o órfão travado a ~95% que é o problema caro — a célula da interseção.

09 · 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.

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:

DUAS CHECAGENS, DUAS PERGUNTAS · só verde vs verde + pgrep vazio — comparativo
só "565 verdes" ✓ a lógica está correta? ? nenhum worker sobrou? (não vê) prova só metade — pode passar e vazar verde + pgrep vazio ✓ a lógica está correta? ✓ nenhum worker sobrou? prova inteira → na fronteira do SO A prova tem que ser observada onde o bug vive: na tabela de processos.

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.

CÓDIGO DE SAÍDA DO safe-test.mjs · o que o loop lê para decidir
exit 0 565 passaram → segue o loop exit 1 teste falhou (visível) → para e corrige exit 124 estourou o tempo → mata grupo + investiga hang Distinguir 1 de 124 importa: "teste vermelho" e "travou" pedem ações opostas — corrigir o teste vs caçar o hang.

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.

Em linguagem simples: é como uma casa com dois pontos de entrada. Trancar só a porta (timeouts) ainda deixa a janela (orfanamento) aberta; trancar só a janela (kill de grupo) ainda deixa a porta (um hang dentro do grupo prendendo um core até o relógio) aberta. Você tranca as duas — é o que "defesa em profundidade" significa.
No detalhe técnico: só a camada 1 (timeouts + forks): um pai morto antes do 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.
AbordagemCobre o hang?Cobre o orfanamento?Buraco que resta
Só timeout maior✗ piora (demora mais a falhar)✗ nãotudo — não ataca nenhuma causa de raiz
Só camada 1 (timeouts + forks)✓ sim✗ nãopai morto antes do timeout → órfão vaza
Só camada 2 (grupo + kill + sweep)✗ não✓ simhang prende um core até o timeout de relógio
As duas camadas✓ sim✓ simnenhum — defesa em profundidade

11 · 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".
A CLASSE GENERALIZADA · "árvore filha que sobrevive a quem a mata" (o padrão a evitar)
orquestrador gera um subprocesso sem grupo + kill por PID nu árvore filha sobrevive → órfãos (a classe do bug) regra geral detached:true + kill(-pid) + timeout rígido vale p/ swarm, .factory, qualquer spawn A correção do vitest é um caso particular de uma regra que o Alembic aplica a todo subprocesso que orquestra.

12 · 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.
"O PID negativo é uma redundância do 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:

ONDE ESTA PEÇA VIVE · do orquestrador ao loop fechado (a engrenagem destacada)

Clique para acender cada etapa em sequência e seguir o caminho do veredito.

orquestrador / loop gera uma árvore de teste ESTA PEÇA · test:safe grupo destacado + kill(-pid) + sweep + timeout rígido Proof Gate verde + pgrep vazio veredito por exit code 0 segue · 1 corrige · 124 caça hang o loop fechado segue para a próxima unidade ↺ ↑ a montante quem gera os processos ↑ esta lição impede o worker imortal ↓ a jusante o que confia no veredito A montante: o swarm / o loop que gera a árvore. Esta peça: a contenção que a torna matável. A jusante: o Proof Gate e o loop fechado confiam que cada run termina limpa — senão a CPU satura e nada avança.

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.

A que esta peça se conecta
  • Lição 25 · Test-safetya 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çãoos testes que o test:safe protege exercitam ports injetados; é o que torna a suíte determinística e rápida de rodar em loop.
  • Lição 19 · O swarmo 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 gatesa jusante: o Proof Gate consome o veredito (verde + pgrep vazio) que este wrapper produz; é a disciplina "prove na fronteira real".
  • Lição 29 · Estendendo a fusãoao adicionar um pacote novo, você herda esta rede: rode sempre via pnpm test:safe, nunca pnpm -w test cru 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
Experimente · prove a higiene de processo na sua máquina
1
Entre no repositório. 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".
2
Rode a suíte pelo wrapper. pnpm test:safe. Acompanhe os testes passando — espere a linha Tests 565 passed (o número cresce conforme a fusão ganha pacotes).
3
Logo depois, verifique a tabela de processos. 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.
4
Leia o veredito. 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.
Por que não 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.
Linha de base de build (o contexto maior). O 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.

Causa-raiz
Por que nenhuma causa vaza sozinha?
clique pra virar ↻
Resposta
O imortal é a interseção: hang (nunca termina) ∩ órfão (ninguém ceifa). Hang com pai vivo → Ctrl-C; órfão que ia sair → sai.
Camada 1
O que torna um hang finito?
clique pra virar ↻
Resposta
testTimeout/hookTimeout/teardownTimeout + pool:'forks' em vitest.config.ts: o hang vira falha em 15 s; o fork travado é morto no teardown.
Camada 2
O que o PID negativo faz?
clique pra virar ↻
Resposta
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.
Proof Gate
Qual é a prova de que o leak sumiu?
clique pra virar ↻
Resposta
565 verdes via 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.

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) — duas camadas, uma por causa. a inventa um retry automático que não existe e não teria a ver com orfanamento. b é o oposto da verdade: o PID 1 adota órfãos e os mantém vivos; ele não ceifa um processo que está girando. d erra a premissa — não havia timeout limitado antes da correção; é justamente o que a camada 1 acrescentou.
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. 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.
3. Por que "os 565 testes passam" não é considerado prova suficiente de que o vazamento está corrigido?
Correto: d. Verde prova correção da lógica; 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 — a disciplina do Proof Gate. a confunde quantidade com tipo de evidência: mais testes não veem processos remanescentes. b descreve uma má prática (watch), mas a verificação usa 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.
💬 Travou em algo? Eu sou seu professor neste curso — pergunte. "Por que 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.