Need to send CTRL+C (SIGINT) to Process object from main C# WPF app

Viewed 391

I have an application where I spin up a number of Process objects, redirecting the output to trigger events. These processes should be able to run indefinitely, but I also want to be able to signal to them to terminate gracefully (e.g., finish up whatever business they have and then end). For testing purposes, I'm using tracert. Here's how I create and start the processes:

//Create
this.process = new Process();
this.process.StartInfo.FileName = "tracert.exe";
this.process.StartInfo.Arguments = "google.com";
this.process.StartInfo.UseShellExecute = false;
this.process.StartInfo.CreateNoWindow = true;
this.process.StartInfo.RedirectStandardOutput = true;
this.process.StartInfo.RedirectStandardError = true;
this.process.StartInfo.RedirectStandardInput = true;
this.process.EnableRaisingEvents = true;
this.process.OutputDataReceived += Process_OutputDataReceived;
this.process.Exited += Process_Exited;
...
//Start
new Thread(() =>
{
  Thread.CurrentThread.IsBackground = true;
  this.process.Refresh();
  this.process.Start();
  this.process.BeginOutputReadLine();
  this.process.WaitForExit();
}).Start();

Based on what I've read, my understanding is that this is the way to send the ctrl+c signal is the following:

[DllImport("kernel32.dll", SetLastError = true)]
static extern bool GenerateConsoleCtrlEvent(ConsoleCtrlEvent sigevent, int dwProcessGroupId);

[DllImport("kernel32.dll", SetLastError = true)]
static extern bool AttachConsole(uint dwProcessId);

[DllImport("kernel32.dll")]
static extern bool SetConsoleCtrlHandler(ConsoleCtrlDelegate HandlerRoutine, bool Add);

[DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)]
static extern bool FreeConsole();

delegate Boolean ConsoleCtrlDelegate(uint CtrlType);

public enum ConsoleCtrlEvent
{
  CTRL_C = 0,
  CTRL_BREAK = 1,
  CTRL_CLOSE = 2,
  CTRL_LOGOFF = 5,
  CTRL_SHUTDOWN = 6
}

private void StopProcess {
  if (AttachConsole((uint)this.process.Id))
  {
    SetConsoleCtrlHandler(null, true);
    GenerateConsoleCtrlEvent(ConsoleCtrlEvent.CTRL_C, 0);
    FreeConsole();
    SetConsoleCtrlHandler(null, false);
  }
}

But this doesn't seem to work. It seems to immediately trigger the process's Exited event (with a negative exit code from tracert), but tracert's output continues to be displayed to completion, and then the process's Exited event is triggered a SECOND time, this time with an exit code of zero. If I call the StopProcess function once and then call it a second time while tracert continues to do its business, the whole application closes.

I'm building the main app using WPF targeting the .NET 5.0 framework. Any help would be greatly appreciated!

1 Answers

I'm able to reproduce the second issue you report, i.e. the WPF process exiting when you try to send the signal to a console process. (The first issue, you've explained in your comment that it turned out to be a bug in your watch-dog code restarting the process even when it was signaled explicitly to exit.)

After investigation, it appears to me that this is caused by a race condition between the call to GenerateConsoleCtrlEvent() and the subsequent call to SetConsoleCtrlHandler(). It seems that if those calls occur too quickly, the Ctrl+C that is sent by GenerateConsoleCtrlEvent() remains visible to the default handling in the WPF app, causing the process to exit with the STATUS_CONTROL_C_EXIT code (i.e. the normal result from pressing Ctrl+C, but for the wrong process).

Interestingly, one of the things that stood out to me about the code you're using to send the signal, is that it restores the process state for the console and the signal handling in the same order those states were modified. This seems unusual to me, as one normally restores state in reverse order, to "back out" the state, as it were.

If one changes the code so that the signal handling is restored before freeing the attached console (i.e. the way one might normally write the code), then the problem with the host process receiving the signal and exiting reproduces the first time the method is called. I.e. it seems that the only reason it even works the first time is that there's some delay the first time that the host process calls the FreeConsole() function, that is sufficient to let the signal go unnoticed. The second time through, the delay no longer exists (possibly something got cached in the p/invoke layer…I didn't bother to investigate that part).

After that though, it works just like it would if you restored the state in the expected order.

Anyway…

I was able to reliably fix the issue by not restoring the current process's state until the target process had actually exited. In the proof-of-concept app I had to build in order to reproduce the issue, this was relatively simple, because I'd already implemented a TaskCompletionSource that is set when the Exited event is raised, and so I was able to pass the Task for that source to the StopProcess() method so it could await the Task before restoring the state.

I recommend you fix your code in a similar way. Note that you cannot call WaitForExit() on the Process itself, unless you do so from some thread other than the UI thread, because the Process class uses the UI thread to raise the Exited event, and so blocking the UI thread with a call to WaitForExit() will cause a deadlock. You could avoid that by putting the entire call to StopProcess() in a different thread, but that seems like overkill to me, especially when there's a more elegant way to implement the whole thing.

You can use any mechanism you like to wait for the process to terminate, as long as you take care to not deadlock the UI thread. But here's the code I wrote, in case you want to refer to it…

In the window class (note, completely broken for WPF as there's no MVVM here at all…this was just to get the basic minimal, complete example working):

private Process _process;
private TaskCompletionSource _processTask;

private async void startButton_Click(object sender, RoutedEventArgs e)
{
    startButton.IsEnabled = false;
    stopButton.IsEnabled = true;

    try
    {
        _process = new Process();
        _processTask = new TaskCompletionSource();

        _process.StartInfo.FileName = "tracert.exe";
        _process.StartInfo.Arguments = "google.com";
        _process.StartInfo.UseShellExecute = false;
        _process.StartInfo.CreateNoWindow = true;
        _process.StartInfo.RedirectStandardOutput = true;
        _process.StartInfo.RedirectStandardError = true;
        _process.StartInfo.RedirectStandardInput = true;
        _process.EnableRaisingEvents = true;
        _process.OutputDataReceived += (_, e) => _WriteLine($"stdout: \"{e.Data}\"");
        _process.ErrorDataReceived += (_, e) => _WriteLine($"stderr: \"{e.Data}\"");
        _process.Exited += (_, _) =>
        {
            _WriteLine($"Process exited. Exit code: {_process.ExitCode}");
            _processTask.SetResult();
        };

        _process.Start();
        _process.BeginOutputReadLine();
        _process.BeginErrorReadLine();

        await _processTask.Task;
    }
    finally
    {
        _process?.Dispose();
        _process = null;
        _processTask = null;
        startButton.IsEnabled = true;
        stopButton.IsEnabled = false;
    }
}

private async void stopButton_Click(object sender, RoutedEventArgs e)
{
    try
    {
        await Win32Process.StopProcess(_process, _processTask.Task);
    }
    catch (InvalidOperationException exception)
    {
        _WriteLine(exception.Message);
    }
}

private void _WriteLine(string text)
{
    Dispatcher.Invoke(() => consoleOutput.Text += $"{text}{Environment.NewLine}");
}

Here's the updated version of the StopProcess() method (which I put into its own helper class):

public static async Task StopProcess(Process process, Task processTask)
{
    if (AttachConsole((uint)process.Id))
    {
        // NOTE: each of these functions could fail. Error-handling omitted
        // for clarity. A real-world program should check the result of each
        // call and handle errors appropriately.
        SetConsoleCtrlHandler(null, true);
        GenerateConsoleCtrlEvent(ConsoleCtrlEvent.CTRL_C, 0);
        await processTask;
        SetConsoleCtrlHandler(null, false);
        FreeConsole();
    }
    else
    {
        int hresult = Marshal.GetLastWin32Error();
        Exception e = Marshal.GetExceptionForHR(hresult);

        throw new InvalidOperationException(
            $"ERROR: failed to attach console to process {process.Id}: {e?.Message ?? hresult.ToString()}");
    }
}

You can probably infer what the XAML is — just a couple of buttons and a TextBlock to display messages — but for completeness, here it is anyway:

<Window x:Class="WpfApp1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
  <Grid>
    <Grid.RowDefinitions>
      <RowDefinition Height="Auto"/>
      <RowDefinition/>
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
      <ColumnDefinition Width="Auto"/>
      <ColumnDefinition Width="Auto"/>
      <ColumnDefinition/>
    </Grid.ColumnDefinitions>

    <Button x:Name="startButton" Grid.Row="0" Grid.Column="0" Content="Start" Click="startButton_Click"/>
    <Button x:Name="stopButton" Grid.Row="0" Grid.Column="1" Content="Ctrl-C" Click="stopButton_Click" IsEnabled="False"/>
    <ScrollViewer Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3">
      <TextBlock x:Name="consoleOutput"/>
    </ScrollViewer>
  </Grid>
</Window>
Related