System Programming Windows

Процеси в .NET — API та Запуск

Повний розбір System.Diagnostics.Process — від читання властивостей поточного процесу до запуску зовнішніх програм з перехопленням stdout/stderr в режимі реального часу. Теорія, анатомія та практика з детальними прикладами.

Процеси в .NET — API та Запуск

Навіщо Вивчати Роботу з Процесами?

У попередній темі ми розглянули процес як концепцію операційної системи: ізольований адресний простір, таблиця дескрипторів, контекст безпеки. Проте для практичної роботи цієї концептуальної бази недостатньо — нам потрібне конкретне API, що дозволяє взаємодіяти з процесами безпосередньо з коду C#.

Розглянемо три типові сценарії, з якими стикаються розробники:

Сценарій перший: Системне адміністрування та моніторинг. DevOps-інструмент має кожні 30 секунд перевіряти, чи запущені критичні служби, фіксувати їх споживання RAM і CPU, і надсилати alert якщо процес "завис" або перевищив пороговий показник. Без Process API жоден рядок коду тут не стане в пригоді.

Сценарій другий: Інтеграція з CLI-інструментами. CI/CD пайплайн, скрипт збірки або тестовий runner мають запустити dotnet build, npm install, чи зовнішній компілятор і отримати вивід у реальному часі — не чекаючи завершення процесу, а стрімінгом по рядках. Це вимагає розуміння перенаправлення потоків вводу-виводу.

Сценарій третій: Архітектура multi-process систем. Коли безпека або надійність вимагають ізоляції, функціональність виносять в окремий процес. Браузери Chrome та Firefox роблять саме це для кожної вкладки. Два .NET процеси мають "домовитись" про протокол обміну даними — і тут на сцену виходить IPC (Inter-Process Communication).

.NET надає для всього перерахованого єдину точку входу: клас System.Diagnostics.Process. Ця тема — практично орієнтований розбір: теорія пояснює "чому", код демонструє "як".


Модель Процесу в Windows і Зв'язок з .NET

Перш ніж перейти до API, варто зрозуміти, як Windows "думає" про процеси і як .NET Runtime вписується в цю картину.

Коли ви запускаєте .exe файл (або dotnet run), Windows проходить через наступну послідовність:

Крок перший — Validation і Security. NtCreateProcess() у kernel перевіряє права доступу до виконуваного файлу, створює об'єкт _EPROCESS у kernel memory — внутрішню структуру ядра, що описує процес. Саме _EPROCESS є "справжнім" процесом у розумінні ОС.

Крок другий — Virtual Address Space. Kernel резервує 128 ТБ (на x64) або 2-4 ГБ (на x86) virtual address space. Це не реальна пам'ять — лише діапазон адрес. Реальні сторінки RAM виділяються "ліниво" при першому зверненні (page fault).

Крок третій — DLL Loader. Завантажується сам виконуваний файл, потім всі DLL-залежності: системні (kernel32.dll, ntdll.dll) і прикладні (.NET Runtime). На x64 Windows ASLR (Address Space Layout Randomization) розміщує кожну DLL за випадковою адресою при кожному завантаженні — захист від buffer overflow атак.

Крок четвертий — .NET Runtime Init. Для .NET застосунків coreclr.dll ініціалізує CLR: налаштовує GC, ThreadPool, JIT-компілятор, завантажує збірки збірки за описом з .runtimeconfig.json. Тільки після цього починає виконуватися IL-код вашої програми.

Крок п'ятий — Main Thread. Перший потік процесу (Main Thread, ID = 1 у .NET) починає виконання точки входу (Main() або top-level statements).

Клас Process у .NET — це обгортка над Win32 API функціями OpenProcess, GetProcessTimes, QueryInformationJobObject та десятками інших. Він не надає доступ до _EPROCESS напряму, але виставляє user-mode інформацію через зручний об'єктно-орієнтований інтерфейс.


System.Diagnostics.Process: Повний API

Отримання Об'єктів Process

Простір імен System.Diagnostics містить клас Process, що є центральним API для роботи з процесами. Існує чотири способи отримати екземпляр:

ProcessAcquisition.cs
using System.Diagnostics;

// Спосіб 1: Поточний процес — найшвидший, без системних викликів
Process current = Process.GetCurrentProcess();

// Спосіб 2: Всі процеси на локальній машині
// Увага: може зайняти 100-300ms при великій кількості процесів
Process[] all = Process.GetProcesses();

// Спосіб 3: Пошук за іменем (повертає масив — може бути кілька примірників)
// Ім'я без розширення, case-insensitive на Windows
Process[] chromes = Process.GetProcessesByName("chrome");
Process[] dotnetProcs = Process.GetProcessesByName("dotnet");

// Спосіб 4: За конкретним PID — викидає ArgumentException якщо не знайдено
try
{
    Process specific = Process.GetProcessById(1234);
}
catch (ArgumentException)
{
    Console.WriteLine("Процес з таким PID не знайдено або вже завершився");
}

// Важливо: Process реалізує IDisposable
// Клас тримає native handle на процес — завжди використовуйте using
using var p = Process.GetCurrentProcess();
GetProcesses() виконує snapshot всіх процесів у системі. На завантаженій машині з 200+ процесами це може зайняти 200-400ms. Якщо вам потрібно лише кілька процесів за іменем — завжди використовуйте GetProcessesByName(), він значно ефективніший.

Анатомія Властивостей Process

Кожен об'єкт Process надає доступ до детальної інформації. Властивості можна розбити на три категорії:

Ідентифікаційні властивості — не вимагають підвищених прав:

Id
int
Унікальний числовий ідентифікатор процесу (PID). Ядро гарантує унікальність PID серед усіх живих процесів, але після завершення процесу його PID може бути перевикористаний. Читається навіть після завершення процесу, якщо ви тримаєте об'єкт.
ProcessName
string
Ім'я процесу без розширення .exe. Не унікальне — кілька примірників Chrome мають однакове ProcessName = "chrome". Формується з імені виконуваного файлу, не з заголовка вікна.
SessionId
int
Ідентифікатор сесії Windows. Служби запускаються у Session 0 (ізольована від UI). Звичайні застосунки — у Session 1 і вище. Важливо при роботі зі службами та UI isolation.

Ресурсні властивості — вимагають достатніх прав:

WorkingSet64
long
Обсяг фізичної RAM (Working Set), що ОС наразі виділила процесу, у байтах. Це число, що ви бачите в колонці "Memory (RAM)" Task Manager. Може коливатися — Windows може "забрати" сторінки під тиском пам'яті (memory pressure).
PrivateMemorySize64
long
Private bytes — пам'ять, яка належить виключно цьому процесу (не shared DLL, не memory-mapped files). Найточніший показник "власного" споживання. Task Manager показує саме це у колонці "Commit Size".
VirtualMemorySize64
long
Загальний розмір зарезервованого virtual address space. Включає uncommitted пам'ять, shared DLL сторінки, MMF. На x64 може бути сотні ГБ, що не означає реального споживання RAM.
PagedMemorySize64
long
Обсяг даних, що може бути вивантажено у page file (pageable). Протилежність — non-paged pool (kernel structures, DMA buffers), що ніколи не вивантажується.
HandleCount
int
Кількість відкритих системних дескрипторів (handles): файли, сокети, mutex-и, semaphore-и, window handles. Постійно зростаючий HandleCount — класична ознака handle leak. Нормальне значення: 100-1000 для типового застосунку.
Threads
ProcessThreadCollection
Колекція всіх потоків процесу. Включає не лише "ваші" потоки, але й внутрішні потоки .NET Runtime: GC threads (один на ядро у Server GC), Finalizer thread, ThreadPool threads. Для простої консольної програми може бути 10-15 потоків.
Modules
ProcessModuleCollection
Список завантажених DLL. Корисно для перевірки, яка версія бібліотеки фактично завантажена, чи виявлення DLL injection (security).

Часові властивості:

StartTime
DateTime
Час запуску процесу. Вимагає SeDebugPrivilege або читання власного процесу. Разом із DateTime.Now - process.StartTime дає Uptime.
TotalProcessorTime
TimeSpan
Сукупний CPU час (user + kernel mode). Якщо за 1 секунду TotalProcessorTime виросло на 2 секунди — процес утилізує 200% CPU (2 ядра). TotalProcessorTime / Environment.ProcessorCount — нормалізований відсоток CPU.
UserProcessorTime
TimeSpan
CPU час у user mode (виконання вашого коду, .NET Runtime). Виключає час у syscall-ах і kernel callbacks.

Process як Читач Системних Даних: Приклад

Наступний код демонструє побудову мінімального консольного Task Manager:

MiniTaskManager.cs
using System.Diagnostics;

static string FormatBytes(long bytes)
{
    return bytes switch
    {
        >= 1_073_741_824 => $"{bytes / 1_073_741_824.0:F1} GB",
        >= 1_048_576     => $"{bytes / 1_048_576.0:F1} MB",
        >= 1_024         => $"{bytes / 1_024.0:F1} KB",
        _                => $"{bytes} B"
    };
}

// Отримуємо всі процеси, сортуємо за WorkingSet
var processes = Process.GetProcesses()
    .OrderByDescending(p =>
    {
        try { return p.WorkingSet64; }
        catch { return 0L; } // UnauthorizedAccessException для деяких системних процесів
    })
    .Take(20);

Console.OutputEncoding = System.Text.Encoding.UTF8;
Console.WriteLine($"{"PID",-8} {"Назва",-25} {"RAM",-12} {"CPU (s)",-10} {"Потоки",-8} {"Handles",-8}");
Console.WriteLine(new string('─', 75));

foreach (var p in processes)
{
    try
    {
        var cpuSeconds = p.TotalProcessorTime.TotalSeconds;
        Console.WriteLine(
            $"{p.Id,-8} " +
            $"{p.ProcessName[..Math.Min(24, p.ProcessName.Length)],-25} " +
            $"{FormatBytes(p.WorkingSet64),-12} " +
            $"{cpuSeconds,-10:F1} " +
            $"{p.Threads.Count,-8} " +
            $"{p.HandleCount,-8}");
    }
    catch
    {
        // Системні процеси (System, Idle) можуть мати обмежений доступ
        Console.WriteLine($"{p.Id,-8} {p.ProcessName,-25} {"<обмежений доступ>"}");
    }
    finally
    {
        p.Dispose();
    }
}
MiniTaskManager
$ dotnet run
PID Назва RAM CPU (s) Потоки Handles
───────────────────────────────────────────────────────────────────────────
14824 chrome 1.2 GB 3821.4 78 2341
19042 rider64 812.3 MB 1204.1 142 4871
4512 dotnet 287.1 MB 12.8 18 412
8194 Code 241.7 MB 892.3 34 1023
3220 explorer 103.4 MB 58.1 42 2150

Запуск Зовнішніх Процесів

ProcessStartInfo: Архітектура Конфігурації

Запуск зовнішніх процесів в .NET здійснюється через Process.Start() із об'єктом ProcessStartInfo. Цей об'єкт є декларативним описом того, як слід запустити процес — аналог аргументів CreateProcess() у Win32 API.

Найважливіша властивість, що визначає поведінку всіх інших — UseShellExecute:

UseShellExecute = true (поведінка за замовчуванням до .NET Core): Запуск через оболонку ОС (Shell Execute). Дозволяє відкривати документи (Process.Start("report.pdf") відкриє PDF у Adobe Reader), URL, виконувати дії ("open", "print"). Але забороняє перенаправлення stdin/stdout/stderr. Використовується коли потрібен UI, а не програмна взаємодія.

UseShellExecute = false (рекомендовано для програмної взаємодії): Прямий системний виклик CreateProcess(). Дозволяє перенаправлення потоків, але вимагає повного шляху до виконуваного файлу або щоб файл знаходився в PATH. Жодного Shell Extension, жодних verb actions.

ProcessStartInfoDemo.cs
using System.Diagnostics;

// Сценарій 1: Відкрити URL у браузері (UseShellExecute = true)
var openBrowser = new ProcessStartInfo
{
    FileName = "https://docs.microsoft.com",
    UseShellExecute = true  // обов'язково для URL
};
Process.Start(openBrowser);

// Сценарій 2: Відкрити файл у відповідній програмі
var openDoc = new ProcessStartInfo
{
    FileName = @"C:\report.docx",
    UseShellExecute = true,
    Verb = "open"  // або "print", "edit" — залежить від асоціації файлу
};
Process.Start(openDoc);

// Сценарій 3: Запуск з правами адміністратора
var runAsAdmin = new ProcessStartInfo
{
    FileName = "notepad.exe",
    Arguments = @"C:\Windows\System32\drivers\etc\hosts",
    UseShellExecute = true,
    Verb = "runas"  // ініціює UAC prompt
};
// Process.Start(runAsAdmin);

// Сценарій 4: Програмна взаємодія (UseShellExecute = false обов'язково!)
var psi = new ProcessStartInfo
{
    FileName = "git",                              // команда у PATH
    Arguments = "log --oneline -10",
    UseShellExecute = false,
    RedirectStandardOutput = true,                 // читаємо stdout програмно
    RedirectStandardError = true,                  // читаємо stderr
    WorkingDirectory = @"C:\MyProject",            // де виконувати команду
    CreateNoWindow = true                          // без зайвого консольного вікна
};

Повний Список Властивостей ProcessStartInfo

FileName
string
Шлях до виконуваного файлу або ім'я команди (якщо в PATH). При UseShellExecute = true може бути URL або шлях до документа. При UseShellExecute = false — виключно виконуваний файл.
Arguments
string
Аргументи командного рядка у вигляді рядка. Рядок передається процесу "як є" — ви відповідальні за правильне екранування пробілів та спеціальних символів.
ArgumentList
Collection<string>
Типобезпечна альтернатива Arguments. Кожен аргумент — окремий елемент колекції. .NET автоматично виконує правильне екранування (лапки, пробіли). Рекомендується замість Arguments коли аргументи формуються динамічно з user input.
WorkingDirectory
string
Початковий робочий каталог дочірнього процесу. Якщо не вказано — успадковується від батьківського, що може призвести до несподіваної поведінки CLI-інструментів, що залежать від Directory.GetCurrentDirectory().
Environment
IDictionary<string, string?>
Словник змінних середовища для дочірнього процесу. За замовчуванням успадковується від батька. Можна додати нові (Environment["MY_VAR"] = "value") або змінити існуючі. Ізоляція: зміни не впливають на батьківський процес.
RedirectStandardInput
bool
Якщо true — stdin дочірнього процесу підключається до process.StandardInput (StreamWriter). Дозволяє програмно "вводити" дані як ніби з клавіатури. Вимагає UseShellExecute = false.
RedirectStandardOutput
bool
Якщо true — stdout підключається до process.StandardOutput (StreamReader). Потрібно читати stdout, навіть якщо вам не потрібен вміст, інакше stdout буфер може переповнитись і процес зависне.
RedirectStandardError
bool
Аналог для stderr. Читайте stderr в окремому потоці або паралельному завданні від stdout. Синхронне читання спочатку stdout, потім stderr може призвести до deadlock.
CreateNoWindow
bool
Якщо true — процес не створює нове вікно консолі. При UseShellExecute = false для консольних застосунків — за замовчуванням вікно не з'являється, але краще указати явно.
WindowStyle
ProcessWindowStyle
При UseShellExecute = true: Normal, Minimized, Maximized, Hidden. При UseShellExecute = false — ігнорується.

Синхронний Захват Виводу

Найпростіший спосіб — дочекатися завершення процесу і прочитати весь вивід:

SyncCapture.cs
using System.Diagnostics;

// ❌ НЕБЕЗПЕЧНО: синхронне послідовне читання stdout потім stderr!
// Якщо процес генерує багато stderr, буфер ПЕРЕПОВНИТЬСЯ і виникне deadlock
static string RunCommandUnsafe(string command, string arguments)
{
    using var process = new Process();
    process.StartInfo = new ProcessStartInfo
    {
        FileName = command,
        Arguments = arguments,
        UseShellExecute = false,
        RedirectStandardOutput = true,
        RedirectStandardError = true,
        CreateNoWindow = true
    };

    process.Start();

    // DEADLOCK RISK: якщо stderr переповниться, процес чекає — а ми чекаємо stdout
    string stdout = process.StandardOutput.ReadToEnd();   // блокуємо тут...
    string stderr = process.StandardError.ReadToEnd();     // ...ніколи не доходимо

    process.WaitForExit();
    return stdout;
}

// ✅ ПРАВИЛЬНО: async паралельне читання обох потоків
static async Task<(string Output, string Error, int ExitCode)> RunCommandAsync(
    string command, string arguments, CancellationToken ct = default)
{
    using var process = new Process();
    process.StartInfo = new ProcessStartInfo
    {
        FileName = command,
        Arguments = arguments,
        UseShellExecute = false,
        RedirectStandardOutput = true,
        RedirectStandardError = true,
        CreateNoWindow = true
    };

    process.Start();

    // Запускаємо читання stdout і stderr паралельно,
    // що унеможливлює deadlock від переповнення буфера
    Task<string> outputTask = process.StandardOutput.ReadToEndAsync(ct);
    Task<string> errorTask  = process.StandardError.ReadToEndAsync(ct);

    await process.WaitForExitAsync(ct);

    string output = await outputTask;
    string error  = await errorTask;

    return (output, error, process.ExitCode);
}

// Використання
var (output, error, code) = await RunCommandAsync("git", "log --oneline -5");
Console.WriteLine($"Exit: {code}");
Console.WriteLine(output);
if (!string.IsNullOrEmpty(error))
    Console.Error.WriteLine($"Errors:\n{error}");

Стрімінг Виводу в Реальному Часі

Для довгоіснуючих процесів (збірка, тести, міграція БД) потрібно отримувати рядки по мірі їх появи, а не чекати завершення:

RealtimeStreaming.cs
using System.Diagnostics;

static async Task<int> RunWithStreamingOutputAsync(
    string command,
    string arguments,
    Action<string>? onOutput = null,
    Action<string>? onError = null,
    CancellationToken ct = default)
{
    using var process = new Process();

    process.StartInfo = new ProcessStartInfo
    {
        FileName = command,
        Arguments = arguments,
        UseShellExecute = false,
        RedirectStandardOutput = true,
        RedirectStandardError = true,
        CreateNoWindow = true
    };

    // Підписуємося на події ДО виклику Start()
    process.OutputDataReceived += (_, e) =>
    {
        if (e.Data is not null)
            onOutput?.Invoke(e.Data);
    };

    process.ErrorDataReceived += (_, e) =>
    {
        if (e.Data is not null)
            onError?.Invoke(e.Data);
    };

    process.Start();

    // Активуємо асинхронне читання — тепер події будуть вогнем
    process.BeginOutputReadLine();
    process.BeginErrorReadLine();

    await process.WaitForExitAsync(ct);

    // WaitForExitAsync без аргументу (або з CT) для BeginOutputReadLine
    // правильно чекатиме дочитування буферів після завершення процесу
    return process.ExitCode;
}

// Приклад: запуск dotnet test з живим виводом
int exitCode = await RunWithStreamingOutputAsync(
    command: "dotnet",
    arguments: "test ./MyTests/ --verbosity normal",
    onOutput: line =>
    {
        // Підфарбовуємо рядки за змістом
        if (line.Contains("PASSED"))
        {
            Console.ForegroundColor = ConsoleColor.Green;
            Console.WriteLine($"  ✓ {line}");
        }
        else if (line.Contains("FAILED"))
        {
            Console.ForegroundColor = ConsoleColor.Red;
            Console.WriteLine($"  ✗ {line}");
        }
        else
        {
            Console.ResetColor();
            Console.WriteLine($"  {line}");
        }
        Console.ResetColor();
    },
    onError: line => Console.Error.WriteLine($"ERR: {line}")
);

Console.WriteLine($"\nExit code: {exitCode}");
Порядок прив'язки подій і Start() критичний. Якщо підписатись на OutputDataReceived після Start() — перші рядки виводу можуть загубитись у race window між запуском процесу та реєстрацією обробника. Завжди: спочатку +=, потім Start().

Управління Дочірніми Процесами

Завершення Процесів: Graceful vs Forceful

Існує два принципово різних способи зупинити процес:

CloseMainWindow() — "м'яке" завершення. Відправляє повідомлення WM_CLOSE головному вікну процесу (через Windows Message Queue). Еквівалентно натисканню хрестика. Процес може обробити це повідомлення: зберегти файли, запитати підтвердження чи ігнорувати взагалі. Повертає true якщо повідомлення відправлено успішно (не означає, що процес завершився). Не працює для консольних застосунків без вікна.

Kill() / Kill(entireProcessTree: true) — примусове завершення. Аналог TerminateProcess() у Win32 API. Процес негайно завершується без можливості обробки: файли не зберігаються, з'єднання не закриваються, деструктори не викликаються. .NET 5+ додав параметр entireProcessTree: true, що вбиває і всі дочірні процеси.

ProcessTermination.cs
using System.Diagnostics;

static async Task GracefulShutdownAsync(Process process, int gracePeriodMs = 5000)
{
    // Спочатку пробуємо graceful shutdown
    bool hasClosed = process.CloseMainWindow();

    if (!hasClosed)
    {
        // Немає головного вікна — для консольних застосунків можна надіслати Ctrl+C через stdin
        // Або одразу переходити до Kill()
        Console.WriteLine("Немає вікна — примусовий Kill");
        process.Kill(entireProcessTree: true);
        return;
    }

    // Чекаємо грейс-period на самостійне завершення
    using var cts = new CancellationTokenSource(gracePeriodMs);
    try
    {
        await process.WaitForExitAsync(cts.Token);
        Console.WriteLine($"Процес завершився сам. ExitCode={process.ExitCode}");
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine($"Не завершився за {gracePeriodMs}ms — примусово вбиваємо");
        if (!process.HasExited)
            process.Kill(entireProcessTree: true);
    }
}

// Реакція на завершення через подію (не блокує потік)
process.EnableRaisingEvents = true;
process.Exited += (sender, _) =>
{
    var p = (Process)sender!;
    Console.WriteLine($"Процес {p.ProcessName} (PID={p.Id}) завершився. Code={p.ExitCode}");
};

Передача Даних у stdin Дочірнього Процесу

Деякі CLI утиліти очікують введення з stdin (паролі, підтвердження, конфігурація):

StdinPipe.cs
using System.Diagnostics;

// Імітуємо введення даних до інтерактивної програми
static async Task RunInteractiveAsync()
{
    using var process = new Process();
    process.StartInfo = new ProcessStartInfo
    {
        FileName = "powershell",
        Arguments = "-NoLogo -NoProfile -NonInteractive",
        UseShellExecute = false,
        RedirectStandardInput = true,   // будемо "вводити" команди програмно
        RedirectStandardOutput = true,
        RedirectStandardError = true,
        CreateNoWindow = true
    };

    process.Start();

    Task<string> outputTask = process.StandardOutput.ReadToEndAsync();
    Task<string> errorTask  = process.StandardError.ReadToEndAsync();

    // Пишемо команди у stdin PowerShell
    await process.StandardInput.WriteLineAsync("Get-Date");
    await process.StandardInput.WriteLineAsync("$env:USERNAME");
    await process.StandardInput.WriteLineAsync("exit");
    process.StandardInput.Close();  // сигналізуємо EOF — PowerShell завершиться

    await process.WaitForExitAsync();

    Console.WriteLine(await outputTask);
}

Наскрізний Приклад: Build Automation Runner

Побудуємо повноцінний інструмент автоматизації збірки, що виконує послідовність shell-команд з перехопленням виводу, вимірюванням часу кожного кроку та генерацією звіту.

Крок 1: Структура проєкту та моделі даних

dotnet new console -n BuildRunner
cd BuildRunner
Models.cs
namespace BuildRunner;

/// <summary>Опис одного кроку збірки</summary>
record BuildStep(
    string Name,
    string Command,
    string Arguments,
    string? WorkingDirectory = null,
    bool ContinueOnError = false
);

/// <summary>Результат виконання кроку</summary>
record StepResult(
    string StepName,
    bool Success,
    int ExitCode,
    string Output,
    string Error,
    TimeSpan Duration
);

Крок 2: Виконавець кроків

StepRunner.cs
using System.Diagnostics;
using System.Text;
using BuildRunner;

class StepRunner
{
    public async Task<StepResult> RunAsync(BuildStep step, CancellationToken ct = default)
    {
        Console.ForegroundColor = ConsoleColor.Cyan;
        Console.WriteLine($"\n▶ [{step.Name}]");
        Console.WriteLine($"  {step.Command} {step.Arguments}");
        Console.ResetColor();

        var outputBuffer = new StringBuilder();
        var errorBuffer  = new StringBuilder();

        using var process = new Process();
        process.StartInfo = new ProcessStartInfo
        {
            FileName = step.Command,
            Arguments = step.Arguments,
            WorkingDirectory = step.WorkingDirectory ?? Directory.GetCurrentDirectory(),
            UseShellExecute = false,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            CreateNoWindow = true
        };

        process.OutputDataReceived += (_, e) =>
        {
            if (e.Data is null) return;
            outputBuffer.AppendLine(e.Data);
            Console.WriteLine($"  {e.Data}");  // live streaming
        };

        process.ErrorDataReceived += (_, e) =>
        {
            if (e.Data is null) return;
            errorBuffer.AppendLine(e.Data);

            Console.ForegroundColor = ConsoleColor.Yellow;
            Console.Error.WriteLine($"  [WARN] {e.Data}");
            Console.ResetColor();
        };

        var sw = Stopwatch.StartNew();
        process.Start();
        process.BeginOutputReadLine();
        process.BeginErrorReadLine();

        await process.WaitForExitAsync(ct);
        sw.Stop();

        bool success = process.ExitCode == 0;

        var statusColor = success ? ConsoleColor.Green : ConsoleColor.Red;
        Console.ForegroundColor = statusColor;
        Console.WriteLine($"  {(success ? "✅" : "❌")} [{step.Name}] exit={process.ExitCode}, time={sw.Elapsed.TotalSeconds:F2}s");
        Console.ResetColor();

        return new StepResult(
            step.Name,
            success,
            process.ExitCode,
            outputBuffer.ToString(),
            errorBuffer.ToString(),
            sw.Elapsed
        );
    }
}

Крок 3: Оркестратор пайплайну

BuildPipeline.cs
using BuildRunner;

class BuildPipeline(IEnumerable<BuildStep> steps)
{
    private readonly List<BuildStep> _steps = steps.ToList();
    private readonly StepRunner _runner = new();

    public async Task<List<StepResult>> RunAsync(CancellationToken ct = default)
    {
        var results = new List<StepResult>();

        Console.WriteLine($"🚀 Починаємо збірку: {_steps.Count} кроків");
        Console.WriteLine(new string('═', 60));

        foreach (var step in _steps)
        {
            if (ct.IsCancellationRequested)
            {
                Console.WriteLine("\n⚠️ Збірку скасовано");
                break;
            }

            var result = await _runner.RunAsync(step, ct);
            results.Add(result);

            if (!result.Success && !step.ContinueOnError)
            {
                Console.ForegroundColor = ConsoleColor.Red;
                Console.WriteLine($"\n🛑 Зупинено на кроці [{step.Name}] — помилка!");
                Console.ResetColor();
                break;
            }
        }

        PrintSummary(results);
        return results;
    }

    private static void PrintSummary(List<StepResult> results)
    {
        Console.WriteLine("\n" + new string('═', 60));
        Console.WriteLine("📊 ЗВІТ ЗБІРКИ");
        Console.WriteLine(new string('─', 60));

        var total = TimeSpan.FromTicks(results.Sum(r => r.Duration.Ticks));
        int success = results.Count(r => r.Success);
        int failed  = results.Count(r => !r.Success);

        foreach (var r in results)
        {
            string icon = r.Success ? "✅" : "❌";
            Console.WriteLine($"{icon} {r.StepName,-30} {r.Duration.TotalSeconds,6:F2}s  exit={r.ExitCode}");
        }

        Console.WriteLine(new string('─', 60));
        Console.WriteLine($"Кроків: {results.Count} | ✅ {success} | ❌ {failed} | Час: {total.TotalSeconds:F2}s");
    }
}

Крок 4: Точка входу з конфігурацією пайплайну

Program.cs
using BuildRunner;

var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
    e.Cancel = true;
    Console.WriteLine("\n⚠️ Отримано Ctrl+C — скасовуємо збірку...");
    cts.Cancel();
};

// Конфігурація пайплайну
var pipeline = new BuildPipeline(
[
    new BuildStep("Restore",   "dotnet", "restore",                 WorkingDirectory: "./MyProject"),
    new BuildStep("Build",     "dotnet", "build --no-restore -c Release", WorkingDirectory: "./MyProject"),
    new BuildStep("Test",      "dotnet", "test --no-build -c Release",    WorkingDirectory: "./MyProject"),
    new BuildStep("Publish",   "dotnet", "publish --no-build -c Release -o ./publish", WorkingDirectory: "./MyProject"),
    new BuildStep("Artifacts", "powershell", "-c \"Get-ChildItem ./publish | Select Name, Length\"",
        ContinueOnError: true)  // помилка тут не зупиняє збірку
]);

var results = await pipeline.RunAsync(cts.Token);

Environment.Exit(results.Any(r => !r.Success) ? 1 : 0);

Крок 5: Запуск

dotnet run
Build Automation Runner
$ dotnet run
🚀 Починаємо збірку: 5 кроків
════════════════════════════════════════════════════════════
▶ [Restore]
dotnet restore
Restored in 1.23s
✅ [Restore] exit=0, time=1.23s
▶ [Build]
dotnet build --no-restore -c Release
Build succeeded. 0 Warning(s) 0 Error(s)
✅ [Build] exit=0, time=3.81s
▶ [Test]
Passed! — Failed: 0, Passed: 42, Skipped: 1
✅ [Test] exit=0, time=8.14s
════════════════════════════════════════════════════════════
📊 ЗВІТ ЗБІРКИ
────────────────────────────────────────────────────────────
Restore 1.23s exit=0
Build 3.81s exit=0
Test 8.14s exit=0
Кроків: 5 | ✅ 5 | ❌ 0 | Час: 16.42s

Підсумок

Process API

  • GetCurrentProcess(), GetProcesses(), GetProcessesByName(), GetProcessById()
  • Властивості: Id, WorkingSet64, PrivateMemorySize64, Threads, HandleCount
  • Process реалізує IDisposable — завжди using

ProcessStartInfo

  • UseShellExecute = false — програмна взаємодія
  • UseShellExecute = true — відкрити документ/URL
  • ArgumentList безпечніший за Arguments для user input

Stdout/Stderr Capture

  • Async паралельне читання — уникаємо deadlock
  • OutputDataReceived + BeginOutputReadLine — real-time streaming
  • Підписка на події ДО виклику Start()

Завершення

  • CloseMainWindow() — graceful, чекає відповіді від вікна
  • Kill(entireProcessTree: true) — примусово, вбиває і дочірні
  • WaitForExitAsync() + CancellationToken — таймаут

Практичні Завдання

Рівень 1: Process Explorer

Напишіть консольну утиліту, що:

  1. Приймає опціональний аргумент --filter <name> для фільтрації за ім'ям процесу
  2. Виводить таблицю: PID, Name, RAM, CPU Time, Threads, Uptime
  3. Сортує за RAM (за замовчуванням) або за CPU (якщо вказано --sort cpu)
  4. У підсумку виводить: кількість процесів, загальний RAM, загальний CPU time

Рівень 2: Parallel Command Runner

Напишіть інструмент, що:

  1. Зчитує список команд з файлу commands.txt (по одній на рядок)
  2. Запускає їх паралельно (через Task.WhenAll)
  3. Для кожної команди: перехоплює stdout+stderr, вимірює час
  4. Генерує підсумковий файл report.md з результатами кожної команди

Рівень 3: Process Watchdog

Реалізуйте watchdog-службу:

  1. Конфігурація у watchdog.json: список процесів для моніторингу з maxRamMb, maxCpuPercent, restartCommand
  2. Кожні 10 секунд перевіряти процеси
  3. Якщо процес не знайдено або перевищив ліміти — запустити restartCommand
  4. Вести лог подій у файл з timestamp
  5. Завершуватися коректно при Ctrl+C (CancellationToken)