Executando processos externos com Process
Objective
Chamar git, ffmpeg, docker ou um script a partir de C# parece uma linha,
Process.Start, e dá errado de quatro jeitos previsíveis: argumentos que uma
concatenação de string estragou ou deixou um atacante reescrever, um filho que
trava para sempre porque ninguém leu o stderr dele, um timeout que derruba o
programa mas deixa os filhos rodando, e um código de saída que ninguém
interpretou. Este concept cobre Process e ProcessStartInfo como você usa no
.NET 8 e no 10 (o padrão que funciona em todo lugar), o deadlock e a correção,
timeout com Kill(entireProcessTree: true), códigos de saída e sinais, e os
atalhos que o .NET 11 acrescenta (Process.Run, RunAndCaptureText,
ReadAllLines, ProcessExitStatus), incluindo um comportamento do timeout deles
que me surpreendeu. Os códigos de saída do seu próprio programa estão no concept
de aplicativos de console.
Use Cases
- Rodar
git,ffmpegou um linter a partir de uma ferramenta de build ou de um worker e devolver o que ele imprimiu. - Chamar um programa de linha de comando legado cujos parâmetros vêm de entrada do usuário.
- Rodar um script com prazo num passo de CI e limpar tudo o que ele iniciou.
- Distinguir "a ferramenta falhou" de "a ferramenta foi morta" numa política de retry.
Deep Dive
Inicie sem shell, um argumento por item
Todos os números e mensagens abaixo foram executados no .NET 11.0.0 (SDK
11.0.100), macOS, com sh como filho.
csharpvar psi = new ProcessStartInfo("printf") { RedirectStandardOutput = true };
psi.ArgumentList.Add("[%s]\n");
psi.ArgumentList.Add("hello world");
psi.ArgumentList.Add("a \"quoted\" $HOME");
using var p = Process.Start(psi)!;
Console.WriteLine(p.StandardOutput.ReadToEnd());
// [hello world]
// [a "quoted" $HOME]ArgumentList entrega cada item ao filho como um argumento. A mesma chamada com
a propriedade de string, Arguments = "[%s]\\n hello world", imprimiu [hello]
e [world]: a string é dividida nos espaços, e quem cuida de aspas e escapes é
você. UseShellExecute é false por padrão no .NET, então nenhum shell entra no
caminho e $HOME ou ; rm -rf continuam sendo caracteres comuns. Nunca monte
uma string de argumentos a partir de entrada do usuário. Definir as duas
propriedades falha no start com
InvalidOperationException: Only one of Arguments or ArgumentList may be used.
Um programa que não existe lança exceção no Start, não depois:
Win32Exception: An error occurred trying to start process 'definitely-not-a-program' with working directory '...'. No such file or directory.
Capture isso onde você inicia o processo, é o caso "ferramenta não instalada".
WorkingDirectory e psi.Environment["FOO"] = "bar" definem a pasta e as
variáveis do filho (o filho imprimiu bar e /private/tmp).
Redirecionando a saída, e o deadlock
RedirectStandardOutput e RedirectStandardError entregam os pipes como
StreamReader. Um pipe tem um buffer fixo. Quando o filho o enche e você não
está lendo aquele pipe, o filho bloqueia no write, e se você está bloqueado
esperando o outro pipe, os dois esperam para sempre. O código clássico faz
exatamente isso:
csharp// o filho escreve 200 KB no stderr e depois "done" no stdout
var o = p.StandardOutput.ReadToEnd(); // espera o stdout fechar...
var e = p.StandardError.ReadToEnd(); // ...mas o filho está preso escrevendo no stderrRodei com uma guarda de 3 segundos: não terminou, e HasExited era False.
Nada lança exceção, o programa simplesmente para. A correção é drenar os dois
pipes ao mesmo tempo e só então esperar:
csharpvar o = p.StandardOutput.ReadToEndAsync();
var e = p.StandardError.ReadToEndAsync();
await Task.WhenAll(o, e);
await p.WaitForExitAsync();
// out=done err=200000 exit=0Inicie as duas leituras antes de aguardar qualquer uma. Redirecionar só um
stream, ou nenhum, não é problema: sem redirecionar, o filho escreve direto no
seu console. Ler um stream que você não redirecionou lança
InvalidOperationException: StandardOut has not been redirected or the process hasn't started yet.
A alternativa mais antiga é BeginOutputReadLine com OutputDataReceived.
Funciona, com uma armadilha: WaitForExit() sem argumento também espera os
streams de evento chegarem ao fim do arquivo. Com um shell que saiu na hora mas
deixou um sleep 2 segurando o pipe, WaitForExit() voltou depois de 2012 ms,
enquanto WaitForExit(5000) voltou em 3 ms com HasExited verdadeiro. É um
motivo comum de passo de build "travado": um neto ainda é dono do pipe.
Para entrada, ligue RedirectStandardInput, escreva e feche o writer. O cat
devolveu hello só depois de StandardInput.Close(), porque fechar é o que
avisa ao filho que não há mais entrada.
Timeout e matar a árvore
Process não tem timeout próprio. Cancele a espera e mate:
csharpusing var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try { await p.WaitForExitAsync(cts.Token); }
catch (OperationCanceledException)
{
p.Kill(entireProcessTree: true);
await p.WaitForExitAsync();
}Kill() mata um processo. Com sh -c "sleep 310 & echo $!; wait" como filho,
depois de Kill(false) o pai estava morto e o neto sleep continuava vivo;
depois de Kill(true) os dois tinham sumido. Um neto sobrevivente também mantém
seu pipe de stdout aberto, então um leitor esperando o fim do arquivo trava
depois do kill "bem-sucedido". Use Kill(true) por padrão e aguarde a saída
antes de ler ExitCode: ler antes lança
InvalidOperationException: Process must exit before requested information can be determined.
Códigos de saída e sinais
Uma saída normal dá o código do próprio programa (exit 3 deu ExitCode=3). No
Unix, um processo morto por sinal não tem código de saída, e o .NET reporta
128 + sinal: um filho que enviou SIGTERM a si mesmo deu 143, e um morto por
Kill() deu 137 (SIGKILL é o 9). Então código > 128 significa "morto por
sinal" na prática, com a ressalva de que um programa também pode sair com 130 por
conta própria. No Windows não há sinais e o código é o que o programa devolveu.
No .NET 11 você pode enviar um sinal e ler o sinal de volta:
Process.Signal(PosixSignal.SIGTERM) devolveu True e
WaitForExitStatusAsync() entregou um ProcessExitStatus com ExitCode=143 e
Signal=SIGTERM. Um SIGTERM educado primeiro, e um kill depois de um período de
graça, é a forma elegante de parar um servidor filho.
O que o .NET 11 acrescenta
O .NET 11 coloca o padrão inteiro acima atrás de métodos estáticos em Process
(documentados em What's new in .NET 11 libraries). No runtime GA eu chamei cada
um:
csharpvar r = Process.RunAndCaptureText("sh", ["-c", "echo out; echo err >&2; exit 3"]);
// r.StandardOutput = "out", r.StandardError = "err", r.ExitStatus.ExitCode = 3, r.ProcessId > 0
var st = Process.Run("sleep", ["30"], silent: false, timeout: TimeSpan.FromMilliseconds(300));
// st.ExitCode = 137, st.Signal = SIGKILL, st.Canceled = TrueRun,RunAsync: iniciam, esperam e devolvem umProcessExitStatus(ExitCode,Signal,Canceled).silent: truedescarta a saída do filho (nada foi impresso),falsedeixa passar para o seu console.RunAndCaptureText,RunAndCaptureTextAsync: o padrão de redirecionar e ler deste concept, com os dois streams separados. 2 MB em cada stream terminaram sem deadlock.ReadAllLines,ReadAllText,ReadAllBytes(e versões assíncronas) numProcessque você mesmo iniciou.ReadAllLinesproduzProcessOutputLinecomContente uma flagStandardError. A ordem não é a ordem em que o filho escreveu: com três linhas intercaladas em cada stream, vieram primeiro as linhas do stderr e depois as do stdout, então use a flag, não a posição.StartAndForgetdevolve o id do processo e não acompanha o filho.ProcessStartInfotambém ganhouStartSuspended(documentado para Windows e macOS),KillOnParentExit(Linux, Android e Windows, o compilador avisou no macOS),CreateNewProcessGroup(Windows) eInheritedHandles. Não executei estes.
O timeout é onde os atalhos diferem entre si e do padrão manual. Run com
timeout e RunAsync com token cancelado não lançam exceção: devolvem
Canceled=True, ExitCode=137, Signal=SIGKILL. RunAndCaptureText com
timeout lança TimeoutException: The operation has timed out., e
RunAndCaptureTextAsync com token cancelado lança OperationCanceledException.
E em Run e em RunAndCaptureText o neto do experimento anterior continuava
vivo depois do timeout. Ou seja, o atalho mata o processo que iniciou, não a
árvore. Se o seu filho inicia filhos, mantenha a versão manual com
Kill(entireProcessTree: true), ou coloque-os num script que faz a limpeza.
Observei isso só no macOS; confira a documentação da sua plataforma.
Trade-offs
- O atalho é mais curto, a versão manual é a que você consegue ajustar.
RunAndCaptureTextelimina a classe de bugs do deadlock. Ele não transmite a saída enquanto o filho roda, não envia entrada e não mata a árvore.csharpvar r = Process.RunAndCaptureText("git", ["rev-parse", "HEAD"], TimeSpan.FromSeconds(10)); - Capturar texto mantém tudo em memória. Um filho que imprime gigabytes
enche o seu heap. Para saída grande, leia linha a linha (
ReadAllLines) ou copie o stream para um arquivo. ArgumentListprotege do shell, não do programa. Um valor que começa com-ainda é uma opção paragitoutar. Coloque--antes dos valores do usuário quando o programa suportar, e valide o valor.- Código de saída 0 é convenção, não lei.
grepdevolve 1 para "sem resultado",diffdevolve 1 para "diferente". Mapeie os códigos por ferramenta em vez de tratar todo não-zero como erro. 128 + né uma leitura de Unix. Código portável confereExitStatus.Signalno .NET 11 ou a plataforma antes de assumir um sinal.- Não envolva isso numa thread por chamada. As leituras assíncronas e o
WaitForExitAsyncnão bloqueiam uma thread enquanto o filho trabalha.