Processos Filhos em Zig: stdout, stderr, Exit Code e Timeout

Para executar um comando externo em Zig, use std.process.Child.run quando o trabalho for curto e a saída puder ser capturada em memória. Passe cada argumento separadamente em argv, libere stdout e stderr com o mesmo allocator e valide o campo term: conseguir iniciar o processo não significa que o comando terminou com sucesso.

Quando você precisa acompanhar logs enquanto o programa roda, enviar dados por stdin, impor timeout, cancelar ou controlar sinais, configure um std.process.Child, chame spawn e finalize com wait. O cuidado mais importante é drenar stdout e stderr sem deadlock: se ambos forem pipes e um deles encher enquanto o pai lê apenas o outro, filho e pai podem ficar bloqueados para sempre.

Este guia cobre os dois níveis de API, códigos de saída, limites de memória, pipes, timeout, segurança e um checklist para CLIs, ferramentas internas e automações. Como a biblioteca padrão do Zig continua evoluindo antes do 1.0, confirme nomes de campos e assinaturas na versão fixada pelo projeto.

Resposta rápida

NecessidadeAbordagem recomendada
Rodar comando curto e capturar tudostd.process.Child.run
Mostrar saída ao usuário em tempo realherdar stdout e stderr ou fazer streaming
Enviar uma entrada pequenapipe em stdin, escrever e fechar
Capturar muita saídaarquivo temporário, streaming ou limite explícito
Capturar stdout e stderr volumososleitura concorrente ou event loop
Executar pipeline de shellprefira montar processos; use shell só quando necessário
Aplicar timeoutspawn + relógio + encerramento + wait
Executar input do usuárioargumentos separados, nunca concatenação em sh -c
Rodar em produçãolimite de saída, timeout, log estruturado e cleanup

Exemplo básico com Child.run

O caminho mais simples é executar o programa, esperar e receber a saída acumulada:

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const result = try std.process.Child.run(.{
        .allocator = allocator,
        .argv = &.{ "git", "rev-parse", "--short", "HEAD" },
        .max_output_bytes = 64 * 1024,
    });
    defer allocator.free(result.stdout);
    defer allocator.free(result.stderr);

    switch (result.term) {
        .Exited => |code| {
            if (code != 0) {
                std.log.err("git falhou code={d} stderr={s}", .{
                    code,
                    std.mem.trim(u8, result.stderr, " \r\n"),
                });
                return error.ComandoFalhou;
            }
        },
        else => {
            std.log.err("git terminou de forma anormal: {}", .{result.term});
            return error.ComandoInterrompido;
        },
    }

    const commit = std.mem.trim(u8, result.stdout, " \r\n");
    std.debug.print("commit atual: {s}\n", .{commit});
}

Child.run é uma boa escolha porque concentra o ciclo spawn + captura + wait. O limite de saída evita que uma ferramenta com bug, um comando muito barulhento ou uma entrada maliciosa consuma memória sem controle. O nome exato da opção de limite pode variar entre releases; procure a definição de RunResult e das opções de run na stdlib instalada.

Três recursos precisam de ownership explícito:

  1. result.stdout foi alocado e deve ser liberado;
  2. result.stderr também foi alocado, mesmo quando está vazio;
  3. o allocator precisa continuar válido até os dois free.

Esse padrão combina bem com uma CLI profissional em Zig e com ferramentas internas para DevOps.

Spawn não é sucesso

Há duas famílias de falha diferentes.

A primeira ocorre antes de o programa começar:

  • executável não encontrado no PATH;
  • permissão negada;
  • diretório de trabalho inválido;
  • falta de memória ou descritores;
  • formato de executável incompatível.

Essas falhas aparecem como erro de run ou spawn.

A segunda ocorre depois que o filho começou:

  • encerrou com código 1, 2 ou outro valor;
  • foi morto por sinal;
  • recebeu uma exceção do sistema;
  • foi interrompido pelo mecanismo de timeout;
  • escreveu uma mensagem de erro e saiu normalmente com código não zero.

Por isso, este código é incompleto:

_ = try std.process.Child.run(.{
    .allocator = allocator,
    .argv = &.{ "git", "status" },
});

O try prova apenas que a operação da API foi concluída. O contrato do comando está em term. Em Unix, o caso normal costuma ser .Exited => code; outros targets podem representar término anormal de outra forma.

Também não trate stderr como sinônimo de falha. Muitas ferramentas escrevem progresso ou warnings em stderr e saem com zero. A regra deve vir da documentação do executável:

  • exit code decide sucesso ou falha;
  • stdout contém o resultado consumível;
  • stderr contém diagnóstico humano;
  • códigos diferentes de zero podem ter significados específicos.

grep, por exemplo, tradicionalmente usa 1 para “nenhuma correspondência”, não para erro operacional. Uma integração correta traduz o contrato da ferramenta em um enum ou error set próprio.

argv direto é melhor do que shell

std.process.Child não interpreta uma linha de shell. Cada elemento de argv é um argumento separado:

.argv = &.{
    "git",
    "show",
    "--format=%H",
    "--",
    caminho_recebido,
},

Isso é uma vantagem. Um caminho como relatorios/agosto final.txt continua sendo um único argumento. Caracteres como ;, &, |, $ e espaços não viram operadores do shell.

Evite:

// Perigoso se arquivo vier de usuário, API ou configuração externa.
.argv = &.{ "sh", "-c", comando_concatenado },

Uma entrada como arquivo.txt; curl exemplo muda o programa executado. Mesmo quando não existe atacante, quoting inconsistente quebra com espaços, aspas, caracteres Unicode e diferenças entre Bash, PowerShell e cmd.exe.

Se você precisa do equivalente a:

compactador arquivo.log | uploader --stdin

há duas alternativas:

  1. criar os dois filhos e conectar a saída de um à entrada do outro;
  2. chamar um shell explicitamente, somente para uma expressão fixa e controlada.

A primeira opção é mais trabalhosa, porém oferece exit code individual, portabilidade consciente e menor superfície de command injection.

Controle manual com spawn e wait

Use o ciclo manual quando precisar configurar o processo antes da execução:

const std = @import("std");

pub fn runStreaming(allocator: std.mem.Allocator) !void {
    var child = std.process.Child.init(
        &.{ "zig", "build", "test" },
        allocator,
    );

    child.stdin_behavior = .Ignore;
    child.stdout_behavior = .Inherit;
    child.stderr_behavior = .Inherit;

    try child.spawn();
    const term = try child.wait();

    switch (term) {
        .Exited => |code| if (code != 0) return error.TestesFalharam,
        else => return error.ProcessoInterrompido,
    }
}

Herdar os fluxos é a opção mais simples para uma CLI interativa: o usuário vê cores, progresso e prompts diretamente. Também evita armazenar logs gigantes no heap.

Escolha o comportamento por fluxo:

  • Inherit: filho usa o terminal ou redirecionamento do pai;
  • Ignore: conecta a uma fonte ou destino descartável, conforme a plataforma;
  • Pipe: pai recebe um handle para ler ou escrever;
  • Close ou equivalente: disponível conforme a versão e o fluxo.

Os identificadores exatos dos enums mudaram entre versões de Zig. Fixe a versão no CI e consulte o código-fonte da stdlib; não copie cegamente um snippet criado para outra release.

Como enviar dados pelo stdin

Para alimentar um comando que aceita entrada padrão:

  1. configure stdin como pipe;
  2. chame spawn;
  3. escreva todos os bytes;
  4. feche o pipe para enviar EOF;
  5. drene as saídas;
  6. chame wait.

O fechamento é essencial. Muitos programas continuam esperando mais dados enquanto não recebem EOF.

Pseudocódigo próximo da API real:

var child = std.process.Child.init(
    &.{ "sha256sum", "-" },
    allocator,
);
child.stdin_behavior = .Pipe;
child.stdout_behavior = .Pipe;
child.stderr_behavior = .Pipe;

try child.spawn();

try child.stdin.?.writeAll(payload);
child.stdin.?.close();
child.stdin = null;

// Drene stdout e stderr de forma segura antes/de forma coordenada com wait.

Não envie senha, token ou chave na linha de comando. Argumentos podem aparecer em listagens de processos, logs de CI e telemetria. Prefira stdin, arquivo com permissão restrita, descritor herdado ou mecanismo de secrets da plataforma.

O deadlock clássico de stdout e stderr

Considere um filho que escreve 10 MiB em stderr antes de escrever a resposta em stdout. O pai faz:

  1. stdout até EOF;
  2. só depois lê stderr;
  3. chama wait.

O pipe de stderr possui capacidade limitada. Quando enche, o filho bloqueia tentando escrever. Como ele está bloqueado, não fecha stdout. O pai espera EOF em stdout; o filho espera espaço em stderr. Deadlock.

As soluções são:

Usar Child.run com limite

É a melhor opção para comandos curtos e saída moderada, pois a implementação da stdlib coordena a captura.

Herdar um dos fluxos

Capture apenas o dado estruturado em stdout e deixe stderr aparecer no terminal:

stdout -> pipe para o pai
stderr -> terminal herdado

Redirecionar para arquivo

Para compilação, backup ou migração com logs grandes, redirecione stderr para um arquivo e mantenha somente um resumo na memória. Isso também preserva diagnóstico após uma falha.

Drenar em paralelo

Crie uma thread para cada pipe ou use um event loop com poll, epoll, kqueue ou mecanismo equivalente. Cada consumidor aplica limite e continua drenando mesmo depois de atingir o limite armazenado — descartar o excedente é diferente de parar de ler.

Não resolva apenas aumentando o buffer. A quantidade produzida pode ser maior e o problema volta.

Limites de memória e saída hostil

Todo comando externo deve ser tratado como uma dependência falível. Mesmo um executável confiável pode receber uma entrada que gere saída enorme.

Defina por chamada:

  • máximo de bytes de stdout;
  • máximo de bytes de stderr;
  • política ao exceder: falhar, truncar ou salvar em arquivo;
  • encoding esperado;
  • prazo máximo;
  • tamanho máximo da entrada;
  • diretório de trabalho permitido.

Não pressuponha que a saída é UTF-8. Comandos podem gerar bytes binários, encoding do sistema ou nomes de arquivo inválidos como texto. Se a saída será logada ou incorporada em JSON, valide ou escape antes.

Para parsing estruturado, prefira uma opção estável do programa, como --json, --porcelain ou -z, em vez de analisar mensagens destinadas a humanos. A disciplina é a mesma do guia de parsing e serialização em Zig.

Timeout: o ciclo completo

Um timeout correto não é apenas “dormir e chamar kill”. O fluxo é:

  1. iniciar o filho;
  2. registrar um deadline monotônico;
  3. drenar os pipes enquanto monitora o prazo;
  4. quando vencer, solicitar encerramento;
  5. aguardar uma pequena janela de graça, se aplicável;
  6. forçar a interrupção se o processo ignorar o pedido;
  7. chamar wait para fazer o reap;
  8. fechar handles e liberar buffers;
  9. retornar um erro que diferencie timeout de exit code comum.

O monitor deve usar relógio monotônico, não horário civil. Mudanças de NTP ou relógio do sistema não podem estender ou encurtar o prazo.

Um esqueleto conceitual:

var child = std.process.Child.init(argv, allocator);
child.stdout_behavior = .Pipe;
child.stderr_behavior = .Pipe;
try child.spawn();

const deadline = monotonicNow() + timeout_ns;
var timed_out = false;

while (!processFinished(&child)) {
    try drainAvailableOutput(&child);

    if (monotonicNow() >= deadline) {
        timed_out = true;
        try requestTermination(&child);
        break;
    }
}

const term = try child.wait();
if (timed_out) return error.ProcessoExpirou;
try validateTerm(term);

As funções de consulta e encerramento variam por versão e sistema operacional. O ponto arquitetural é manter captura, relógio, sinal e wait no mesmo state machine.

Em Unix, há outra decisão: matar apenas o filho direto ou o grupo de processos. Se você iniciar sh -c e ele criar compactador | uploader, encerrar somente o shell pode deixar netos rodando. Ferramentas de automação robustas criam grupo/sessão própria e aplicam o sinal ao grupo. No Windows, o equivalente operacional costuma envolver Job Objects. Portabilidade exige uma camada específica por plataforma.

Diretório, ambiente e PATH

Processos filhos herdam estado do pai por padrão, mas automações reproduzíveis devem ser explícitas.

Diretório de trabalho

Não dependa de “quem chamou a CLI”. Configure o diretório do repositório, workspace ou job. Antes, valide que ele existe e que não aponta para uma área controlada por usuário quando o comando manipula arquivos sensíveis.

Variáveis de ambiente

Construa um ambiente mínimo quando necessário. Evite repassar por acidente:

  • tokens de deploy;
  • credenciais de cloud;
  • proxies internos;
  • flags de debug;
  • variáveis que mudam locale e formato de saída.

Para ferramentas que precisam do ambiente completo, remova ao menos secrets que não fazem parte do contrato do filho. Veja também o guia de configuração via XDG e variáveis de ambiente.

Resolução do executável

PATH é conveniente, mas pode ser manipulado. Em serviço privilegiado, agente de CI ou daemon, prefira caminho absoluto ou um PATH conhecido. Registre a versão do executável, não apenas o nome, quando a compatibilidade for crítica.

Cleanup em todos os caminhos

Processos combinam recursos de vários tipos: PID/handle, pipes, buffers, threads leitoras e arquivos de log. Estruture a função com errdefer durante a inicialização e defer após adquirir ownership estável.

Regras práticas:

  • se spawn funcionou, algum caminho precisa executar wait ou mecanismo equivalente;
  • feche o stdin depois de enviar a entrada;
  • não use um pipe depois de transferir ou fechar seu handle;
  • espere threads de drain antes de liberar os buffers compartilhados;
  • mate o filho em caminhos de erro somente quando o contrato exigir;
  • não confie em std.process.exit para cleanup, pois ele pula defer.

O padrão de graceful shutdown com SIGTERM também se aplica ao pai: se sua CLI receber cancelamento, decida como propagar o sinal ao filho e como evitar processos órfãos.

Observabilidade sem vazar dados

Para diagnosticar uma execução, registre:

  • nome lógico da operação;
  • executável ou ferramenta;
  • duração;
  • exit code ou tipo de término;
  • se houve timeout;
  • bytes capturados em cada fluxo;
  • se a saída foi truncada;
  • ID do job ou correlação.

Não registre automaticamente:

  • linha de comando completa com credenciais;
  • conteúdo integral de stderr sem limite;
  • variáveis de ambiente;
  • payload enviado ao stdin;
  • caminhos privados sem necessidade.

Uma mensagem útil e segura:

child_process operation=git_fetch duration_ms=1842 exit_code=128 stderr_bytes=317 truncated=false

Associe isso a métricas de latência e falha conforme o guia de observabilidade com logs e Prometheus.

Testes recomendados

Não teste somente /bin/echo. Crie um pequeno executável auxiliar para reproduzir cenários:

  • sai com código configurável;
  • escreve N bytes em stdout;
  • escreve N bytes em stderr;
  • alterna escrita nos dois fluxos;
  • lê até EOF no stdin;
  • dorme por um período;
  • ignora um sinal em POSIX;
  • cria um processo neto;
  • produz bytes que não são UTF-8.

Então valide:

  1. sucesso com código zero;
  2. falha com código não zero;
  3. executável inexistente;
  4. saída acima do limite;
  5. stdout e stderr volumosos ao mesmo tempo;
  6. timeout e reap do filho;
  7. cancelamento do processo pai;
  8. argumentos com espaços e caracteres especiais;
  9. ambiente mínimo;
  10. ausência de leak no std.testing.allocator.

Esse conjunto encontra bugs que um teste feliz jamais mostra, especialmente deadlocks que só aparecem quando o pipe enche.

Checklist de produção

  • o projeto fixa a versão de Zig usada pela API de Child;
  • argumentos são passados separadamente, sem concatenação de shell;
  • term é validado, não apenas o retorno de spawn;
  • stdout e stderr possuem limite ou destino de streaming;
  • os dois pipes são drenados sem deadlock;
  • stdin é fechado para enviar EOF;
  • todo filho iniciado passa por wait ou reap equivalente;
  • timeout usa relógio monotônico;
  • pipelines e processos netos têm política de encerramento;
  • diretório de trabalho é explícito;
  • ambiente herdado não vaza secrets desnecessários;
  • executável vem de caminho ou PATH confiável;
  • logs não incluem tokens, payloads ou argv sensível;
  • testes cobrem saída grande, falha, timeout e cancelamento;
  • diferenças entre POSIX e Windows estão isoladas.

Perguntas frequentes

Como executar um comando externo em Zig?

Use std.process.Child.run para execução curta com captura em memória. Passe allocator e argv, libere as duas saídas e confira term. Para controle avançado, use Child.init, configure os fluxos, chame spawn e finalize com wait.

Child.run executa por meio do Bash?

Não. Ele inicia o executável diretamente. Pipes, redirecionamentos, glob e && só existem se você chamar um shell explicitamente.

Como saber se o comando teve sucesso?

Aceite sucesso apenas quando o tipo de término e o exit code atendem ao contrato do programa. Na maioria dos comandos, isso significa encerramento normal com código zero.

Como evitar deadlock em stdout e stderr?

Use a captura coordenada de Child.run, herde ou redirecione um fluxo, ou drene os dois simultaneamente. Nunca espere EOF de um pipe enquanto ignora outro que pode encher.

Como implementar timeout?

Faça spawn, monitore um deadline monotônico, interrompa o processo quando vencer e sempre chame wait. Se houver netos, defina também uma política para o grupo de processos.

É seguro usar sh -c com texto do usuário?

Não. Construa argv diretamente. Se um shell for obrigatório, mantenha o script fixo e restrinja rigorosamente os valores incorporados.

Conclusão

Executar processos filhos em Zig é simples no caso básico e exige engenharia cuidadosa no caso real. std.process.Child.run resolve comandos curtos com captura limitada; spawn e wait oferecem o controle necessário para streaming, entrada, timeout e cancelamento.

O design seguro tem cinco regras: argumentos separados, validação de exit code, limites de saída, drain simultâneo de stdout e stderr e cleanup com wait em todos os caminhos. Com essas garantias, uma CLI Zig pode orquestrar compiladores, Git, compactadores e ferramentas de infraestrutura sem depender de um shell frágil — e sem deixar deadlocks ou processos órfãos escondidos no pipeline.

Continue com o guia de automação e scripts em Zig, a referência de std.process e as práticas de error handling para transformar esse padrão em uma camada reutilizável do seu projeto.

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.