Prozesse kontrolliert stoppen mit C#

C# bzw. .Net bieten von Haus aus ein Set an Methoden und Klassen, um mit anderen Prozessen zu interagieren. So ermöglicht beispielsweise die Klasse System.Diagnostics.Process das Starten und Überwachen von fremden Prozessen. Auch die Konsolenausgabe kann ebenso mit wenigen Handgriffen abgegriffen werden. Wenn es allerdings um das Stoppen eines Prozesses geht, stehen lediglich die Methoden Kill und CloseMainWindow zur Verfügung. Erstere ist sehr unsauber, sie schießt den Prozess direkt ab. Der betroffene Prozess hat somit keine Möglichkeit mehr, sich kontrolliert herunterzufahren. Letztere funktioniert nur für Applikationen mit einer Benutzeroberfläche und (vermutlich) auch nur für Windows. In diesem Artikel möchte ich Wege zeigen, um Prozesse kontrolliert stoppen zu können. Hierbei wird lediglich zwischen dem darunter liegenden Betriebssystem (Windows, Linux oder macOS) unterschieden.

Prozesse unter Linux und macOS stoppen

Linux und macOS sind beides Unix-Systeme. Unter solchen gibt es per Signal eine vergleichbar einfache Möglichkeit, eine Nachricht in Form eines Integer-Wertes an einen anderen Prozess zu schicken (siehe [1]). Hierbei gibt es eine längere Liste von standardisierten Werten, etwa zum Stoppen oder Beenden von Prozessen. Für den Zweck dieses Artikels ist insbesondere der Wert SIGINT (=2) interessant, dieser ist gleichbedeutend wie ein CTRL+C über die Konsole. Leider gibt es im Standard von .Net keine Möglichkeit, um ein solches Signal an einen Prozess zu schicken. Glücklicherweise stehen Bibliotheken zur Verfügung, welche diese Lücke ausfüllen. So etwa bietet das Nuget-Paket Mono.Unix unter Anderem diese Funktionalität über die Klasse Syscall. Im Nachfolgenden Codeausschnitt ist zu sehen, wie das Signal SIGINT an einen anderen Prozess geschickt werden kann. Zunächst verwirrt der Aufruf Syscall.kill. Diese Methode schießt den Prozess nicht ab. Stattdessen sendet sie ein Signal an den Prozess mit der übergebenen Prozess-ID. Den Code hätte ich unter macOS (aktueller M1 Mac) und Linux (WSL / Ubuntu) getestet.

public Task StopProcessAsync(Process processToStop, CancellationToken cancellationToken)
{
    Syscall.kill(processToStop.Id, Signum.SIGINT);

    return processToStop.WaitForExitAsync(cancellationToken);
}

Prozesse unter Windows stoppen

Unter Windows ist es leider etwas komplizierter. Zunächst kann zwischen Dienste-, Konsolen- und GUI-Prozessen unterschieden werden. Erstere möchte ich in diesem Artikel nicht weiter betrachten. Bei letzteren bietet der Standard tatsächlich eine Möglichkeit über die Methode Process.CloseMainWindow [2]. Diese Methode macht etwas ähnliches wie das Schließen des Hauptfensters per X. Bei Konsolen-Programmen dagegen bewirkt CloseMainWindow rein gar nichts, da kein Hauptfenster existiert (Ausnahme: Das Konsolen-Programm hat ein eigenes Shell-Fenster). Die Konsolen-API von Windows stellt mehrere Funktionen zur Verfügung, um mit Konsolen-Programmen interagieren zu können (siehe [3]). Im nächsten Codeausschnitt hätte ich eine mögliche Implementierung, welche auf der Lösung unter [4] basiert.

// ...

NativeMethods.FreeConsole();
if (NativeMethods.AttachConsole(processID)) 
{
    NativeMethods.SetConsoleCtrlHandler(null, true);
    try
    {
        if (!NativeMethods.GenerateConsoleCtrlEvent(NativeMethods.CTRL_C_EVENT, 0))
        {
            // Error: Unable send close event to process
        }
    }
    catch(Exception ex)
    {
        // Error
    }
}
else
{
    // Error: Unable to attach to console of process
}

// ...

internal class NativeMethods
{
    internal const int CTRL_C_EVENT = 0;

    [DllImport("kernel32.dll")]
    internal static extern bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId);

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

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

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

    // Delegate type to be used as the Handler Routine for SCCH
    internal delegate Boolean ConsoleCtrlDelegate(uint ctrlType);
}

Im Vergleich zur Lösung für Linux / macOS weiter oben sticht der Komplexitätsunterschied direkt ins Auge. Dabei ist das noch lange nicht alles. Die Unterscheidung zwischen Konsolen- und GUI-Prozesse ist im letzten Codeausschnitt gar nicht enthalten. Zusätzlich gibt es in obiger Lösung noch ein anderes Problem. Damit das CTRL+C Event an einen Prozess geschickt werden kann, muss der aktuelle Prozess an der Konsole des Ziel-Prozesses hängen. Das geht für Konsolen-Prozesse nur dann, wenn vorher FreeConsole aufgerufen wird und damit die Bindung zur eigenen Konsole unwiderruflich unterbrochen wurde. Wird obiger Code also von einem Konsolen-Prozess durchlaufen, kann danach nicht mehr mit dem eigenen StandardOutput oder StandardInput kommuniziert werden. Im verwiesenen Beitrag auf Stackoverflow [4] wird daher vorgeschlagen, obigen Code in einem separaten Prozess auszuführen.

Ein vollständiges, plattformunabhängiges Beispiel

Auf GitHub unter [5] hätte ich abschließend alles notwendige in einem Projekt zusammen, um plattformübergreifend (unter Windows, Linux oder macOS) fremde Prozesse kontrolliert stoppen zu können. Zunächst definiere ich darin das Interface IProcessStopCaller (siehe nachfolgenden Codeausschnitt).

internal interface IProcessStopCaller
{
    Task StopProcessAsync(Process processToStop, CancellationToken cancellationToken);
}

Die Implementierung für Linux und macOS ist entsprechend einfach (siehe nachfolgenden Codeausschnitt).

[SupportedOSPlatform(nameof(OSPlatform.Linux))]
[SupportedOSPlatform(nameof(OSPlatform.OSX))]
internal class UnixProcessStopCaller : IProcessStopCaller
{
    public Task StopProcessAsync(Process processToStop, CancellationToken cancellationToken)
    {
        Syscall.kill(processToStop.Id, Signum.SIGINT);

        return processToStop.WaitForExitAsync(cancellationToken);
    }
}

Nachfolgender Codeausschnitt zeigt die Implementierung für Windows. Diese versucht zunächst, den Prozess mittels der Methode CloseMainWindow zu stoppen. Falls es sich um keinen GUI-Prozess handelt, so wird hier false zurückgegeben. Es gibt allerdings noch andere Fälle für den Rückgabewert false: Das Stoppen des Prozesses kann nämlich entweder durch die Applikation selbst oder auch durch den Benutzer abgebrochen werden. Für Konsolen-Prozesse wird ein separater Hilfs-Prozess gestartet, welcher oben beschriebenen Weg über die Konsolen-API von Windows geht. Wenn man auch Dienst-Prozesse unter Windows stoppen können möchte, müsste man über die Klassen im Namespace System.ServiceProcess gehen.

[SupportedOSPlatform(nameof(OSPlatform.Windows))]
internal class WindowsProcessStopCaller : IProcessStopCaller
{
    public async Task StopProcessAsync(Process processToStop, CancellationToken cancellationToken)
    {
        if (processToStop.MainWindowHandle != IntPtr.Zero)
        {
            // Trigger closing of the MainWindow
            if (processToStop.CloseMainWindow())
            {
                await processToStop.WaitForExitAsync(cancellationToken);
            }
            else
            {
                throw new UnableToStopProcessException(
                    $"Unable to stop process {processToStop.Id}, CloseMainWindow returned false");
            }
        }
        else
        {
            // Start helper process which sends CTRL+C command
            var startInfo = new ProcessStartInfo(
                "dotnet", $"HappyCoding.WinProcessSignalingHelper.dll {processToStop.Id}");
            startInfo.RedirectStandardOutput = true;

            var helperProcess = Process.Start(startInfo);
            await helperProcess!.WaitForExitAsync(cancellationToken);
            var exitCode = helperProcess.ExitCode;

            if (exitCode == 0)
            {
                await processToStop.WaitForExitAsync(cancellationToken);
            }
            else
            {
                var error = (await helperProcess.StandardOutput.ReadToEndAsync()) ?? "";
                throw new UnableToStopProcessException(
                    $"Unable to end process {processToStop.Id}, helper process returned {exitCode} ({error})");
            }
        }
    }
}

Fazit

Neulich habe ich begonnen, mich mit dem Stoppen von Prozessen zu beschäftigen. Hintergrund für mich war es schlicht, durch C#-Code gestartete Prozesse kontrolliert stoppen zu können. Hierbei war ich sehr überrascht, dass dies insbesondere unter Windows nicht einmal so unkompliziert ist. Ich hoffe, ich konnte mit diesem Artikel etwas Licht ins Dunkel bringen, wenn andere Entwickler das Gleiche tun wollen.

Verweise

[1] = https://de.wikipedia.org/wiki/Signal_(Unix)
[2] = https://docs.microsoft.com/en-us/dotnet/api/system.diagnostics.process.closemainwindow?view=net-6.0
[3] = https://docs.microsoft.com/en-us/windows/console/console-reference
[4] = https://stackoverflow.com/questions/283128/how-do-i-send-ctrlc-to-a-process-in-c
[5] = https://github.com/RolandKoenig/HappyCoding/tree/main/2022/HappyCoding.ControlledProcessShutdownCall

Schreibe einen Kommentar

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre mehr darüber, wie deine Kommentardaten verarbeitet werden.