需要从主 C# WPF 应用程序将 CTRL+C (SIGINT) 发送到 Process 对象

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

我有一个应用程序,我在其中启动了一些 Process 对象,将输出重定向到触发事件。这些进程应该能够 运行 无限期地运行,但我也希望能够向它们发出信号以优雅地终止(例如,完成它们所有的业务然后结束)。出于测试目的,我正在使用 tracert。以下是我如何创建和启动流程:

//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();

根据我的阅读,我的理解是发送 ctrl+c 信号的方式如下:

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

但这似乎不起作用。它似乎立即触发进程的 Exited 事件(tracert 的退出代码为负),但 tracert 的输出继续显示完成,然后第二次触发进程的 Exited 事件,这次的退出代码为零。如果我调用 StopProcess 函数一次,然后在 tracert 继续执行其业务时调用它第二次,整个应用程序将关闭。

我正在使用针对 .NET 5.0 框架的 WPF 构建主应用程序。任何帮助将不胜感激!

我能够重现您报告的第二个问题,即当您尝试将信号发送到控制台进程时 WPF 进程退出。 (第一个问题,您在 中解释说,它原来是您的看门狗代码中的一个错误,即使它被明确指示要退出,它也会重新启动该过程。)

经过调查,在我看来,这是由对 GenerateConsoleCtrlEvent() 的调用和对 SetConsoleCtrlHandler() 的后续调用之间的竞争条件引起的。似乎如果这些调用发生得太快,GenerateConsoleCtrlEvent() 发送的 Ctrl+C 对默认处理仍然可见WPF 应用程序,导致进程以 STATUS_CONTROL_C_EXIT 代码退出(即按 Ctrl+C 的正常结果,但对于错误的过程)。

有趣的是,关于您用来发送信号的代码,我最感兴趣的一件事是它恢复了控制台的进程状态,并按照修改这些状态​​的相同顺序处理信号.这对我来说似乎很不寻常,因为通常以相反的顺序恢复状态,可以说是“退出”状态。

如果更改代码以便在释放附加控制台之前恢复信号处理(即通常编写代码的方式),那么主机进程接收信号并退出的问题会重现 第一次 方法被调用的时间。 IE。似乎它甚至第一次工作的唯一原因是主机进程第一次调用 FreeConsole() 函数时有一些延迟,这足以让信号不被注意。第二次通过,延迟不再存在(可能在 p/invoke 层中缓存了一些东西......我没有费心去调查那部分)。

不过在那之后,它的工作方式就像按预期顺序恢复状态一样。

总之……

通过在目标进程实际退出之前不恢复当前进程的状态,我能够可靠地解决问题。在我必须构建以重现问题的概念验证应用程序中,这相对简单,因为我已经实现了 TaskCompletionSource,它是在引发 Exited 事件时设置的,因此我能够将该源的 Task 传递给 StopProcess() 方法,这样它就可以在恢复状态之前 await Task

我建议您以类似的方式修复您的代码。请注意,您不能在 Process 本身上调用 WaitForExit(),除非您从 UI 线程以外的某个线程这样做,因为 Process class 使用UI 线程引发 Exited 事件,因此通过调用 WaitForExit() 阻塞 UI 线程将导致死锁。您可以通过将对 StopProcess() 的整个调用放在不同的线程中来避免这种情况,但这对我来说似乎有点过分,尤其是当有更优雅的方式来实现整个事情时。

您可以使用任何您喜欢的机制来等待进程终止,只要您注意不要让 UI 线程死锁。但这是我写的代码,如果你想参考它......

在 window class 中(请注意,WPF 完全损坏,因为这里根本没有 MVVM……这只是为了让基本的最小完整示例工作):

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

这是 StopProcess() 方法的更新版本(我将其放入自己的助手 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()}");
    }
}

您可能可以推断出 XAML 是什么——只是几个按钮和一个 TextBlock 来显示消息——但为了完整起见,无论如何,它就在这里:

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