El proceso a veces se cuelga mientras espera la salida

13

¿Cuál puede ser la razón de que mi proceso se cuelgue mientras espero la salida?

Este código tiene que iniciar el script de PowerShell que en su interior realiza muchas acciones, por ejemplo, comenzar a recompilar el código a través de MSBuild, pero probablemente el problema es que genera demasiada salida y este código se atasca mientras espera salir incluso después de que el script de Power Shell se ejecute correctamente

es un poco "extraño" porque a veces este código funciona bien y otras simplemente se atasca.

El código se cuelga en:

process.WaitForExit (ProcessTimeOutMiliseconds);

El script de Powershell se ejecuta en 1-2 segundos, mientras que el tiempo de espera es de 19 segundos.

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);
        }
    }
}

Guión:

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;
}

dónde

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

Editar

A la solución de @ ingen agregué un pequeño contenedor que reintenta ejecutar MS Build colgado

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;
}

¿Dónde InternalExecuteScriptestá el código de ingen

Joelty
fuente
¿en qué línea el proceso realmente se cuelga? e introduce tu código mucho más
Mr.AF
@ Mr.AF tienes razón, listo.
Joelty
1
La llamada real de Powershell es una cosa, sin embargo, lo que NO está proporcionando es el resto del script que está tratando de procesar mientras está DENTRO de Powershell. Llamar a PowerShell en sí no es el problema, sino dentro de lo que está tratando de hacer. Edite su publicación y coloque la llamada / comandos explícitos que está intentando ejecutar.
DRapp
1
Es realmente extraño que intenté replicar el error. Sucedió al azar dos veces en 20 intentos o algo así y no puedo activarlo nuevamente.
KiKoS
1
@Joelty, ohh genial interesante, ¿estás diciendo que el Rxenfoque funcionó (ya que no se agotó el tiempo) incluso con el proceso de MSBuild perdido que condujo a una espera indefinida? interesado en saber cómo se manejó eso
Clint

Respuestas:

9

Comencemos con un resumen de la respuesta aceptada en una publicación relacionada.

El problema es que si redirige StandardOutput y / o StandardError, el búfer interno puede llenarse. Cualquiera sea el orden que use, puede haber un problema:

  • Si espera a que el proceso salga antes de leer StandardOutput, el proceso puede bloquear el intento de escribir en él, por lo que el proceso nunca termina.
  • Si lee desde StandardOutput usando ReadToEnd, su proceso puede bloquearse si el proceso nunca cierra StandardOutput (por ejemplo, si nunca termina, o si está bloqueado al escribir en StandardError).

Incluso la respuesta aceptada, sin embargo, lucha con el orden de ejecución en ciertos casos.

EDITAR: Vea las respuestas a continuación para saber cómo evitar una ObjectDisposedException si se produce el tiempo de espera.

Es en este tipo de situaciones, donde desea organizar varios eventos, que Rx realmente brilla.

Tenga en cuenta que la implementación .NET de Rx está disponible como el paquete System.Reactive NuGet.

Vamos a sumergirnos para ver cómo Rx facilita el trabajo con 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);

FromEventPatternnos permite asignar distintas ocurrencias de un evento a una secuencia unificada (también conocida como observable). Esto nos permite manejar los eventos en una tubería (con semántica similar a LINQ). La Subscribesobrecarga utilizada aquí se proporciona con an Action<EventPattern<...>>y an Action<Exception>. Cada vez que se produce el evento observado, su sendery argsserá envuelto EventPatterny empujado a través del Action<EventPattern<...>>. Cuando se genera una excepción en la canalización, Action<Exception>se utiliza.

Uno de los inconvenientes del Eventpatrón, claramente ilustrado en este caso de uso (y por todas las soluciones en la publicación referenciada), es que no es aparente cuándo / dónde cancelar la suscripción de los controladores de eventos.

Con Rx volvemos y IDisposablecuando hacemos una suscripción. Cuando lo desechamos, efectivamente finalizamos la suscripción. Con la adición del DisposeWithmétodo de extensión (prestado de RxUI ), podemos agregar múltiples IDisposables a un CompositeDisposable(nombrado disposablesen los ejemplos de código). Cuando hayamos terminado, podemos finalizar todas las suscripciones con una sola llamada disposables.Dispose().

Sin duda, no hay nada que podamos hacer con Rx, que no podríamos hacer con Vanilla .NET. El código resultante es mucho más fácil de razonar, una vez que se haya adaptado a la forma 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;
    }
}

Ya discutimos la primera parte, donde asignamos nuestros eventos a observables, para que podamos saltar directamente a la parte carnosa. Aquí asignamos nuestro observable a la processExitedvariable, porque queremos usarlo más de una vez.

Primero, cuando lo activamos, llamando Subscribe. Y más tarde cuando queremos 'esperar' su primer 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();

Uno de los problemas con OP es que supone process.WaitForExit(processTimeOutMiliseconds)que terminará el proceso cuando se agote el tiempo de espera. De MSDN :

Indica al componente Proceso que espere el número especificado de milisegundos para que el proceso asociado salga.

En cambio, cuando se agota el tiempo de espera, simplemente devuelve el control al subproceso actual (es decir, deja de bloquear). Debe forzar manualmente la terminación cuando el proceso agota el tiempo de espera. Para saber cuándo ha transcurrido el tiempo de espera, podemos asignar el Process.Exitedevento a un processExitedobservable para su procesamiento. De esta manera podemos preparar la entrada para el Dooperador.

El código se explica por sí mismo. Si exitedSuccessfullyel proceso habrá terminado con gracia. Si no exitedSuccessfully, la terminación deberá ser forzada. Tenga en cuenta que process.Kill()se ejecuta de forma asíncrona, comentarios de referencia . Sin embargo, llamar process.WaitForExit()inmediatamente después abrirá la posibilidad de puntos muertos nuevamente. Por lo tanto, incluso en el caso de terminación forzada, es mejor dejar que todos los desechables se limpien cuando usingfinalice el alcance, ya que la salida puede considerarse interrumpida / dañada de todos modos.

La try catchconstrucción está reservada para el caso excepcional (sin juego de palabras) en el que se haya alineado processTimeOutMillisecondscon el tiempo real necesario para completar el proceso. En otras palabras, se produce una condición de carrera entre el Process.Exitedevento y el temporizador. La posibilidad de que esto suceda se ve nuevamente aumentada por la naturaleza asincrónica de process.Kill(). Lo encontré una vez durante las pruebas.


Para completar, el DisposeWithmétodo de extensión.

/// <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
fuente
44
En mi humilde opinión, definitivamente vale la pena la recompensa. Buena respuesta, y buena introducción sobre el tema a RX.
quetzalcoatl
¡¡¡Gracias!!! Tus ExecuteScriptRxmanijas hangsperfectamente. Desafortunadamente aún se producen bloqueos, pero acabo de agregar un pequeño envoltorio sobre tu ExecuteScriptRxque funciona Retryy luego se ejecuta bien. La razón de los bloqueos de MSBUILD puede ser la respuesta de @Clint. PD: Ese código me hizo sentir estúpido <lol> Esa es la primera vez que veoSystem.Reactive.Linq;
Joelty
El código del contenedor está en la publicación principal
Joelty
3

Para beneficio de los lectores, voy a dividir esto en 2 secciones.

Sección A: problema y cómo manejar escenarios similares

Sección B: recreación del problema y solución

Sección A: Problema

Cuando ocurre este problema, el proceso aparece en el administrador de tareas, luego, después de que desaparece 2-3 segundos (está bien), luego espera el tiempo de espera y luego se lanza la excepción System.InvalidOperationException: el proceso debe salir antes de que se pueda determinar la información solicitada.

& Ver escenario 4 a continuación

En su código:

  1. Process.WaitForExit(ProcessTimeOutMiliseconds); Con esto, está esperando el TiempoProcess de espera o la Salida , lo que ocurra primero .
  2. OutputWaitHandle.WaitOne(ProcessTimeOutMiliseconds)y errorWaitHandle.WaitOne(ProcessTimeOutMiliseconds); con esto le está esperando OutputDatay ErrorDataflujo de operación de lectura para señalar su completa
  3. Process.ExitCode == 0 Obtiene el estado del proceso cuando salió

Diferentes configuraciones y sus advertencias:

  • Escenario 1 (Happy Path) : el proceso se completa antes del tiempo de espera y, por lo tanto, su stdoutput y stderror también finalizan antes y todo está bien.
  • Escenario 2 : Proceso, OutputWaitHandle y ErrorWaitHandle se agota el tiempo de espera; sin embargo, stdoutput & stderror todavía se está leyendo y completa después del tiempo de espera WaitHandlers. Esto lleva a otra excepciónObjectDisposedException()
  • Escenario 3 : el tiempo de espera del proceso primero (19 segundos) pero stdout y stderror está en acción, espera a que WaitHandler agote el tiempo de espera (19 segundos), causando un retraso adicional de + 19 segundos.
  • Escenario 4 : Proceso de tiempo de espera y los intentos de código a consulta antes de tiempo Process.ExitCodeque produce el error System.InvalidOperationException: Process must exit before requested information can be determined.

He probado este escenario más de una docena de veces y funciona bien, se han utilizado las siguientes configuraciones durante las pruebas

  • Tamaño del flujo de salida que varía de 5 KB a 198 KB iniciando la construcción de aproximadamente 2-15 proyectos
  • Tiempos de espera prematuros y salidas de proceso dentro de la ventana de tiempo de espera


Código actualizado

.
.
.
    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:

Después de horas de jugar con MSBuild, finalmente pude reproducir el problema en mi sistema


Sección B: Recreación de problemas y solución

MSBuild tiene un-m[:number]interruptor que se usa para especificar el número máximo de procesos concurrentes que se usarán al compilar.

Cuando esto está habilitado, MSBuild genera varios nodos que viven incluso después de que se completa la compilación. Ahora, Process.WaitForExit(milliseconds)esperaría nunca salir y, finalmente, el tiempo de espera

Pude resolver esto de dos maneras

  • Genera el proceso MSBuild indirectamente a través de 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
    
  • Continúe usando MSBuild pero asegúrese de establecer el nodo Reutilizar en Falso

    $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
    
  • Incluso si la compilación paralela no está habilitada, aún puede evitar que su proceso se bloquee WaitForExitiniciando la compilación a través de CMD y, por lo tanto, no cree una dependencia directa en el proceso de compilación

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

Se prefiere el segundo enfoque ya que no desea que haya demasiados nodos MSBuild por ahí.

Clint
fuente
Entonces, como dije anteriormente, gracias, esto "-nr:False","-m:3"parece haber solucionado el comportamiento de MSBuild hang-ish, lo que ha Rx solutionhecho que todo el proceso sea algo confiable (el tiempo se mostrará). Desearía poder aceptar ambas respuestas o dar dos recompensas
Joelty
@Joelty Solo intentaba saber si el Rxenfoque en la otra solución podía resolver el problema sin aplicar -nr:False" ,"-m:3". Según tengo entendido, maneja la espera indefinida de puntos muertos y otras cosas que había cubierto en la sección 1. Y la causa raíz en la Sección 2 es la que creo que es la causa raíz del problema que enfrentaron;) Puedo estar equivocado, por eso Pregunté, solo el tiempo lo dirá ...
Clint
3

El problema es que si redirige StandardOutput y / o StandardError, el búfer interno puede llenarse.

Para resolver los problemas mencionados anteriormente, puede ejecutar el proceso en subprocesos separados. No uso WaitForExit, utilizo el evento de proceso salido que devolverá el código de salida del proceso de forma asincrónica asegurando que se haya completado.

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;
    }

El código anterior se prueba en batalla llamando a FFMPEG.exe con argumentos de línea de comando. Estaba convirtiendo archivos mp4 en archivos mp3 y haciendo más de 1000 videos a la vez sin fallar. Desafortunadamente, no tengo experiencia directa en el shell de energía, pero espero que esto ayude.

Alex
fuente
Es extraño este código, de manera similar a otras soluciones fallidas (atascadas) en el PRIMER intento y luego pareció funcionar bien (como otros 5 intentos, lo probaré más). Por cierto, ¿por qué se realiza BegingOutputReadliney después de realizar ReadToEndAsyncel StandardError?
Joelty
OP ya está leyendo de forma asíncrona, por lo que es poco probable que el problema sea un punto muerto en el búfer de la consola.
Yaakov
0

No estoy seguro de si este es su problema, pero al mirar MSDN parece haber algo extraño con el WaitForExit sobrecargado cuando redirige la salida de forma asincrónica. El artículo de MSDN recomienda llamar a WaitForExit que no toma argumentos después de llamar al método sobrecargado.

Página de documentos ubicada aquí. Texto relevante:

Cuando la salida estándar se ha redirigido a controladores de eventos asíncronos, es posible que el procesamiento de salida no se haya completado cuando regrese este método. Para garantizar que se haya completado el manejo de eventos asíncronos, llame a la sobrecarga WaitForExit () que no toma ningún parámetro después de recibir un verdadero de esta sobrecarga. Para ayudar a garantizar que el evento Exited se maneje correctamente en las aplicaciones de formularios Windows Forms, configure la propiedad SynchronizingObject.

La modificación del código podría verse así:

if (process.WaitForExit(ProcessTimeOutMiliseconds))
{
  process.WaitForExit();
}
Tyler Hundley
fuente
Hay algunas complejidades con el uso de process.WaitForExit()lo indicado por los comentarios a esta respuesta .
ingen