Às vezes, o processo trava enquanto aguarda a Saída

13

Qual pode ser o motivo do meu processo interrompido enquanto aguardo a saída?

Esse código precisa iniciar o script do powershell, que executa várias ações, por exemplo, iniciar a recompilação do código via MSBuild, mas provavelmente o problema é que gera muita saída e esse código fica travado enquanto espera para sair, mesmo após o script do shell de energia ter sido executado corretamente

é meio "estranho" porque às vezes esse código funciona bem e às vezes fica preso.

O código trava em:

process.WaitForExit (ProcessTimeOutMiliseconds);

O script do PowerShell é executado em 1-2seg, enquanto o tempo limite é 19seg.

public static (bool Success, string Logs) ExecuteScript(string path, int ProcessTimeOutMiliseconds, params string[] args)
{
    StringBuilder output = new StringBuilder();
    StringBuilder error = new StringBuilder();

    using (var outputWaitHandle = new AutoResetEvent(false))
    using (var errorWaitHandle = new AutoResetEvent(false))
    {
        try
        {
            using (var process = new Process())
            {
                process.StartInfo = new ProcessStartInfo
                {
                    WindowStyle = ProcessWindowStyle.Hidden,
                    FileName = "powershell.exe",
                    RedirectStandardOutput = true,
                    RedirectStandardError = true,
                    UseShellExecute = false,
                    Arguments = $"-ExecutionPolicy Bypass -File \"{path}\"",
                    WorkingDirectory = Path.GetDirectoryName(path)
                };

                if (args.Length > 0)
                {
                    var arguments = string.Join(" ", args.Select(x => $"\"{x}\""));
                    process.StartInfo.Arguments += $" {arguments}";
                }

                output.AppendLine($"args:'{process.StartInfo.Arguments}'");

                process.OutputDataReceived += (sender, e) =>
                {
                    if (e.Data == null)
                    {
                        outputWaitHandle.Set();
                    }
                    else
                    {
                        output.AppendLine(e.Data);
                    }
                };
                process.ErrorDataReceived += (sender, e) =>
                {
                    if (e.Data == null)
                    {
                        errorWaitHandle.Set();
                    }
                    else
                    {
                        error.AppendLine(e.Data);
                    }
                };

                process.Start();

                process.BeginOutputReadLine();
                process.BeginErrorReadLine();

                process.WaitForExit(ProcessTimeOutMiliseconds);

                var logs = output + Environment.NewLine + error;

                return process.ExitCode == 0 ? (true, logs) : (false, logs);
            }
        }
        finally
        {
            outputWaitHandle.WaitOne(ProcessTimeOutMiliseconds);
            errorWaitHandle.WaitOne(ProcessTimeOutMiliseconds);
        }
    }
}

Roteiro:

start-process $args[0] App.csproj -Wait -NoNewWindow

[string]$sourceDirectory  = "\bin\Debug\*"
[int]$count = (dir $sourceDirectory | measure).Count;

If ($count -eq 0)
{
    exit 1;
}
Else
{
    exit 0;
}

Onde

$args[0] = "C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe"

Editar

Na solução da @ ingen, adicionei um pequeno invólucro que tenta executar novamente o MS Build desligado

public static void ExecuteScriptRx(string path, int processTimeOutMilliseconds, out string logs, out bool success, params string[] args)
{
    var current = 0;
    int attempts_count = 5;
    bool _local_success = false;
    string _local_logs = "";

    while (attempts_count > 0 && _local_success == false)
    {
        Console.WriteLine($"Attempt: {++current}");
        InternalExecuteScript(path, processTimeOutMilliseconds, out _local_logs, out _local_success, args);
        attempts_count--;
    }

    success = _local_success;
    logs = _local_logs;
}

Onde InternalExecuteScriptestá o código da ingen

Joelty
fonte
em que linha realmente o processo trava? e introduza seu código muito mais
Mr.AF
@ Mr.AF você está certo - feito.
Joelty 17/02
11
A chamada real do Powershell é uma coisa, no entanto, o que você NÃO está fornecendo é o restante do script que você está tentando processar enquanto DENTRO do Powershell. Chamar PowerShell em si não é o problema, mas dentro do que você está tentando fazer. Edite sua postagem e coloque as chamadas / comandos explícitos que você está tentando executar.
DRapp
11
É muito estranho eu tentei replicar o erro. Aconteceu aleatoriamente duas vezes em 20 tentativas ou algo assim e não consigo acioná-lo novamente.
KiKoS 17/02
11
@ Joelty, ohh bem interessante, você está dizendo que essa Rxabordagem funcionou (como não esgotou o tempo limite) mesmo com o processo disperso do MSBuild por aí, levando a uma espera indefinida? interessado em saber como isso foi tratado
Clint

Respostas:

9

Vamos começar com uma recapitulação da resposta aceita em um post relacionado.

O problema é que, se você redirecionar o StandardOutput e / ou o StandardError, o buffer interno poderá ficar cheio. Qualquer que seja a ordem que você use, pode haver um problema:

  • Se você esperar o processo sair antes de ler StandardOutput, o processo poderá bloquear a tentativa de gravação nele, para que o processo nunca termine.
  • Se você ler a partir do StandardOutput usando o ReadToEnd, seu processo poderá bloquear se o processo nunca fechar o StandardOutput (por exemplo, se nunca terminar, ou se estiver bloqueado na gravação no StandardError).

Mesmo a resposta aceita, no entanto, luta com a ordem de execução em certos casos.

EDIT: consulte as respostas abaixo para saber como evitar uma ObjectDisposedException se o tempo limite ocorrer.

É nesse tipo de situação, em que você deseja orquestrar vários eventos, que Rx realmente brilha.

Observe que a implementação .NET do Rx está disponível como o pacote System.Reactive NuGet.

Vamos nos aprofundar para ver como o Rx facilita o trabalho com eventos.

// Subscribe to OutputData
Observable.FromEventPattern<DataReceivedEventArgs>(process, nameof(Process.OutputDataReceived))
    .Subscribe(
        eventPattern => output.AppendLine(eventPattern.EventArgs.Data),
        exception => error.AppendLine(exception.Message)
    ).DisposeWith(disposables);

FromEventPatternpermite mapear ocorrências distintas de um evento para um fluxo unificado (também conhecido como observável). Isso nos permite manipular os eventos em um pipeline (com semântica semelhante ao LINQ). A Subscribesobrecarga usada aqui é fornecida com um Action<EventPattern<...>>e um Action<Exception>. Sempre que o evento observado é gerado, é sendere argsserá envolvido EventPatterne pressionado através do Action<EventPattern<...>>. Quando uma exceção é gerada no pipeline, Action<Exception>é usada.

Uma das desvantagens do Eventpadrão, claramente ilustrada neste caso de uso (e por todas as soluções alternativas na postagem mencionada), é que não é aparente quando / onde cancelar a inscrição dos manipuladores de eventos.

Com o Rx, retornamos IDisposablequando fazemos uma assinatura. Quando descartamos, efetivamente encerramos a assinatura. Com a adição do DisposeWithmétodo de extensão (emprestado do RxUI ), podemos adicionar vários IDisposables a CompositeDisposable(nomeados disposablesnos exemplos de código). Quando terminarmos, podemos encerrar todas as assinaturas com uma chamada para disposables.Dispose().

Certamente, não há nada que possamos fazer com o Rx que não possamos fazer com o vanilla .NET. O código resultante é muito mais fácil de se raciocinar, uma vez que você se adaptou à maneira funcional de pensar.

public static void ExecuteScriptRx(string path, int processTimeOutMilliseconds, out string logs, out bool success, params string[] args)
{
    StringBuilder output = new StringBuilder();
    StringBuilder error = new StringBuilder();

    using (var process = new Process())
    using (var disposables = new CompositeDisposable())
    {
        process.StartInfo = new ProcessStartInfo
        {
            WindowStyle = ProcessWindowStyle.Hidden,
            FileName = "powershell.exe",
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            Arguments = $"-ExecutionPolicy Bypass -File \"{path}\"",
            WorkingDirectory = Path.GetDirectoryName(path)
        };

        if (args.Length > 0)
        {
            var arguments = string.Join(" ", args.Select(x => $"\"{x}\""));
            process.StartInfo.Arguments += $" {arguments}";
        }

        output.AppendLine($"args:'{process.StartInfo.Arguments}'");

        // Raise the Process.Exited event when the process terminates.
        process.EnableRaisingEvents = true;

        // Subscribe to OutputData
        Observable.FromEventPattern<DataReceivedEventArgs>(process, nameof(Process.OutputDataReceived))
            .Subscribe(
                eventPattern => output.AppendLine(eventPattern.EventArgs.Data),
                exception => error.AppendLine(exception.Message)
            ).DisposeWith(disposables);

        // Subscribe to ErrorData
        Observable.FromEventPattern<DataReceivedEventArgs>(process, nameof(Process.ErrorDataReceived))
            .Subscribe(
                eventPattern => error.AppendLine(eventPattern.EventArgs.Data),
                exception => error.AppendLine(exception.Message)
            ).DisposeWith(disposables);

        var processExited =
            // Observable will tick when the process has gracefully exited.
            Observable.FromEventPattern<EventArgs>(process, nameof(Process.Exited))
                // First two lines to tick true when the process has gracefully exited and false when it has timed out.
                .Select(_ => true)
                .Timeout(TimeSpan.FromMilliseconds(processTimeOutMilliseconds), Observable.Return(false))
                // Force termination when the process timed out
                .Do(exitedSuccessfully => { if (!exitedSuccessfully) { try { process.Kill(); } catch {} } } );

        // Subscribe to the Process.Exited event.
        processExited
            .Subscribe()
            .DisposeWith(disposables);

        // Start process(ing)
        process.Start();

        process.BeginOutputReadLine();
        process.BeginErrorReadLine();

        // Wait for the process to terminate (gracefully or forced)
        processExited.Take(1).Wait();

        logs = output + Environment.NewLine + error;
        success = process.ExitCode == 0;
    }
}

Já discutimos a primeira parte, onde mapeamos nossos eventos para observáveis, para que possamos pular diretamente para a parte carnuda. Aqui atribuímos nosso observável à processExitedvariável, porque queremos usá-lo mais de uma vez.

Primeiro, quando a ativamos, ligando Subscribe. E mais tarde, quando queremos "aguardar" seu primeiro valor.

var processExited =
    // Observable will tick when the process has gracefully exited.
    Observable.FromEventPattern<EventArgs>(process, nameof(Process.Exited))
        // First two lines to tick true when the process has gracefully exited and false when it has timed out.
        .Select(_ => true)
        .Timeout(TimeSpan.FromMilliseconds(processTimeOutMilliseconds), Observable.Return(false))
        // Force termination when the process timed out
        .Do(exitedSuccessfully => { if (!exitedSuccessfully) { try { process.Kill(); } catch {} } } );

// Subscribe to the Process.Exited event.
processExited
    .Subscribe()
    .DisposeWith(disposables);

// Start process(ing)
...

// Wait for the process to terminate (gracefully or forced)
processExited.Take(1).Wait();

Um dos problemas com o OP é que ele pressupõe process.WaitForExit(processTimeOutMiliseconds)que o processo será encerrado quando o tempo limite expirar. Do MSDN :

Instrui o componente Processo a aguardar o número especificado de milissegundos para a saída do processo associado.

Em vez disso, quando atinge o tempo limite, ele simplesmente retorna o controle ao thread atual (ou seja, para de bloquear). Você precisa forçar manualmente a rescisão quando o processo atingir o tempo limite. Para saber quando o tempo limite ocorreu, podemos mapear o Process.Exitedevento para um processExitedobservável para processamento. Dessa forma, podemos preparar a entrada para o Dooperador.

O código é bastante auto-explicativo. Se exitedSuccessfullyo processo tiver terminado normalmente. Caso contrário exitedSuccessfully, a rescisão precisará ser forçada. Note-se que process.Kill()é executado de forma assíncrona, Ref observações . No entanto, ligar process.WaitForExit()imediatamente abrirá a possibilidade de conflitos novamente. Portanto, mesmo no caso de terminação forçada, é melhor deixar todos os materiais descartáveis ​​limpos quando o usingescopo terminar, pois a saída pode ser considerada interrompida / corrompida de qualquer maneira.

A try catchconstrução é reservada para o caso excepcional (sem trocadilhos) em que você se alinhou processTimeOutMillisecondscom o tempo real necessário para a conclusão do processo. Em outras palavras, uma condição de corrida ocorre entre o Process.Exitedevento e o cronômetro. A possibilidade disso acontecer é novamente ampliada pela natureza assíncrona de process.Kill(). Eu o encontrei uma vez durante o teste.


Para completar, o DisposeWithmétodo de extensão.

/// <summary>
/// Extension methods associated with the IDisposable interface.
/// </summary>
public static class DisposableExtensions
{
    /// <summary>
    /// Ensures the provided disposable is disposed with the specified <see cref="CompositeDisposable"/>.
    /// </summary>
    public static T DisposeWith<T>(this T item, CompositeDisposable compositeDisposable)
        where T : IDisposable
    {
        if (compositeDisposable == null)
        {
            throw new ArgumentNullException(nameof(compositeDisposable));
        }

        compositeDisposable.Add(item);
        return item;
    }
}
ingen
fonte
4
IMHO, definitivamente vale a pena. Boa resposta e boa introdução ao tópico sobre o RX.
quetzalcoatl 22/02
Obrigado!!! SeuExecuteScriptRx alças hangsperfeitamente. Infelizmente, travamentos ainda acontecem, mas eu apenas adicionei um pequeno wrapper sobre o seu ExecuteScriptRxque executa Retrye, em seguida, ele executa bem. O motivo de travamentos do MSBUILD pode ser a resposta @Clint. PS: Esse código me fez sentir estúpido <lol> É a primeira vez que vejoSystem.Reactive.Linq;
Joelty 24/02
O código do wrapper está no post principal
Joelty 24/02
3

Para o benefício dos leitores, vou dividir isso em 2 seções

Seção A: Problema e como lidar com cenários semelhantes

Seção B: Recriação de problemas e solução

Seção A: Problema

Quando esse problema ocorre - o processo aparece no gerenciador de tarefas, depois de 2-3 segundos desaparece (tudo bem), aguarda o tempo limite e a exceção é lançada System.InvalidOperationException: O processo deve sair antes que as informações solicitadas possam ser determinadas.

& Veja o Cenário 4 abaixo

No seu código:

  1. Process.WaitForExit(ProcessTimeOutMiliseconds); Com isso, você está esperando Processpara Timeout ou Exit , que já acontece primeiro .
  2. OutputWaitHandle.WaitOne(ProcessTimeOutMiliseconds)e errorWaitHandle.WaitOne(ProcessTimeOutMiliseconds); com isso você está esperando OutputData&ErrorData operação de leitura transmissão para sinalizar sua completa
  3. Process.ExitCode == 0 Obtém o status do processo ao sair

Diferentes configurações e suas advertências:

  • Cenário 1 (caminho feliz) : o processo é concluído antes do tempo limite e, portanto, sua saída de stdout e stderror também são concluídos antes e tudo está bem.
  • Cenário 2 : Processo, OutputWaitHandle & ErrorWaitHandle expiram, no entanto, stdoutput & stderror ainda estão sendo lidos e concluídos após o tempo limite do WaitHandlers. Isso leva a outra exceçãoObjectDisposedException()
  • Cenário 3 : O tempo limite do processo é excedido primeiro (19 s), mas stdout e stderror estão em ação; você espera que o WaitHandler atinja o tempo limite (19 s), causando um atraso adicional de + 19 s.
  • Cenário 4 : o tempo limite do processo e o código tentam consultar prematuramenteProcess.ExitCode resultando no erro System.InvalidOperationException: Process must exit before requested information can be determined.

Eu testei esse cenário mais de uma dúzia de vezes e funciona bem, as seguintes configurações foram usadas durante o teste

  • Tamanho do fluxo de saída variando de 5 KB a 198 KB, iniciando a construção de cerca de 2-15 projetos
  • Tempos limite prematuros e saídas de processo dentro da janela de tempo limite


Código atualizado

.
.
.
    process.BeginOutputReadLine();
    process.BeginErrorReadLine();

    //First waiting for ReadOperations to Timeout and then check Process to Timeout
    if (!outputWaitHandle.WaitOne(ProcessTimeOutMiliseconds) && !errorWaitHandle.WaitOne(ProcessTimeOutMiliseconds)
        && !process.WaitForExit(ProcessTimeOutMiliseconds)  )
    {
        //To cancel the Read operation if the process is stil reading after the timeout this will prevent ObjectDisposeException
        process.CancelOutputRead();
        process.CancelErrorRead();

        Console.ForegroundColor = ConsoleColor.Red;
        Console.WriteLine("Timed Out");
        Logs = output + Environment.NewLine + error;
       //To release allocated resource for the Process
        process.Close();
        return  (false, logs);
    }

    Console.ForegroundColor = ConsoleColor.Green;
    Console.WriteLine("Completed On Time");
    Logs = output + Environment.NewLine + error;
    ExitCode = process.ExitCode.ToString();
    // Close frees the memory allocated to the exited process
    process.Close();

    //ExitCode now accessible
    return process.ExitCode == 0 ? (true, logs) : (false, logs);
    }
}
finally{}

EDITAR:

Depois de horas brincando com o MSBuild, finalmente pude reproduzir o problema no meu sistema


Seção B: Recreação e solução de problemas

MSBuild tem-m[:number] opção usada para especificar o número máximo de processos simultâneos a serem usados ​​na criação.

Quando isso está ativado, o MSBuild gera vários nós que permanecem vivos mesmo após a conclusão da compilação. Agora, Process.WaitForExit(milliseconds)esperaria nunca sair e eventualmente esgotar o tempo limite

Consegui resolver isso de duas maneiras

  • Gerar o processo MSBuild indiretamente através do CMD

    $path1 = """C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe"" ""C:\Users\John\source\repos\Test\Test.sln"" -maxcpucount:3"
    $cmdOutput = cmd.exe /c $path1  '2>&1'
    $cmdOutput
  • Continue usando o MSBuild, mas certifique-se de definir o nodeReuse como False

    $filepath = "C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\MSBuild.exe"
    $arg1 = "C:\Users\John\source\repos\Test\Test.sln"
    $arg2 = "-m:3"
    $arg3 = "-nr:False"
    
    Start-Process -FilePath $filepath -ArgumentList $arg1,$arg2,$arg3 -Wait -NoNewWindow
  • Mesmo que a compilação paralela não esteja ativada, você ainda pode impedir que seu processo seja interrompido WaitForExitiniciando a compilação via CMD e, portanto, não cria uma dependência direta no processo de compilação

    $path1 = """C:\....\15.0\Bin\MSBuild.exe"" ""C:\Users\John\source\Test.sln"""
    $cmdOutput = cmd.exe /c $path1  '2>&1'
    $cmdOutput

A segunda abordagem é preferida, pois você não deseja que muitos nós do MSBuild estejam por aí.

Clint
fonte
Então, como eu disse acima, obrigado, isso "-nr:False","-m:3"parece ter corrigido o comportamento de suspensão do MSBuild, o que Rx solutiontornou todo o processo um tanto confiável (o tempo vai mostrar). Eu gostaria de poder aceitar as duas respostas ou dar duas recompensas
Joelty 24/02
@ Joelty Eu estava apenas tentando saber se a Rxabordagem na outra solução seria capaz de resolver o problema sem aplicar -nr:False" ,"-m:3". No meu entendimento, ele lida com a espera indefinida de impasses e outras coisas que eu havia abordado na seção 1. E a causa raiz na Seção 2 é o que eu acredito ser a causa raiz do problema que você enfrentou;) Eu posso estar errado e é por isso que Eu perguntei, só o tempo dirá ... Saúde !!
Clint
3

O problema é que, se você redirecionar o StandardOutput e / ou o StandardError, o buffer interno poderá ficar cheio.

Para resolver os problemas mencionados acima, você pode executar o processo em threads separados. Não uso WaitForExit, utilizo o evento encerrado pelo processo que retornará o ExitCode do processo de forma assíncrona, garantindo que ele tenha sido concluído.

public async Task<int> RunProcessAsync(params string[] args)
    {
        try
        {
            var tcs = new TaskCompletionSource<int>();

            var process = new Process
            {
                StartInfo = {
                    FileName = 'file path',
                    RedirectStandardOutput = true,
                    RedirectStandardError = true,
                    Arguments = "shell command",
                    UseShellExecute = false,
                    CreateNoWindow = true
                },
                EnableRaisingEvents = true
            };


            process.Exited += (sender, args) =>
            {
                tcs.SetResult(process.ExitCode);
                process.Dispose();
            };

            process.Start();
            // Use asynchronous read operations on at least one of the streams.
            // Reading both streams synchronously would generate another deadlock.
            process.BeginOutputReadLine();
            string tmpErrorOut = await process.StandardError.ReadToEndAsync();
            //process.WaitForExit();


            return await tcs.Task;
        }
        catch (Exception ee) {
            Console.WriteLine(ee.Message);
        }
        return -1;
    }

O código acima é testado em batalha, chamando FFMPEG.exe com argumentos de linha de comando. Eu estava convertendo arquivos mp4 para arquivos mp3 e fazendo mais de 1000 vídeos por vez sem falhar. Infelizmente, não tenho experiência direta com shell de energia, mas espero que isso ajude.

Alex
fonte
É estranho esse código, da mesma forma que outras soluções falharam (travaram) na PRIMEIRA tentativa e pareciam funcionar bem (como outras 5 tentativas, testarei mais). Btw por que você executar BegingOutputReadlinee, em seguida, executar ReadToEndAsyncem StandardError?
Joelty 20/02
O OP já está lendo de forma assíncrona, portanto, é improvável que um conflito no buffer do console seja o problema aqui.
yaakov 20/02
0

Não tenho certeza se esse é o seu problema, mas olhando para o MSDN parece haver alguma estranheza com o WaitForExit sobrecarregado quando você está redirecionando a saída de forma assíncrona. O artigo MSDN recomenda chamar o WaitForExit que não usa argumentos após chamar o método sobrecarregado.

Página do Google Docs localizada aqui. Texto relevante:

Quando a saída padrão foi redirecionada para manipuladores de eventos assíncronos, é possível que o processamento da saída não seja concluído quando esse método retornar. Para garantir que a manipulação de eventos assíncronos tenha sido concluída, chame a sobrecarga WaitForExit () que não usa parâmetro após receber um valor verdadeiro dessa sobrecarga. Para ajudar a garantir que o evento Exited seja tratado corretamente nos aplicativos Windows Forms, defina a propriedade SynchronizingObject.

A modificação do código pode ser algo como isto:

if (process.WaitForExit(ProcessTimeOutMiliseconds))
{
  process.WaitForExit();
}
Tyler Hundley
fonte
Existem alguns meandros com o uso de, process.WaitForExit()conforme indicado pelos comentários a esta resposta .
Nenhum