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.
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.
kill(pid) (um processo) e kill(-pgid) (o grupo inteiro) — e por que só o segundo alcança a árvore de workers.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.SIGTERM → 5s → SIGKILL → exit 124 e por que a varredura roda em todo caminho de saída.setsid, tinypool, PGID ou "PID negativo". Tudo isso é explicado aqui.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.
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.
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?
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:
vitest main recebe o sinal. Os três forks perdem o pai, reparenteiam para o PID 1 e continuam girando. Isto é o vazamento.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".
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.PID positivo mira um processo e abandona os filhos; PID negativo mira o grupo inteiro. detached:true é o que cria esse grupo.
detached:true faz no nível do SO?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.kill endereça o process group inteiro com aquele PGID, não um processo único. Positivo = um; negativo = todos.tinypool?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:
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', }, });
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.
forks não é performance: é segurança — um processo é force-killável; uma thread travada, não tanto.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:
// 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.
ESRCH — e o catch vazio engole exatamente esse caso, porque "já se foi" é o que queríamos.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.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"):
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
vitest que já tinha escapado do grupo.timeout(1) usam para "estourou o tempo".O valor do timeout vem do ambiente, com um default seguro de 10 minutos:
scripts/safe-test.mjs:20const TIMEOUT_MS = Number(process.env.SAFE_TEST_TIMEOUT_MS ?? 600_000); // 600000 = 10 min
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.unref(), traria de volta o próprio bug que combate — um processo que não sai. Uma linha cancela esse risco.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:
/** 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 o sinaliza com código ≠ 0, então o try/catch trata o no-match como sucesso, não como erro.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); });
killGroup + sweep. Não há rota de fuga que pule a limpeza.Cada camada tem uma falha conhecida — que é exatamente o trabalho da camada seguinte. O piso é a varredura por nome.
| Camada | O que pega | O que passa adiante |
|---|---|---|
| timeouts da config + forks | a maioria dos hangs — falham rápido e o Vitest force-killa o fork | um 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 syscall | um worker que já reparenteou para o PID 1 antes do kill |
| varredura pkill | qualquer vitest pelo nome, inclusive órfãos no PID 1 | — (este é o piso) |
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.
.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.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?
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.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.pkill -9 -f vitest casa pelo nome e o derruba. É o piso.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.)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.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.Três perguntas. O placar acompanha seus acertos.
safe-test.mjs cria a suíte com detached:true?detached:true ⇒ setsid ⇒ 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.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.testTimeout/teardownTimeout limitados e usar pool:'forks' em vez de confiar só no wrapper que mata o grupo?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.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".