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

问题描述

我有一个应用程序,我在其中启动了许多 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 进程退出。 (第一个问题,您已经在 your comment 中解释过,结果证明这是您的看门狗代码中的一个错误,即使在明确发出退出信号的情况下,它也会重新启动进程。)

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

有趣的是,您用来发送信号的代码让我印象深刻的一件事是,它以修改这些状态​​的相同顺序恢复控制台的进程状态和信号处理。这对我来说似乎不寻常,因为人们通常以相反的顺序恢复状态,以“退出”状态,就像它一样。

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

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

无论如何……

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

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

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

在窗口类中(注意,WPF 完全被破坏了,因为这里根本没有 MVVM……这只是为了让基本的最小的、完整的示例工作):

StopProcess()

这是 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()

您可能可以推断出 XAML 是什么——只有几个按钮和一个 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()}"); } } 来显示消息——但为了完整起见,无论如何都在这里:

TextBlock