Engenharia de test-safety: 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 três camadas — um vitest.config.ts endurecido, mais um wrapper scripts/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.
scripts/safe-test.mjs · vitest.config.ts
Esta lição destila o código real do repositório, lido literalmente (rodapé com as linhas exatas). Por que importa pra missão: a fábrica de software roda pnpm -w test em loops AFK; um único worker órfão fixando um core por horas degrada toda execução autônoma. O test:safe é o que mantém o loop seguro de rodar repetidamente.
- Explicar por que
kill(pid)deixa filhos vivos — e como eles reparentam ao PID 1. - Descrever o que
detached:truefaz (setsid→ novo grupo de processo → líder de grupo). - Diferenciar
process.kill(pid)deprocess.kill(-pgid)e por que o PID negativo "não erra". - Mapear as três camadas de defesa e o que cada uma pega e deixa passar para a próxima.
01 · O problema raiz: órfãos escapam do pai
O tinypool do Vitest roda testes em processos filhos (workers). Imagine um chefe (o processo pai) com vários funcionários (os workers). Se você demite só o chefe, os funcionários não somem — eles continuam trabalhando, agora sem ninguém para encerrá-los. É exatamente o que acontece num SO POSIX: 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.
scripts/safe-test.mjs:7-10. As duas condições (a) e (b) são o problema inteiro; as três camadas atacam cada uma.A consequência crucial: matar o processo pai não basta. Você tem que matar a árvore inteira — e uma árvore que já reparentou ao PID 1 não é mais alcançável a partir do pai, porque o vínculo pai→filho que você usaria para alcançá-la deixou de existir.
top — quase dezesseis cores saturados. Se você chutou "uns 100%", subestimou: o vazamento não é um processo travado, são dezesseis, e por isso o custo é "hours of pinned CPU" (safe-test.mjs:9). É essa severidade que justifica três camadas de defesa, não uma.02 · Anatomia de um grupo de processo (a estrutura que salva)
Em linguagem simples: um grupo de processo é como um sobrenome de família. Em vez de chamar cada pessoa pelo nome (um PID de cada vez), você chama "a família Silva" e todos respondem. No POSIX, esse "sobrenome" é o PGID (process group id), e existe uma forma de sinalizar a família inteira numa única chamada — desde que todos os workers compartilhem o mesmo PGID.
detached:true dá ao filho um sobrenome novo e o torna o chefe dessa nova família — e é esse sobrenome que vamos usar para chamar todos de uma vez.setsid() cria uma nova sessão e um novo grupo de processo cujo líder é o chamador — seu PID == PGID. Filhos criados depois herdam esse PGID. O Node expõe isso via a opção detached:true do spawn (comentário do código: "detached:true => POSIX setsid", safe-test.mjs:34). Sinais enviados a um PID negativo são entregues a todo processo cujo PGID seja o valor absoluto desse número.03 · A ideia central: kill(pid) vs kill(-pgid)
Aqui está o coração da lição, e cabe numa frase: um PID negativo é um grupo de processo. No POSIX, kill(pid, sig) sinaliza um processo; kill(-pgid, sig) sinaliza todo processo daquele grupo. Como detached:true tornou o filho um líder de grupo (PID == PGID), process.kill(-child.pid, …) alcança o vitest principal e todo fork tinypool numa única syscall.
Veja a mesma ideia no código real do wrapper. O killGroup sempre usa -child.pid (negativo); o try/catch apenas absorve o caso em que o grupo já se foi:
// scripts/safe-test.mjs:38-44 const killGroup = (signal) => { try { process.kill(-child.pid, signal); // PID NEGATIVO = o grupo inteiro } catch { /* group already gone */ } };
Mas qual sinal mandar para o grupo? O wrapper usa dois, em ordem, e a diferença entre eles é a razão da ordem: SIGTERM pode ser capturado (um processo bem-comportado faz teardown ao recebê-lo); SIGKILL não pode ser capturado nem ignorado — o kernel encerra o processo na hora. Pedir com SIGTERM primeiro dá chance de limpeza; forçar com SIGKILL depois garante a morte de quem ignorou.
É exatamente a sequência das linhas 52-56 do safe-test.mjs: killGroup('SIGTERM'), depois — 5 s adiante — killGroup('SIGKILL'). O primeiro respeita quem ainda pode limpar; o segundo não negocia com quem travou.
04 · Camada 1 — faça um travamento FALHAR em vez de travar
A defesa em profundidade começa a montante: a melhor forma de não vazar é o travamento nunca acontecer. A config compartilhada do Vitest 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:20-27 — o endurecimento anti-órfão // 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',
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 "Vitest force-kills on teardown, so a stuck file cannot pin a CPU core" (vitest.config.ts:22-23). Os timeouts transformam uma espera infinita numa falha de teste: visível, limitada e vermelha no CI.
Por que 'forks' isola e 'threads' (o padrão) não? Uma thread divide o mesmo espaço de memória e o mesmo event loop do processo host — um loop preso numa thread pode emperrar o host inteiro. Um fork é um processo de SO separado, com memória e event loop próprios; quando ele trava, o host continua são e pode matá-lo à força no teardown. É a fronteira de isolamento que torna a camada 1 confiável:
05 · Camada 2 — o wrapper que possui um grupo de processo
A config sozinha não cobre toda fuga: um handle nativo que o Vitest não consegue ceifar, ou um pai morto com SIGKILL no meio da execução (um Ctrl-C impaciente, um OOM killer do SO). 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, de novo, é detached:true:
// scripts/safe-test.mjs:34-36 // 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 });
detached:true ⇒ setsid ⇒ o filho se torna líder de grupo (seu PID é o id do grupo). Então process.kill(-child.pid, …) alcança o vitest principal e todo fork tinypool numa syscall. É a diferença entre "matei o pai, orfanei os filhos" e "matei a família". O resto do arquivo é só quando chamar o killGroup — no timeout, no sinal, e na saída.
O hard timeout: SIGTERM, depois SIGKILL, depois varredura
Um timer de wall-clock limitado escala educadamente-e-depois-à-força. Como demitir alguém: primeiro o aviso (SIGTERM, "encerre e limpe"), depois — se ignorar — a escolta para a saída (SIGKILL, 5 s depois), depois uma varredura, e sai com 124 (o código convencional de timeout):
// scripts/safe-test.mjs:46-58 let timedOut = false; 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); // conventional "timed out" code }, 5_000); }, TIMEOUT_MS); timer.unref(); // não mantenha o event loop vivo só pelo timer
timer.unref() (linha 59): sem isso, o próprio timer manteria o event loop vivo, e o processo não terminaria quando os testes acabassem cedo — ele esperaria o timeout inteiro à toa. O unref() diz "este timer não é motivo para continuar vivo". O default de TIMEOUT_MS é 600_000 (10 min), configurável via SAFE_TEST_TIMEOUT_MS (safe-test.mjs:20).06 · A escalada na mão (passo a passo → agora você)
Você viu a escalada no código. Agora percorra-a na mão, devagar, com o caso do timeout — depois um exercício é seu. Recuperar a ordem dos sinais (não só vê-la pronta) é o que fixa de verdade.
TIMEOUT_MS ms (default 600.000 = 10 min) sem o teste terminar. timedOut = true — isso marca que o caminho do timeout agora é o dono da saída (a linha 77 vai respeitar isso).HARD TIMEOUT after ${TIMEOUT_MS}ms — killing process group -${child.pid}. Repare no sinal de menos antes do PID: já é a forma de grupo.killGroup('SIGTERM') — pede educadamente a toda a família que encerre e limpe. Um processo bem-comportado faz teardown aqui.setTimeout(…, 5_000) dá uma janela de graça. Quem ignorou o SIGTERM (travado de verdade) ainda está vivo.killGroup('SIGKILL') — força. SIGKILL não pode ser ignorado nem capturado; a família inteira morre agora.sweep() pega qualquer fugitivo que já tinha escapado do grupo, e process.exit(124) sinaliza "deu timeout" para o CI.killGroup('SIGKILL') da linha 79 ainda é chamado mesmo numa saída limpa? Pense antes de revelar.
child.on('exit', …) (linhas 76-82). Como timedOut é false, ele não retorna cedo: limpa o timer, chama killGroup('SIGKILL') para "reap any fork still lingering in the group" (linha 79) e sweep() para qualquer um que escapou ao PID 1 — "leakage must not accumulate" (linha 80). A ideia: mesmo no caminho feliz o wrapper assume que pode ter sobrado lixo e limpa de qualquer jeito. Cinto e suspensório.07 · O fluxograma do safe-test.mjs (todos os caminhos de saída)
Junte tudo num único fluxograma. O safe-test.mjs tem quatro caminhos de saída — spawn falhou, sinal externo, timeout, saída normal — e todos terminam na varredura. Cada caminho carrega um exit code próprio, por convenção, para que o CI saiba o que aconteceu só pelo número. Esse é o contrato:
Agora siga as setas do fluxograma; cada losango é uma pergunta que escolhe o caminho.
child 'error'), o operador interrompe (SIGINT), o teste trava (timeout) ou tudo dá certo (exit). Cada modo precisa do seu exit code próprio (1, 130, 124, ou o do teste) e da mesma faxina.if (timedOut) return (l.77): sem ele, o handler de exit rodaria junto com o caminho do timeout (o SIGKILL faz o filho sair), e dois caminhos disputariam o process.exit. O flag garante que "the timeout path owns the exit" (l.77) — exatamente um dono por saída.Vale ver a corrida de perto, porque é um bug clássico de concorrência evitado por uma única linha. Quando o timeout dispara o SIGKILL, o filho morre — e morrer dispara o handler child.on('exit'). Ou seja: o mesmo evento (a morte do filho) ativaria dois caminhos que ambos chamam process.exit com códigos diferentes. O flag timedOut é o árbitro:
08 · Camada 3 — a varredura: pega o que já escapou
Resta um caso que nem a config nem o kill-do-grupo cobrem: se um worker reparentou ao PID 1 antes do kill-do-grupo disparar, ele não está mais no grupo — o kill(-pgid) não o alcança (ele saiu da família). A varredura de último recurso alcança, espelhando a rede manual do operador pgrep -f vitest | kill -9:
// scripts/safe-test.mjs:24-32 /** Last-resort net: kill any `vitest` the group-kill missed (e.g. already * reparented to PID 1). Mirrors the operator net `pgrep -f vitest | kill -9`. */ const sweep = () => { try { execFileSync('pkill', ['-9', '-f', 'vitest'], { stdio: 'ignore' }); } catch { /* nothing left to kill — pkill exits non-zero when no match */ } };
A diferença essencial: o kill-do-grupo casa por pertencimento (mesmo PGID); a varredura casa por nome (-f vitest). Por isso ela alcança até um órfão que já deixou o grupo. O try/catch existe porque pkill sai com código não-zero quando não há nada para matar — o que é o caso normal e desejável, não um erro.
A varredura roda em todo caminho de saída — spawn falhou, sinal, timeout e saída normal — para que o vazamento "must not accumulate" (safe-test.mjs:80). Mesmo numa saída limpa, o wrapper ainda chama killGroup('SIGKILL') para "reap any fork still lingering in the group" (l.79) e depois varre. Cinto e suspensório, porque o custo de um vazamento é horas de CPU fixada.
09 · Três camadas, lado a lado — e o que cada uma deixa passar
A tese da lição: nenhum mecanismo único é confiado a ser perfeito. Cada camada pega a maioria e entrega à próxima exatamente o caso que ela não consegue cobrir. O que a camada anterior deixa passar é a razão de existir da seguinte.
| Camada | Pega | Deixa passar (entrega à próxima) |
|---|---|---|
timeouts da config + forksvitest.config.ts:24-27 | a maioria dos travamentos — falham rápido e o Vitest mata o fork à força no teardown | um pai morto externamente no meio; um handle nativo que o Vitest não consegue ceifar |
kill-do-grupo (detached)safe-test.mjs:34-44 | a árvore viva inteira numa syscall (kill(-pgid)) | um worker que já reparentou ao PID 1 antes do kill |
varredura pkillsafe-test.mjs:24-32 | qualquer vitest perdido por nome, incluindo órfãos do PID 1 | — (o piso) |
Sinta a escalada: o timer de timeout, passo a passo
Arraste para ver onde no relógio cada sinal dispara. A janela de graça entre SIGTERM e SIGKILL é fixa em 5 s (l.57); o TIMEOUT_MS é o que você ajusta (default 600.000):
A prova: o done-when da correção (verificação da Lição 06)
Uma correção de test-safety só está pronta quando provada na fronteira real — não basta "deve funcionar". A condição de pronto tem duas partes que precisam ser verdadeiras ao mesmo tempo: a suíte fica verde e pgrep -f vitest volta vazio depois. Verde sozinho não basta (poderia ter deixado órfãos); vazio sozinho não basta (poderia ter quebrado testes). As duas juntas são a prova.
10 · Como isso se encaixa
Esta lição não é um truque de UNIX isolado: é o endurecimento que nasceu de uma cicatriz e que hoje protege cada iteração da fábrica. A linha do tempo é direta — primeiro veio o incidente (Lição 06: 16 workers órfãos, ~1550% de CPU), e a engenharia desta lição (test:safe) foi a resposta que o tornou estruturalmente impossível. A partir daí, todo loop AFK que roda pnpm -w test herda essa rede automaticamente. Veja o fluxo da peça no maquinário:
Acenda o fluxo, passo a passo
Clique Próximo para seguir a peça pelo maquinário: a cicatriz que a originou, a peça em si, e cada lugar que ela passa a proteger. Recuperar a ordem (causa → peça → efeito) fixa o lugar dela na metodologia melhor do que vê-la pronta.
Clique Próximo para acender o primeiro nó.
As peças que esta lição conecta
Cada link abaixo é uma peça vizinha no maquinário — siga o "porque conecta" para ver o fio:
a montante (a origem): o incidente real — 16 órfãos, ~1550% de CPU — que esta lição transforma em engenharia. A 06 conta a história; a 25 é o como por baixo da correção.
a jusante: quando você adiciona um pacote novo, ele entra na suíte
pnpm -w test e herda a rede de test-safety sem trabalho extra — o endurecimento já cobre o código que ainda não existe.o porquê importa: o swarm roda workers em paralelo; quanto mais processos a fábrica gera, maior o risco de um órfão escapar — é exatamente o cenário que o kill-do-grupo + sweep neutralizam.
a base testável: ports injetáveis tornam cada pacote testável de forma isolada — é o que cria a suíte massiva que o
test:safe precisa rodar com segurança em loop.o consumidor: o Proof Gate executa os
unit.proof[] — em geral pnpm -w test. Se um proof deixasse órfãos, o gate seguinte herdaria CPU saturada; test-safety mantém o gate limpo entre unidades.Test-safety é a infraestrutura de confiabilidade que fica por baixo de toda a metodologia, não um passo dela. O Forge gera o escopo, o Council aprova, o Proof Gate roda os testes, o Validator revisa, o swarm paraleliza — e cada uma dessas etapas que toca pnpm -w test depende de o teste terminar limpo, sem deixar um worker fixando um core para a próxima etapa. É o tipo de peça que você só nota quando falta. Veja-a posicionada no mapa interativo: → a metodologia completa (mapa interativo).
11 · Na prática
Chega de teoria — rode você mesmo. O wrapper desta lição é exposto por um único script no package.json: "test:safe": "node scripts/safe-test.mjs" (verbatim). Sem argumentos, o safe-test.mjs roda o comando padrão pnpm -w test (a suíte inteira do workspace) dentro do seu próprio grupo de processo. O comando que você digita é:
# roda a suíte inteira sob o wrapper anti-órfão (config + kill-grupo + sweep) pnpm test:safe # saída esperada (resumida) — o vitest roda normalmente, e o wrapper # faz a faxina silenciosa no fim (killGroup + sweep em TODO caminho de saída): # Test Files NN passed (NN) # Tests 565 passed (565) # ...sem "[safe-test] HARD TIMEOUT" = nenhum timeout disparou # exit code = o do vitest (0 se verde); 124 se tivesse estourado o timeout
O safe-test.mjs escreve [safe-test] HARD TIMEOUT after … no stderr só quando o timer estoura (default SAFE_TEST_TIMEOUT_MS=600000, 10 min). Numa run saudável você não vê linha nenhuma do wrapper — ele é invisível até precisar matar algo. O número de testes (565 acima) é ilustrativo: é o valor verificado na Lição 06; o seu pode diferir conforme o repositório cresce. [uncertain] a contagem exata de "Test Files" varia com o número de pacotes — confira a sua na saída real.
A prova de que a rede funcionou tem duas partes (o mesmo done-when da Lição 06): a suíte fica verde e, logo depois, pgrep -f vitest volta vazio. Verifique as duas, nesta ordem:
# 1) rode a suíte protegida pnpm test:safe # 2) imediatamente depois, confirme que NÃO sobrou nenhum vitest vivo pgrep -fl vitest # saída esperada: (vazio) — pgrep sai com código 1 quando não há match, # e "vazio" É a prova de que o sweep/kill-grupo não deixou órfão.
pgrep -fl e não só pgrep: o -l lista o nome do processo junto do PID, então se sobrar algo você vê o quê sobrou, não só um número. O -f casa contra a linha de comando inteira (igual ao pkill -f do sweep), então pega o node …/vitest mesmo que o executável não se chame literalmente "vitest". [uncertain] os flags do pgrep são padrão de BSD/macOS e Linux; em ambientes muito enxutos confira pgrep --help.cd /caminho/para/alembic (a pasta com o package.json que tem o script test:safe e o pnpm-workspace.yaml). O wrapper roda a partir daqui.pnpm test:safe. Repare que o vitest aparece igual ao pnpm -w test normal — a diferença é invisível: o processo está num grupo próprio (detached:true) e há um timer de 10 min armado. Espere terminar verde.pgrep -fl vitest logo em seguida. O que procurar: nenhuma linha. Se aparecer um PID, o sweep falhou (não deveria) — e você acabou de reproduzir, em miniatura, o sintoma da Lição 06. As duas condições juntas — verde e pgrep vazio — são o done-when.SAFE_TEST_TIMEOUT_MS=3000 pnpm test:safe. Numa suíte que leva mais que 3 s você verá a linha [safe-test] HARD TIMEOUT after 3000ms — killing process group -<pid> no stderr, depois SIGTERM → 5 s → SIGKILL → sweep → exit 124. É o caminho do losango "timeout" do fluxograma da seção 07, ao vivo. [uncertain] se a suíte terminar em menos de 3 s, ela sai verde antes do timer — aumente para um valor menor que o tempo real da sua suíte.Note o que não existe: não há alembic test nem flag de CLI para isto. Test-safety vive na camada de build (o package.json + vitest.config.ts + scripts/), não na CLI do produto — é a fundação que mantém pnpm -r typecheck && pnpm -r build && pnpm -w test seguro de rodar em loop, que é o baseline de toda mudança de código no repositório.
Fixe os conceitos (flashcards)
Clique pra virar. Tente lembrar a resposta antes de virar — recuperação ativa fixa mais que reler.
detached:true faz?setsid → o filho lidera um NOVO grupo de processo (PID == PGID). Assim kill(-child.pid) alcança todo descendente. safe-test.mjs:34kill?kill(pid) = 1 processo. kill(-pgid) = todo processo do grupo. O sinal de menos endereça a família inteira numa syscall.pkill -9 -f vitest), não por PGID. Alcança o órfão que já reparentou ao PID 1 e saiu do grupo — caso que o kill-do-grupo não cobre.sweep() → exit 124. Pede com jeito, depois força. safe-test.mjs:46-58Revisã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.
safe-test.mjs cria a suíte com detached:true?detached:true ⇒ setsid ⇒ o filho é líder de grupo (PID == PGID); um PID negativo em kill endereça o grupo inteiro, então uma syscall alcança toda a árvore de workers. a confunde com nohup/background: o stdio:'inherit' mantém o terminal ligado, e o objetivo não é liberar o shell. c inventa um efeito sobre stdout que detached não tem aqui. d é o oposto da verdade: SIGKILL nunca pode ser bloqueado — e o wrapper justamente usa SIGKILL no grupo.kill(-pgid) não o alcança; a varredura baseada em nome é a rede de último recurso para esse caso, e roda em todo caminho de saída. a e b são a camada 1 — agem antes, sobre o teste no fork, não sobre um processo que já escapou. c é exatamente o vazamento que a camada 3 existe para impedir: a verificação da Lição 06 mostra pgrep vazio.testTimeout/teardownTimeout limitados e usar pool:'forks' em vez de confiar só no wrapper que mata o grupo?child.on('exit') ainda chama killGroup('SIGKILL') e sweep()?pkill sair sem match é o caso esperado e tratado pelo try/catch, não um bug.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.pgrep -f vitest vazio.pkill saindo com erro é um problema." Não — pkill retorna código não-zero quando não há processo casando, que é o caso normal e desejável. Por isso o sweep envolve a chamada num try/catch que ignora a exceção (safe-test.mjs:29-31).test:safe se liga ao package.json?". É só dizer.