< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Health.ProcessProbe
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Health/ProcessProbe.cs
Tag: Kestrun/Kestrun@2d87023b37eb91155071c91dd3d6a2eeb3004705
Line coverage
63%
Covered lines: 91
Uncovered lines: 53
Coverable lines: 144
Total lines: 333
Line coverage: 63.1%
Branch coverage
43%
Covered branches: 26
Total branches: 60
Branch coverage: 43.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 10/13/2025 - 16:52:37 Line coverage: 63.1% (91/144) Branch coverage: 43.3% (26/60) Total lines: 333 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e 10/13/2025 - 16:52:37 Line coverage: 63.1% (91/144) Branch coverage: 43.3% (26/60) Total lines: 333 Tag: Kestrun/Kestrun@10d476bee71c71ad215bb8ab59f219887b5b4a5e

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)83.33%66100%
get_Name()100%11100%
get_Tags()100%11100%
get_Logger()100%11100%
CheckAsync()68.75%301661.76%
CreateProcess()100%11100%
RunProcessAsync()100%11100%
SetupProcessKillRegistration(...)100%4477.27%
WaitForProcessWithTimeout()100%1177.77%
ReadProcessStreams()100%1166.66%
TryParseJsonContract(...)6.25%1881612.5%
ParseJsonData(...)0%7280%
MapExitCode(...)58.33%141275%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Health/ProcessProbe.cs

#LineLine coverage
 1using System.Diagnostics;
 2using System.Text.Json;
 3using Serilog.Events;
 4using Serilog;
 5
 6namespace Kestrun.Health;
 7
 8/// <summary>
 9/// A health probe that runs an external process and interprets its output.
 10/// </summary>
 11/// <remarks>
 12/// Initializes a new instance of the <see cref="ProcessProbe"/> class.
 13/// </remarks>
 14/// <param name="name">The name of the probe.</param>
 15/// <param name="tags">The tags associated with the probe.</param>
 16/// <param name="fileName">The file name of the process to run.</param>
 17/// <param name="args">The arguments to pass to the process.</param>
 18/// <param name="timeout">The timeout for the process to complete.</param>
 19/// <param name="logger">Optional logger; if null a contextual logger is created.</param>
 320public sealed class ProcessProbe(string name, string[] tags, string fileName, string args = "", TimeSpan? timeout = null
 21{
 22    /// <summary>
 23    /// The name of the probe.
 24    /// </summary>
 1025    public string Name { get; } = name;
 26    /// <summary>
 27    /// The tags associated with the probe.
 28    /// </summary>
 329    public string[] Tags { get; } = tags;
 30    /// <summary>
 31    /// Logger used for diagnostics.
 32    /// </summary>
 1733    public Serilog.ILogger Logger { get; init; } = logger ?? Log.ForContext("HealthProbe", name);
 34    /// <summary>
 35    /// The file name of the process to run.
 36    /// </summary>
 337    private readonly string _fileName = fileName;
 38    /// <summary>
 39    /// The arguments to pass to the process.
 40    /// </summary>
 341    private readonly string _args = args ?? "";
 42    /// <summary>
 43    /// The timeout for the process to complete.
 44    /// </summary>
 345    private readonly TimeSpan _timeout = timeout ?? TimeSpan.FromSeconds(10);
 46
 47    /// <summary>
 48    /// Executes the process and interprets its output according to the health probe contract.
 49    /// </summary>
 50    /// <param name="ct">The cancellation token.</param>
 51    /// <returns>A task representing the asynchronous operation, with a <see cref="ProbeResult"/> as the result.</return
 52    public async Task<ProbeResult> CheckAsync(CancellationToken ct = default)
 53    {
 354        var sw = Stopwatch.StartNew();
 55        try
 56        {
 357            using var proc = CreateProcess();
 358            _ = proc.Start();
 359            if (Logger.IsEnabled(LogEventLevel.Debug))
 60            {
 361                Logger.Debug("ProcessProbe {Probe} started process {File} {Args} (PID={Pid}) with timeout {Timeout}", Na
 62            }
 363            var (outText, errText, timedOut) = await RunProcessAsync(proc, ct).ConfigureAwait(false);
 364            if (timedOut)
 65            {
 166                sw.Stop();
 167                if (Logger.IsEnabled(LogEventLevel.Debug))
 68                {
 169                    Logger.Debug("ProcessProbe {Probe} internal timeout after {Timeout} (duration={Duration}ms)", Name, 
 70                }
 171                var data = string.IsNullOrWhiteSpace(outText)
 172                    ? null
 173                    : new Dictionary<string, object> { ["stdout"] = outText.Length > 500 ? outText[..500] : outText };
 174                return new ProbeResult(ProbeStatus.Degraded, $"Timed out after {_timeout.TotalMilliseconds}ms", data);
 75            }
 276            sw.Stop();
 277            if (TryParseJsonContract(outText, out var contractResult))
 78            {
 079                if (Logger.IsEnabled(LogEventLevel.Debug))
 80                {
 081                    Logger.Debug("ProcessProbe {Probe} parsed JSON contract (exit={ExitCode}, duration={Duration}ms)", N
 82                }
 083                return contractResult;
 84            }
 285            var mapped = MapExitCode(proc.ExitCode, errText);
 286            if (Logger.IsEnabled(LogEventLevel.Debug))
 87            {
 288                Logger.Debug("ProcessProbe {Probe} completed (exit={ExitCode}, status={Status}, duration={Duration}ms)",
 89            }
 290            return mapped;
 91        }
 092        catch (OperationCanceledException) when (ct.IsCancellationRequested)
 93        {
 94            // Surface caller/request cancellation without converting to a health status; runner decides final response.
 095            throw;
 96        }
 097        catch (TaskCanceledException ex)
 98        {
 99            // Internal timeout (cts.CancelAfter) -> degrade instead of unhealthy so transient slowness isn't reported a
 0100            sw.Stop();
 0101            Logger.Warning(ex, "ProcessProbe {Probe} timed out after {Timeout} (duration={Duration}ms)", Name, _timeout,
 0102            return new ProbeResult(ProbeStatus.Degraded, $"Timed out: {ex.Message}");
 103        }
 0104        catch (Exception ex)
 105        {
 0106            sw.Stop();
 0107            Logger.Error(ex, "ProcessProbe {Probe} failed after {Duration}ms", Name, sw.ElapsedMilliseconds);
 0108            return new ProbeResult(ProbeStatus.Unhealthy, $"Exception: {ex.Message}");
 109        }
 3110    }
 111
 112    /// <summary>
 113    /// Creates and configures the process to be executed.
 114    /// </summary>
 3115    private Process CreateProcess() => new()
 3116    {
 3117        StartInfo = new ProcessStartInfo
 3118        {
 3119            FileName = _fileName,
 3120            Arguments = _args,
 3121            RedirectStandardOutput = true,
 3122            RedirectStandardError = true,
 3123            UseShellExecute = false,
 3124            CreateNoWindow = true
 3125        },
 3126        EnableRaisingEvents = true
 3127    };
 128
 129    /// <summary>
 130    /// Runs the process and captures its standard output and error.
 131    /// </summary>
 132    /// <param name="proc">The process to run.</param>
 133    /// <param name="ct">The cancellation token.</param>
 134    /// <returns>A task representing the asynchronous operation, with the standard output and error as the result.</retu
 135    private async Task<(string StdOut, string StdErr, bool TimedOut)> RunProcessAsync(Process proc, CancellationToken ct
 136    {
 3137        using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
 3138        cts.CancelAfter(_timeout);
 139
 3140        var stdOutTask = proc.StandardOutput.ReadToEndAsync(ct);
 3141        var stdErrTask = proc.StandardError.ReadToEndAsync(ct);
 142
 3143        using var reg = SetupProcessKillRegistration(proc, cts.Token);
 3144        var timedOut = await WaitForProcessWithTimeout(proc, cts.Token, ct);
 3145        var (outText, errText) = await ReadProcessStreams(stdOutTask, stdErrTask, timedOut, ct);
 146
 3147        return (outText, errText, timedOut);
 3148    }
 149
 150    /// <summary>
 151    /// Sets up a cancellation token registration to kill the process when timeout or cancellation occurs.
 152    /// </summary>
 153    /// <param name="proc">The process to potentially kill.</param>
 154    /// <param name="cancellationToken">The token that triggers the kill operation.</param>
 155    /// <returns>The cancellation token registration.</returns>
 156    private CancellationTokenRegistration SetupProcessKillRegistration(Process proc, CancellationToken cancellationToken
 157    {
 3158        return cancellationToken.Register(() =>
 3159        {
 3160            try
 3161            {
 1162                if (!proc.HasExited)
 3163                {
 1164                    if (Logger.IsEnabled(LogEventLevel.Debug))
 3165                    {
 1166                        Logger.Debug("ProcessProbe {Probe} cancel/timeout -> killing PID {Pid}", Name, proc.Id);
 3167                    }
 1168                    proc.Kill(entireProcessTree: true);
 3169                }
 1170            }
 0171            catch (InvalidOperationException)
 3172            {
 3173                // Process already exited, safe to ignore.
 0174            }
 0175            catch (Exception ex)
 3176            {
 0177                Logger.Warning(ex, "ProcessProbe {Probe} exception while attempting to kill PID {Pid}", Name, proc.Id);
 0178            }
 4179        });
 180    }
 181
 182    /// <summary>
 183    /// Waits for the process to exit, handling timeout scenarios.
 184    /// </summary>
 185    /// <param name="proc">The process to wait for.</param>
 186    /// <param name="timeoutToken">Token that fires on timeout.</param>
 187    /// <param name="callerToken">The original caller's cancellation token.</param>
 188    /// <returns>True if the process timed out, false if it completed normally.</returns>
 189    private static async Task<bool> WaitForProcessWithTimeout(Process proc, CancellationToken timeoutToken, Cancellation
 190    {
 191        try
 192        {
 3193            await proc.WaitForExitAsync(timeoutToken).ConfigureAwait(false);
 2194            return false; // No timeout
 195        }
 1196        catch (OperationCanceledException) when (!callerToken.IsCancellationRequested)
 197        {
 198            // Internal timeout fired; process kill requested via registration. Mark and wait for real exit to drain str
 199            try
 200            {
 1201                proc.WaitForExit(); // ensure fully exited so streams close
 1202            }
 0203            catch (Exception ex) when (ex is InvalidOperationException)
 204            {
 205                // ignore - already exited
 0206            }
 1207            return true; // Timed out
 208        }
 3209    }
 210
 211    /// <summary>
 212    /// Reads the process stdout and stderr streams with error handling.
 213    /// </summary>
 214    /// <param name="stdOutTask">Task reading standard output.</param>
 215    /// <param name="stdErrTask">Task reading standard error.</param>
 216    /// <param name="timedOut">Whether the process timed out.</param>
 217    /// <param name="callerToken">The original caller's cancellation token.</param>
 218    /// <returns>The stdout and stderr text.</returns>
 219    private static async Task<(string StdOut, string StdErr)> ReadProcessStreams(
 220        Task<string> stdOutTask,
 221        Task<string> stdErrTask,
 222        bool timedOut,
 223        CancellationToken callerToken)
 224    {
 3225        var outText = string.Empty;
 3226        var errText = string.Empty;
 227
 228        try
 229        {
 3230            outText = await stdOutTask.ConfigureAwait(false);
 3231        }
 0232        catch (OperationCanceledException) when (timedOut && !callerToken.IsCancellationRequested)
 233        {
 234            // stdout read was canceled by caller token? (should not happen since we only passed caller token)
 0235        }
 236
 237        try
 238        {
 3239            errText = await stdErrTask.ConfigureAwait(false);
 3240        }
 0241        catch (OperationCanceledException) when (timedOut && !callerToken.IsCancellationRequested)
 242        {
 243            // ignore similar to above
 0244        }
 245
 3246        return (outText, errText);
 3247    }
 248
 249    /// <summary>
 250    /// Parses the JSON contract from the process output.
 251    /// </summary>
 252    /// <param name="outText">The standard output text from the process.</param>
 253    /// <param name="result">The parsed probe result.</param>
 254    /// <returns>True if the JSON contract was successfully parsed; otherwise, false.</returns>
 255    private bool TryParseJsonContract(string? outText, out ProbeResult result)
 256    {
 2257        if (string.IsNullOrWhiteSpace(outText))
 258        {
 2259            result = default!;
 2260            return false;
 261        }
 262
 263        try
 264        {
 0265            using var doc = JsonDocument.Parse(outText);
 0266            if (!doc.RootElement.TryGetProperty("status", out var statusProp))
 267            {
 0268                result = default!;
 0269                return false;
 270            }
 271
 0272            var status = statusProp.GetString()?.ToLowerInvariant() switch
 0273            {
 0274                ProbeStatusLabels.STATUS_HEALTHY => ProbeStatus.Healthy,
 0275                ProbeStatusLabels.STATUS_DEGRADED => ProbeStatus.Degraded,
 0276                ProbeStatusLabels.STATUS_UNHEALTHY => ProbeStatus.Unhealthy,
 0277                _ => ProbeStatus.Unhealthy
 0278            };
 279
 0280            var desc = doc.RootElement.TryGetProperty("description", out var d) ? d.GetString() : null;
 0281            var data = ParseJsonData(doc.RootElement);
 0282            result = new ProbeResult(status, desc, data);
 0283            return true;
 284        }
 0285        catch (Exception ex)
 286        {
 0287            if (Logger.IsEnabled(LogEventLevel.Debug))
 288            {
 0289                Logger.Debug(ex, "ProcessProbe {Probe} output not valid contract JSON", Name);
 290            }
 0291            result = default!;
 0292            return false;
 293        }
 0294    }
 295
 296    /// <summary>
 297    /// Parses the "data" property from the JSON root element into a dictionary.
 298    /// </summary>
 299    /// <param name="root">The root JSON element.</param>
 300    /// <returns>A dictionary containing the parsed data, or null if no data is present.</returns>
 301    private static IReadOnlyDictionary<string, object>? ParseJsonData(JsonElement root)
 302    {
 0303        if (!root.TryGetProperty("data", out var dataProp) || dataProp.ValueKind != JsonValueKind.Object)
 304        {
 0305            return null;
 306        }
 307
 0308        var dict = new Dictionary<string, object>();
 0309        foreach (var p in dataProp.EnumerateObject())
 310        {
 0311            dict[p.Name] = p.Value.ToString();
 312        }
 0313        return dict.Count == 0 ? null : dict;
 314    }
 315
 316    /// <summary>
 317    /// Maps the process exit code to a ProbeResult according to the health probe contract.
 318    /// </summary>
 319    /// <param name="code">The exit code of the process.</param>
 320    /// <param name="errText">The error text from the process output.</param>
 321    /// <returns>The mapped ProbeResult.</returns>
 322    private static ProbeResult MapExitCode(int code, string? errText)
 323    {
 2324        var trimmedErr = string.IsNullOrWhiteSpace(errText) ? null : errText.Trim();
 2325        return code switch
 2326        {
 1327            0 => new ProbeResult(ProbeStatus.Healthy, trimmedErr ?? "OK"),
 1328            1 => new ProbeResult(ProbeStatus.Degraded, trimmedErr ?? "Degraded"),
 0329            2 => new ProbeResult(ProbeStatus.Unhealthy, trimmedErr ?? "Unhealthy"),
 0330            _ => new ProbeResult(ProbeStatus.Unhealthy, $"Exit {code}: {trimmedErr}".TrimEnd(':', ' '))
 2331        };
 332    }
 333}