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

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.

Leia primeiro (fontes primárias)
Lição 06 — O vitest-orphan-leak · 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.

Objetivos desta lição
  • Explicar por que kill(pid) deixa filhos vivos — e como eles reparentam ao PID 1.
  • Descrever o que detached:true faz (setsid → novo grupo de processo → líder de grupo).
  • Diferenciar process.kill(pid) de process.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.
0
workers órfãos (incidente · Lição 06)
~0%
CPU fixada pelos órfãos
0
camadas de defesa em profundidade
0
exit code convencional de timeout

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.

A frase-âncora do código (lida literalmente): "Vitest's tinypool workers can orphan (reparent to PID 1) and pin a CPU core for hours if (a) a test hangs with no teardown and (b) the parent is killed without killing the worker tree." — 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 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.

O VAZAMENTO · matar o pai por PID deixa os workers órfãos no PID 1 (o caso do incidente da Lição 06)
ANTES · árvore viva pai (vitest) worker ⟳ worker ⟳ worker ⟳ kill(pid) só o pai DEPOIS · pai morto, filhos órfãos pai ✗ morto PID 1 (init) worker ⟳ órfão worker ⟳ órfão worker ⟳ órfão → reparentados ao PID 1, fixando cores por horas A lição: matar o pai ≠ matar a árvore. Precisamos alcançar TODOS de uma vez. a próxima seção mostra a estrutura que torna isso possível: o grupo de processo
Preveja antes de continuar
No incidente da Lição 06 foram 16 workers órfãos. Cada um fixava aproximadamente um core inteiro. Em porcentagem de CPU (onde 100% = um core), quanto você acha que eles somavam? Chute a ordem de grandeza antes de revelar.
~1550%. Dezesseis processos, cada um perto de 100% de um core, somam ~1550% no 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.

A analogia: processo = uma pessoa (tem um PID, como um RG). Grupo de processo = a família (tem um PGID, o sobrenome). Por padrão, quando o pai cria filhos, todos herdam o sobrenome do pai. 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.
O mecanismo POSIX: cada processo tem um PID e um PGID. 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.
PID vs PGID · o filho com detached:true vira líder de um grupo novo (seu PID É o PGID)
detached:true → setsid → grupo próprio grupo de processo · PGID = PID do filho líder child (vitest principal) líder · PID == PGID fork tinypool fork tinypool fork tinypool …todos herdam o PGID Resultado: um único PGID engloba o vitest principal + todos os forks. Agora um só sinal — endereçado ao PGID — alcança a família inteira. É o que a seção 03 explora.

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.

COMPARATIVO · kill(pid) (só o pai, filhos vazam) vs kill(-pgid) (a família toda, nada sobrevive)
kill(pid) — PID positivo pai ✗ morto (1 processo) worker ⟳ vive worker ⟳ vive ✗ vazou: filhos reparentam ao PID 1 o sinal alcançou 1 processo, não a árvore kill(-pgid) — PID negativo grupo de processo (um líder setsid) pai ✗ worker ✗ worker ✗ ✓ o PID negativo endereça todo membro uma syscall, a família inteira, nada sobrevive É a diferença entre "matei o pai, orfanei os filhos" e "matei a família". o sinal de menos é a única mudança — e muda tudo

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.

COMPARATIVO · SIGTERM (capturável, educado) vs SIGKILL (incapturável, à força) — por que a ordem importa
SIGTERM · "encerre e limpe" chega ao processo → pode ter um handler comportadofaz teardown ✓ travadoignora, segue vivo ✗ capturável → permite limpeza, mas NÃO garante a morte SIGKILL · "morra agora" chega ao kernel → handler NÃO roda encerrado pelo kernelsem teardown · incapturável · inignorável garante a morte → mas perde qualquer limpeza Por isso a escalada: SIGTERM dá a chance de limpar; 5 s depois SIGKILL garante o fim. pedir-com-jeito-e-depois-forçar = o melhor dos dois

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

CAMADA 1 · um teste travado, com e sem timeout limitado (thread vs fork)
sem timeout · thread teste trava → gira ∞ host emperra · core fixado ninguém percebe até o CPU saturar testTimeout: 15_000 · fork teste trava → 15 s → FALHA fork isolado · Vitest mata no teardown vira falha vermelha no CI, não core fixado a montante: a maioria dos travamentos morre aqui e nunca chega ao wrapper

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:

ARQUITETURA · pool:'threads' (memória/loop compartilhados) vs pool:'forks' (processos de SO isolados)
pool:'threads' · compartilha tudo processo host (um event loop, uma memória) threadteste A thread ⟳travada threadteste C loop compartilhado → trava emperra o HOST matar uma thread sem derrubar o host é arriscado 1 trava = todos param pool:'forks' · isola por processo host pai (coordena) forkloop próprioteste A ✓ fork ⟳isoladotravado forkloop próprioteste C ✓ o host fica são → mata o fork preso no teardown 1 trava = só aquele fork morre "a stuck file cannot pin a CPU core" — vitest.config.ts:22-23

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 });
A única ideia para internalizar

detached:truesetsid ⇒ 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
Detalhe de engenharia — 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).
COMPARATIVO · timer SEM unref() (o processo trava até o timeout) vs COM unref() (sai junto com os testes)
SEM timer.unref() — o timer segura o event loop testes (terminam cedo) processo VIVO à toa, esperando o timer ⟳ só agora sai (TIMEOUT_MS) COM timer.unref() — o timer não conta como "manter vivo" testes (terminam cedo) ↳ processo SAI na hora ✓ o timer ainda dispararia SE o teste travasse — mas não atrasa a saída feliz

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.

Exemplo resolvido · o que acontece quando o hard timeout dispara
1
O timer estoura. Passaram-se 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).
2
Avisa no stderr. Escreve HARD TIMEOUT after ${TIMEOUT_MS}ms — killing process group -${child.pid}. Repare no sinal de menos antes do PID: já é a forma de grupo.
3
SIGTERM ao grupo. killGroup('SIGTERM') — pede educadamente a toda a família que encerre e limpe. Um processo bem-comportado faz teardown aqui.
4
Espera 5 s. setTimeout(…, 5_000) dá uma janela de graça. Quem ignorou o SIGTERM (travado de verdade) ainda está vivo.
5
SIGKILL ao grupo. killGroup('SIGKILL') — força. SIGKILL não pode ser ignorado nem capturado; a família inteira morre agora.
6
Varre + sai 124. sweep() pega qualquer fugitivo que já tinha escapado do grupo, e process.exit(124) sinaliza "deu timeout" para o CI.
Agora você: a suíte termina normalmente (sem timeout) com os testes passando. Qual caminho roda — e por que o killGroup('SIGKILL') da linha 79 ainda é chamado mesmo numa saída limpa? Pense antes de revelar.
Roda o handler 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:

REFERÊNCIA · os quatro exit codes do safe-test.mjs e o que cada um sinaliza ao CI
child 'error' · l.69-74 1 spawn falhou onSignal · l.61-67 130 sinal externo (SIGINT) timeout · l.46-58 124 hard timeout exit · l.76-82 = teste saída normal (espelha o cmd) "Exit code mirrors the test command; a hard-timeout kill exits 124." — safe-test.mjs:16

Agora siga as setas do fluxograma; cada losango é uma pergunta que escolhe o caminho.

FLUXOGRAMA · safe-test.mjs — do spawn ao exit, com sweep() em todo caminho de saída
spawn(detached:true) cria o filho líder de grupo spawn deu certo? (child 'error'?) NÃO sweep() exit 1 · l.69-74 SIM corre o teste · timer.unref() aguarda um de três eventos SIGINT/SIGTERM externo timer estourou child 'exit' normal onSignal · l.61-67 killGroup('SIGKILL') sweep() exit 130 timeout · l.46-58 SIGTERM → 5s → SIGKILL · sweep() exit 124 exit · l.76-82 killGroup('SIGKILL') sweep() exit = código do teste TODO caminho passa por sweep() "leakage must not accumulate" (l.80)
Por que quatro caminhos e não um: o processo pode terminar de modos muito diferentes — o comando nem nasce (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.
O guard 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:

A CORRIDA · sem o guard dois caminhos disputam process.exit; com o guard (if timedOut return) só um vence
timeout → killGroup('SIGKILL') o filho morre… o caminho do timeout …morrer dispara o handler caminho timeout · l.53-56 sweep() → process.exit(124) child.on('exit') · l.76-82 quer exit(código do teste) timedOut? (l.77) SIM → return (não chama exit) UM dono: exit(124) ✓ Sem o guard, ambos correriam para process.exit — código de saída não determinístico. O flag remove a corrida.

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.

COMPARATIVO · kill-do-grupo (casa por PGID) vs sweep (casa por nome) — quem pega o órfão do PID 1
kill(-pgid) · casa por PGID grupo (PGID = líder) no grupo ✗ no grupo ✗ órfão no PID 1 ⟳ ESCAPA deixou o grupo → fora do alcance pkill -9 -f vitest · casa por nome todo processo cujo nome casa "vitest" do grupo ✗ do grupo ✗ órfão do PID 1 ✗ PEGO nome ignora pertencimento → alcança o fugitivo a varredura não é redundante: cobre um caso que o kill-do-grupo estruturalmente não consegue

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.

CamadaPegaDeixa passar (entrega à próxima)
timeouts da config + forks
vitest.config.ts:24-27
a maioria dos travamentos — falham rápido e o Vitest mata o fork à força no teardownum 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 reparentou ao PID 1 antes do kill
varredura pkill
safe-test.mjs:24-32
qualquer vitest perdido por nome, incluindo órfãos do PID 1— (o piso)
DEFESA EM PROFUNDIDADE · cada camada pega uma fatia; o que escapa cai na próxima; o piso não deixa passar nada
todos os travamentos possíveis → camada 1 · config timeouts + forks pega: a maioria vira falha no CI escapou camada 2 · grupo kill(-pgid) pega: árvore viva escapou camada 3 · sweep pkill -9 -f vitest pega: o órfão ↓ o piso 0 vazamentos ✓ A verificação da Lição 06 confirma o piso: 565 verdes E pgrep -f vitest vazio. Cada miss é o trabalho da próxima camada — é isso que "defesa em profundidade" significa na prática.

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):

0 ms teste roda SIGTERM @ TIMEOUT_MS SIGKILL + sweep() + exit 124 janela de graça fixa: 5 s

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.

PROVA · antes (16 órfãos, ~1550% CPU) vs depois (0 órfãos, pgrep vazio, 565 verdes) — o done-when em duas partes
ANTES · o incidente (Lição 06) CPU fixada pelos órfãos ~1550% (≈16 cores) workers órfãos no PID 1 16 · fixando cores por horas $ pgrep -f vitest → 16 PIDs (vazamento) DEPOIS · com test:safe CPU fixada pelos órfãos ~0% (nenhum órfão) testes da suíte 565 verdes ✓ $ pgrep -f vitest → (vazio) ✓ Done-when = 565 verdes E pgrep vazio. As duas partes, ou não está pronto.

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:

FLUXO · de onde a peça veio (a montante) → test-safety (esta peça) → o que ela protege (a jusante)
A MONTANTE · o que originou Lição 06 · o incidente 16 órfãos · ~1550% CPU Lição 05 · ports & injeção FsPort/spawn testáveis ESTA PEÇA test-safety · test:safe config + kill-grupo + sweep 3 camadas anti-órfão A JUSANTE · o que protege Lição 19 · o swarm workers paralelos Lição 17 · gates (Proof) proof[] roda testes Lição 29 · estender o pacote novo herda a rede cada iteração do loop AFK roda repetidamente, segura

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.

passo 0 / 4 — clique "Próximo"
1 · incidente Lição 06 2 · test-safety test:safe (esta peça) 3 camadas 3 · loop AFK + swarm Lição 19 · paralelo 4 · gates (Proof) Lição 29 · herda a rede causa → peça → efeito: o vazamento exigiu o endurecimento, que hoje guarda todo o resto

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:

Lição 06 · O vitest-orphan-leak
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.
Lição 29 · Estendendo a fusã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.
Lição 19 · O swarm
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.
Lição 05 · Ports & injeção
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.
Lição 17 · A pipeline de gates
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.
Onde você está na metodologia

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 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.
Por que 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.
Experimente · prove a rede na sua máquina (3 passos)
1
Vá para a raiz do monorepo. 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.
2
Rode a suíte protegida. 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.
3
Cace fantasmas. Rode 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.
Quer ver a escalada disparar de propósito? Baixe o timeout para alguns segundos e force a suíte a estourá-lo: 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.

Grupo
O que detached:true faz?
clique pra virar ↻
Resposta
Chama setsid → o filho lidera um NOVO grupo de processo (PID == PGID). Assim kill(-child.pid) alcança todo descendente. safe-test.mjs:34
Sinal
PID positivo vs PID negativo em kill?
clique pra virar ↻
Resposta
kill(pid) = 1 processo. kill(-pgid) = todo processo do grupo. O sinal de menos endereça a família inteira numa syscall.
Camada 3
Por que a varredura, se já existe o kill-do-grupo?
clique pra virar ↻
Resposta
Casa por NOME (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.
Escalada
Qual a sequência do hard timeout?
clique pra virar ↻
Resposta
SIGTERM → espera 5 s → SIGKILL → sweep()exit 124. Pede com jeito, depois força. safe-test.mjs:46-58

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 safe-test.mjs cria a suíte com detached:true?
Correto: b. detached:truesetsid ⇒ 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.
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 original, então 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.
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 o que a config não cobre (kill externo no meio, handles nativos). a é falso — o group-kill POSIX é justamente o que roda no macOS/Linux. b troca o objetivo: timeouts existem para limitar travamentos, não acelerar testes saudáveis. d é falso: as worker threads do Vitest rodam TS igual; o motivo de evitá-las aqui é isolamento de travamento, não capacidade.
4. Numa saída normal (testes passaram, sem timeout), por que o handler child.on('exit') ainda chama killGroup('SIGKILL') e sweep()?
Correto: a. O wrapper assume que mesmo no caminho feliz pode ter sobrado um fork persistindo ou um órfão, e limpa de qualquer jeito — cinto e suspensório. b confunde os caminhos: o exit code normal é o do teste (l.81), e 124 é só do caminho de timeout. c inventa um número fixo: o incidente teve 16, mas uma saída normal tipicamente tem zero — a faxina é seguro, não regra. d inverte a intenção: 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.
"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.
"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).
💬 Travou em algo? Eu sou seu professor neste curso — pergunte. "Por que SIGTERM antes de SIGKILL?", "O que é exatamente uma sessão POSIX vs um grupo?", "Como o test:safe se liga ao package.json?". É só dizer.