Lição 25 · Curso de Fusão · Parte 5 · Engenharia · Engenharia de test-safety
Alembic × Hermes · O Curso de Fusão · Engenharia defensiva de sistemas

Engenharia de test-safety: o kill que não pode falhar

A Lição 6 contou a história do vazamento de órfãos — 16 processos vitest perdidos, ~1550% de CPU. Esta lição é a engenharia por baixo da correção: como funciona um process group do UNIX, 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 camadas torna o vazamento estruturalmente impossível. É um arquivo pequeno — mas cada linha é load-bearing.

Leia primeiro (fonte primária)
scripts/safe-test.mjs + vitest.config.ts (lidos verbatim do repo)

Cada número de linha, cada flag e cada valor de timeout citados aqui vêm desses dois arquivos reais. Esta lição aprofunda a Lição 6 (o incidente) com a mecânica POSIX por trás da correção. Nada é inventado.

Leia a versão simples, ou abra a camada técnica em qualquer seção.
O que você vai conseguir fazer
  • Explicar a diferença entre kill(pid) (um processo) e kill(-pgid) (o grupo inteiro) — e por que só o segundo alcança a árvore de workers.
  • Dizer por que detached:true (que chama setsid()) é o detalhe exato que faz o filho virar líder de grupo, de modo que seu PID é o id do grupo.
  • Descrever a defesa em três camadas (config que falha o hang → kill do grupo → varredura por nome) e dizer o que cada camada pega e o que ela passa adiante.
  • Justificar a escalada SIGTERM → 5s → SIGKILL → exit 124 e por que a varredura roda em todo caminho de saída.
Suposições tolas (o que presumimos de você — bem pouco)
  • Você já rodou uma suíte de testes na linha de comando e sabe que ela "passa" ou "falha".
  • Você sabe que um programa pode iniciar outros programas (processos-filho). Não precisa conhecer POSIX — a gente constrói os termos do zero.
  • Você não precisa saber o que é setsid, tinypool, PGID ou "PID negativo". Tudo isso é explicado aqui.
1

O problema-raiz — órfãos escapam do pai


O tinypool do Vitest roda os testes em processos-filho (workers). Se um teste pendura sem teardown — um socket aberto, uma promise não resolvida, um setInterval vivo — e o pai é morto só pelo PID, os workers não morrem: eles são reparenteados ao PID 1 (o init) e continuam girando, cada um prendendo um núcleo.

Matar o processo-pai não basta. Você tem de matar a árvore inteira — e uma árvore que já reparenteou não é mais alcançável a partir do pai, porque o pai já morreu. Esse é o coração do bug.

kill(pid) — só o pai vitest main ✗ morto fork ⟳ vivo fork ⟳ vivo PID 1 (init) → reparenteados, ainda travando os núcleos kill(-pgid) — o grupo inteiro process group (1 líder via setsid) vitest main ✗ fork ✗ fork ✗ → o PID negativo endereça todos de uma vez; nada sobrevive defesa em profundidade = config (falhar no timeout) + kill-de-grupo + varredura (pegar o que fugiu)
O mesmo "matar" tem dois resultados opostos: depende de você mirar a árvore (limpo) ou só o pai (deixa órfãos no PID 1).
Em uma fraseUm órfão é um filho cujo pai morreu antes dele; o init o adota (PPID vira 1) mas só o recolhe quando ele terminar — e um worker pendurado nunca termina.
spawnpai + workers pai morresó pelo PID PPID → 1init adota ∞ giranunca termina → nunca é recolhido
A vida de um órfão: morto o pai, o init vira PPID — mas só recolhe quem termina, e um hang não termina. Por isso ~11h girando.
2

A ideia central — PID positivo vs PID negativo


Toda a correção gira em torno de uma única regra POSIX. kill(pid, sig) sinaliza um processo. kill(-pgid, sig) — com o número negativo — sinaliza todos os processos daquele grupo. O sinal de menos é a diferença entre matar o pai e matar a família.

Preveja antes de continuar

Você roda spawn(...) com a opção detached:true. O que essa flag, sozinha, muda no relacionamento entre o processo-filho e seus próprios netos?

Ela chama setsid(). O filho deixa de pertencer ao grupo do pai e se torna líder de um grupo NOVO. A consequência crucial: o PID do filho passa a ser também o id do grupo (PGID). Então kill(-child.pid, …) mira o grupo inteiro — o vitest main e todos os forks do tinypool — num único syscall.

Veja a hierarquia de processos viva. Clique entre os dois modos de kill e observe o que sobrevive:

process group (PGID = PID do líder) safe-test.mjs vitest main (líder) tinypool fork ⟳ tinypool fork ⟳ tinypool fork ⟳ PID 1 (init)
kill(child.pid): só o vitest main recebe o sinal. Os três forks perdem o pai, reparenteiam para o PID 1 e continuam girando. Isto é o vazamento.
A única ideia para internalizar: um PID negativo é um process group

Em POSIX, kill(pid, sig) sinaliza um processo; kill(-pgid, sig) sinaliza todos do grupo. detached:true chama setsid(), então o filho vira líder — seu PID é o PGID. Logo process.kill(-child.pid, …) alcança o vitest main e cada fork do tinypool num único syscall. Essa é a diferença entre "matei o pai, orfanei os filhos" e "matei a família".

sem detached — herda o grupo do pai PGID = grupo do shell/pai pai vitest main matar o grupopega o shell junto com detached:true → setsid() PGID NOVO = PID do filho (líder) vitest main (líder) fork fork grupo isoladosó os testes
setsid() não só permite o kill-de-grupo — ele isola os testes num grupo próprio, então matar o grupo nunca atinge o shell que os lançou.
Infográfico comparando kill(pid) — que mata só o vitest main e deixa três tinypool forks reparentearem para o PID 1, ainda travando a CPU — com kill(-pgid), que mata o vitest main e os três forks juntos dentro de uma moldura de process group, com a equação detached:true → setsid() → o filho é líder → seu PID é o id do grupo.

PID positivo mira um processo e abandona os filhos; PID negativo mira o grupo inteiro. detached:true é o que cria esse grupo.

Retrieval
O que detached:true faz no nível do SO?
clique para virar
Chama setsid(): o filho deixa o grupo do pai e vira líder de um grupo novo. Seu PID passa a ser também o PGID do grupo.
Retrieval
Por que o PID precisa ser negativo?
clique para virar
Convenção POSIX: um número negativo em kill endereça o process group inteiro com aquele PGID, não um processo único. Positivo = um; negativo = todos.
Retrieval
O que é o tinypool?
clique para virar
O pool de workers do Vitest, que roda os arquivos de teste em processos-filho paralelos. São esses forks que reparenteiam quando o pai morre cedo.
3

Camada 1 — fazer o hang FALHAR, não pendurar (vitest.config.ts)


A primeira defesa impede o hang de acontecer logo de cara. A config compartilhada fixa timeouts limitados e o pool forks, então um arquivo travado falha rápido e é force-killado no teardown, em vez de girar para sempre:

vitest.config.ts:20-27 — o endurecimento anti-órfão
export default defineConfig({
  test: {
    environment: 'node',
    // Anti-orphan hardening: um teste pendurado (server/socket/MCP/interval/
    // promise não resolvida sem teardown) DEVE falhar num timeout limitado,
    // nunca pendurar um worker para sempre. O pool 'forks' isola hangs em
    // processos-filho que o Vitest force-killa no teardown.
    testTimeout: 15_000,
    hookTimeout: 15_000,
    teardownTimeout: 10_000,
    pool: 'forks',
  },
});
sem timeout hang ⟳ … gira para sempre, prendendo um núcleo com testTimeout: 15_000 espera 15s FALHA do teste (visível, CI-vermelho) fork force-killado
O timeout transforma uma espera infinita numa falha de teste — limitada, visível e CI-vermelha — antes que qualquer núcleo seja preso.
cada knob limita uma fase distinta — nada é ilimitado testTimeout 15s hookTimeout 15s teardownTimeout 10s o teste em si · os hooks (before/after) · a limpeza — cada fase tem teto, então nenhuma pendura
Três relógios, três fases: o corpo do teste, os hooks e o teardown. Sem teto, qualquer uma poderia pendurar — com teto, todas falham rápido.

Por que pool:'forks' e não as threads padrão? Um hang dentro de uma thread pode travar o processo-host. Um hang dentro de um fork (um processo do SO separado) fica isolado, e o Vitest força a morte dele no teardown — então um arquivo travado não consegue prender um núcleo. Os timeouts transformam a espera infinita numa falha visível.

Camada 1 é configuração declarativa do runner. testTimeout/hookTimeout abortam uma execução que estoura o orçamento; teardownTimeout limita a limpeza; e pool:'forks' roda cada arquivo num processo isolado que o Vitest sabe force-killar. O comentário do próprio arquivo (linhas 20-23) diz por quê: um teste pendurado "DEVE falhar num timeout limitado, nunca pendurar um worker para sempre". Isso converte a maioria dos hangs em falhas antes que cheguem ao wrapper.

pool: 'forks' (escolhido) proc A proc B ✗ proc C B travado? o Vitest força só B isolado — não prende o resto pool: 'threads' (contraste) um processo, threads partilhadas thread travada ≈ não-matável pode wedgear o host
Aqui forks não é performance: é segurança — um processo é force-killável; uma thread travada, não tanto.
LembreA Camada 1 não mata a árvore — ela impede que o hang exista. É a prevenção; as duas camadas seguintes são os backstops para o que a config não cobre.
4

Camada 2 — o wrapper que é dono de um process group


A config sozinha não cobre todo escape (um handle nativo, um pai morto por SIGKILL no meio da run). Então scripts/safe-test.mjs roda a suíte inteira em seu próprio process group e mata o grupo, não o PID. A chave é detached:true:

scripts/safe-test.mjs:34-44
// detached:true => POSIX setsid => o filho lidera um NOVO process group,
// 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 */
  }
};

Repare na simetria com a Camada 1: lá, o número era um timeout; aqui, o número-chave é o sinal de menos em -child.pid. Um caractere — e ele decide se o kill atinge um processo ou uma família inteira.

kill(child.pid, sig) → 1 processo kill(-child.pid, sig) → o grupo inteiro um sinal de menos = "o bug" vira "a correção"
O único caractere de diferença é o que separa matar o pai de matar a árvore inteira.
killGroup(sig) grupo vivo → sinaliza todoso caso normal grupo já se foi → ESRCHlança "no such process" catch {}
Dois estados, um deles esperado: matar um grupo que já morreu lança ESRCH — e o catch vazio engole exatamente esse caso, porque "já se foi" é o que queríamos.
Detalhe técnicoO try/catch vazio em volta de process.kill é deliberado: se o grupo já terminou, o syscall lança ESRCH ("no such process"). Engolir esse erro específico é correto — "grupo já se foi" é exatamente o estado que queríamos.
5

O timeout duro — SIGTERM, depois SIGKILL, depois a varredura


Um cronômetro de wall-clock limitado escala educadamente-depois-à-força. Primeiro SIGTERM (deixa limpar), SIGKILL 5 segundos depois (força), então uma varredura, e sai com 124 (o código convencional de "estourou o tempo"):

scripts/safe-test.mjs:46-59
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 jeitinho
  setTimeout(() => {
    killGroup('SIGKILL');            // depois força, 5s mais tarde
    sweep();
    process.exit(124);              // código convencional de "timed out"
  }, 5_000);
}, TIMEOUT_MS);
timer.unref();                       // não segura o event loop só pelo timer
t = 0
SIGTERM ao grupo — "termine, por favor". O processo pode rodar handlers de limpeza.
t = +5s
SIGKILL ao grupo — não-capturável, não-ignorável. O que não saiu, morre agora.
logo após
sweep() — varre qualquer vitest que já tinha escapado do grupo.
saída
exit 124 — o código que ferramentas como timeout(1) usam para "estourou o tempo".
o que o código de saída te conta num loop 0passou (ecoa o código do teste) 124timeout duro estourou 130SIGINT/SIGTERM (Ctrl-C)
Três códigos, três histórias — num loop, é como você sabe se a execução passou, estourou o relógio, ou foi interrompida.

O valor do timeout vem do ambiente, com um default seguro de 10 minutos:

scripts/safe-test.mjs:20
const TIMEOUT_MS = Number(process.env.SAFE_TEST_TIMEOUT_MS ?? 600_000); // 600000 = 10 min
Por que timer.unref()? Sem ele, o timer pendente manteria o event loop do Node vivo mesmo depois de a suíte terminar normalmente — o próprio wrapper penduraria. unref() diz "este timer não é razão para o programa continuar rodando". Outra linha pequena e load-bearing.
sem unref() suíte termina ✓ timer pendente segura o event loop ⟳ com unref() suíte termina ✓ wrapper sai
O mesmo timer protege a run e, sem unref(), traria de volta o próprio bug que combate — um processo que não sai. Uma linha cancela esse risco.
CuidadoSIGTERM antes de SIGKILL não é cerimônia: dá ao processo a chance de fechar arquivos e liberar locks. Pular direto para o SIGKILL pode deixar estado pela metade. A escalada é deliberada.
6

Camada 3 — a varredura: pegar o que já escapou


Se um worker reparenteou para o PID 1 antes do kill-de-grupo, ele já não está no grupo — o kill-de-grupo não o alcança. A varredura de último recurso alcança, espelhando a rede manual do operador pgrep -f vitest | kill -9:

scripts/safe-test.mjs:24-32
/** Rede de último recurso: mata qualquer `vitest` que o kill-de-grupo
 *  perdeu (ex.: já reparenteado ao PID 1). */
const sweep = () => {
  try {
    execFileSync('pkill', ['-9', '-f', 'vitest'], { stdio: 'ignore' });
  } catch {
    /* nada para matar — pkill sai != 0 quando não há match */
  }
};

A varredura roda em todo caminho de saída — saída normal, sinal e timeout — então o vazamento "não pode acumular" (safe-test.mjs:80). Numa saída limpa, o wrapper ainda chama killGroup('SIGKILL') para "reapear qualquer fork ainda persistindo no grupo", e então varre. Cinto e suspensório, porque o custo de um vazamento é horas de CPU presa.

pkill -9 -f vitest casou → matou → exit 0havia órfão, foi derrubado sem match → exit ≠ 0nada para matar — o caso bom catch {}
"Nada para matar" é o resultado desejado — mas o pkill o sinaliza com código ≠ 0, então o try/catch trata o no-match como sucesso, não como erro.
scripts/safe-test.mjs:76-82 — o handler de saída
child.on('exit', (code, signal) => {
  if (timedOut) return;       // o caminho do timeout é dono da saída
  clearTimeout(timer);
  killGroup('SIGKILL');          // reapeia qualquer fork ainda no grupo
  sweep();                       // e qualquer um que fugiu para o PID 1
  process.exit(typeof code === 'number' ? code : signal ? 1 : 0);
});
saída normal (exit) SIGINT / SIGTERM hard timeout killGroup('SIGKILL')+ sweep() nada vaza
Todo caminho de saída converge para o mesmo par killGroup + sweep. Não há rota de fuga que pule a limpeza.
Infográfico de defesa em profundidade em três faixas empilhadas como peneira: Camada 1 (vitest.config.ts: testTimeout/hookTimeout/teardownTimeout/pool forks) pega a maioria dos hangs e passa o pai morto externamente; Camada 2 (safe-test.mjs: detached, kill de PID negativo, SIGTERM→SIGKILL→exit 124) pega a árvore viva e passa o worker já reparenteado; Camada 3 (pkill -9 -f vitest) pega qualquer vitest perdido; à direita uma coluna Proof Gate com 565 green e pgrep vazio.

Cada camada tem uma falha conhecida — que é exatamente o trabalho da camada seguinte. O piso é a varredura por nome.

pnpm test:safe 565 tests green prova: a correção funciona pgrep -f vitest (vazio) prova: nada vazou ✓ bug de processo → prova na fronteira do processo
São duas provas distintas: verde prova a correção; a tabela de processos vazia prova a ausência de vazamento. Para um bug de ciclo de vida de processo, a segunda é a que importa.
7

Por que três camadas e não uma


CamadaO que pegaO que passa adiante
timeouts da config + forksa maioria dos hangs — falham rápido e o Vitest force-killa o forkum pai morto externamente no meio da run; um handle nativo que o Vitest não reapeia
kill-de-grupo (detached)a árvore viva inteira num syscallum worker que reparenteou para o PID 1 antes do kill
varredura pkillqualquer vitest pelo nome, inclusive órfãos no PID 1— (este é o piso)
Camada 1 · config falha o hangtimeouts + pool:'forks' — pega a maioria escapa: pai morto externamente ↓ Camada 2 · kill-de-grupo (detached)kill(-pgid) — a árvore viva inteira escapa: já no PID 1 ↓ Camada 3 · varredura por nomepkill -9 -f vitest — o piso, nada passa o que escapa de uma faixa cai na de baixo
Uma peneira em cascata: cada faixa pega o grosso e deixa cair só o seu furo conhecido — que é exatamente o que a faixa de baixo foi feita para pegar.

A falha de cada camada é o trabalho da próxima. Isso é defesa em profundidade: nenhum mecanismo isolado é confiado como perfeito, e o modo de falha (um núcleo preso por horas) é severo o bastante para justificar a redundância.

Uma quarta medida, de cinto-e-suspensório, vive fora do wrapper: .factory/run.ts varre o vitest perdido do host no início e no fim de cada iteração — então um loop automatizado nunca herda o vazamento de uma execução anterior.
Mapa mental: config evita o hang → kill-de-grupo mata a árvore viva → varredura pega o órfão que fugiu. Três redes, cada uma cobrindo o furo da anterior.
8

Diagnóstico passo a passo


Você abre o monitor de atividade e vê processos vitest velhos comendo CPU depois que a suíte já "terminou". Qual camada deveria ter pego cada cenário?

Atribua o cenário à camada
1
Um teste abre um servidor HTTP e esquece de fechá-lo; a suíte trava nesse arquivo. → Camada 1. O testTimeout: 15_000 aborta após 15s e o pool forks isola e force-killa o arquivo. Vira uma falha, não um órfão.
2
Você dá Ctrl-C no terminal no meio da run. → Camada 2. O handler onSignal chama killGroup('SIGKILL') + sweep() e sai com 130. O grupo inteiro morre junto, em vez de só o processo de topo.
3
Um worker já tinha reparenteado para o PID 1 antes de qualquer kill. → Camada 3. O kill-de-grupo não o alcança (saiu do grupo), mas pkill -9 -f vitest casa pelo nome e o derruba. É o piso.
server HTTP esquecidoa suíte trava no arquivo Camada 1 Ctrl-C no meio da run Camada 2 órfão já no PID 1antes de qualquer kill Camada 3 (o piso) prevenir matar o grupo varrer o resto
Cada sintoma tem uma camada-dona: prevenir o hang, matar a árvore viva, ou varrer o órfão que já fugiu. Saber qual é qual é o diagnóstico.
Agora você: imagine um cenário onde alguém propõe "é só aumentar o testTimeout para 60s". Qual problema isso não resolve? (Resposta: a orfanação. Um timeout maior só faz o hang demorar mais para falhar; ele não toca no fato de um worker reparentear e sobreviver ao matador. A orfanação é trabalho das Camadas 2 e 3.)
9

Confusões comuns


"kill(pid) mata os filhos também." Não — ele sinaliza um processo. Os filhos sobrevivem e reparenteiam para o PID 1. Você precisa da forma de process group (kill(-pgid)) para alcançar a árvore — que é exatamente por que detached:true existe no wrapper.
"A varredura é exagero se o kill-de-grupo funciona." O kill-de-grupo não alcança um processo que já saiu do grupo (reparenteou para o PID 1). A varredura não é redundante — ela cobre um caso que o kill-de-grupo estruturalmente não pode. A verificação (Lição 6) confirma ambos: 565 green e pgrep -f vitest vazio.
"forks vs threads é só performance." Aqui é também segurança: o pool forks isola um hang num processo-filho que o Vitest force-killa no teardown, então um arquivo travado não consegue prender um núcleo como uma thread não-matável poderia.
10

Recapitulando em 6 slides


O problema

Órfãos escapam do pai

Um worker do tinypool que pendura e é deixado sem pai reparenteia para o PID 1 e gira para sempre. Matar o pai não basta.

A ideia central

O sinal de menos

kill(pid) = um processo. kill(-pgid) = o grupo inteiro. Um caractere decide entre matar o pai e matar a família.

O que cria o grupo

detached → setsid

detached:true chama setsid(): o filho vira líder e seu PID é o PGID. Por isso -child.pid alcança todos os forks.

A defesa

Três camadas

1 config falha o hang (timeouts + forks). 2 kill do grupo (detached + PID negativo). 3 varredura (pkill -9 -f vitest).

A escalada

Educado, depois à força

SIGTERM → espera 5s → SIGKILL + sweepexit 124. E timer.unref() para o próprio wrapper nunca pendurar.

A generalização

Nunca um PID nu

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

1 / 6setas
11

Verifique seu entendimento


Quiz cumulativo

Três perguntas. O placar acompanha seus acertos.

1. Por que safe-test.mjs cria a suíte com detached:true?
Correto: b. detached:truesetsid ⇒ o filho é líder de grupo cujo PID é o PGID. Um PID negativo em kill endereça o grupo inteiro, então um syscall alcança a árvore de workers — exatamente o que kill(pid) nu não faz.
2. Um worker reparenteia para o PID 1 antes de o kill-de-grupo disparar. Qual camada o pega?
Correto: d. Uma vez reparenteado ao PID 1, o worker está fora do process group original, então kill(-pgid) não o alcança. A varredura por nome é justamente a rede de último recurso para esse caso, e roda em todo caminho de saída.
3. Por que fixar 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 rio acima: impeça o hang de prender qualquer coisa, falhando rápido num fork isolado. O wrapper e a varredura são os backstops para os casos que a config não cobre (pai morto externamente, handles nativos).
Acertos: 0/3
Recapitulando em uma linha: kill(pid) mira um processo e kill(-pgid) mira o grupo; detached:true cria esse grupo via setsid; e a defesa tem três camadas — config falha o hang, o kill-de-grupo mata a árvore viva, a varredura por nome pega o órfão que fugiu.
Para ler a seguir (fontes verbatim)
scripts/safe-test.mjs · vitest.config.ts · Lição 6 (o incidente)

safe-test.mjs — racional do cabeçalho (2-17), sweep (24-32), detached:true + killGroup com PID negativo (34-44), timeout duro SIGTERM→SIGKILL→exit 124 (46-59), reap + sweep no caminho de saída (76-82). vitest.config.ts — timeouts anti-órfão + pool:'forks' com racional (20-27). E a Lição 6 para o incidente (16 workers, ~1550% CPU, ~11h) e a verificação "565 green + pgrep vazio".