File: Utils\EnvironmentChecker\EnvironmentChecker.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Globalization;
using System.Text.Json.Nodes;
using Aspire.Cli.Resources;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Utils.EnvironmentChecker;
 
/// <summary>
/// Orchestrates environment checks using injected IEnvironmentCheck instances.
/// </summary>
internal sealed class EnvironmentChecker : IEnvironmentChecker
{
    internal static readonly TimeSpan s_defaultCheckTimeout = TimeSpan.FromSeconds(30);
    internal static readonly TimeSpan s_defaultTotalTimeout = TimeSpan.FromMinutes(2);
 
    private readonly IEnvironmentCheck[] _checks;
    private readonly ILogger<EnvironmentChecker> _logger;
    private readonly TimeSpan _checkTimeout;
    private readonly TimeSpan _totalTimeout;
 
    public EnvironmentChecker(IEnumerable<IEnvironmentCheck> checks, ILogger<EnvironmentChecker> logger)
        : this(checks, logger, s_defaultCheckTimeout, s_defaultTotalTimeout)
    {
    }
 
    internal EnvironmentChecker(IEnumerable<IEnvironmentCheck> checks, ILogger<EnvironmentChecker> logger, TimeSpan checkTimeout, TimeSpan totalTimeout)
    {
        ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(checkTimeout, TimeSpan.Zero);
        ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(totalTimeout, TimeSpan.Zero);
 
        _checks = checks.OrderBy(c => c.Order).ToArray();
        _logger = logger;
        _checkTimeout = checkTimeout;
        _totalTimeout = totalTimeout;
    }
 
    /// <inheritdoc />
    public async Task<IReadOnlyList<EnvironmentCheckResult>> CheckAllAsync(CancellationToken cancellationToken = default)
    {
        var results = new List<EnvironmentCheckResult>();
        using var totalTimeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        totalTimeoutCts.CancelAfter(_totalTimeout);
 
        // Run all checks in order (by Order property)
        // Continue running remaining checks even if one fails
        foreach (var check in _checks)
        {
            var checkType = check.GetType();
            var checkName = GetCheckName(checkType);
            using var checkTimeoutCts = CancellationTokenSource.CreateLinkedTokenSource(totalTimeoutCts.Token);
            checkTimeoutCts.CancelAfter(_checkTimeout);
            var stopwatch = Stopwatch.StartNew();
 
            _logger.LogDebug("Starting environment check {CheckType}.", checkType.Name);
 
            try
            {
                // Some checks call synchronous platform APIs before returning their task. Scheduling the
                // invocation keeps those APIs from preventing the timeout token from being observed here.
                var checkTask = Task.Run(() => check.CheckAsync(checkTimeoutCts.Token), CancellationToken.None);
                // WaitAsync can time out while a non-cooperative check keeps running. Observe any
                // exception it produces later because it can no longer reach the catch block below.
                _ = checkTask.ContinueWith(
                    static task => _ = task.Exception,
                    CancellationToken.None,
                    TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
                    TaskScheduler.Default);
 
                var checkResults = await checkTask.WaitAsync(checkTimeoutCts.Token).ConfigureAwait(false);
                results.AddRange(checkResults);
 
                _logger.LogDebug(
                    "Environment check {CheckType} completed in {ElapsedMilliseconds} ms.",
                    checkType.Name,
                    stopwatch.ElapsedMilliseconds);
            }
            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
            {
                // User requested cancellation, stop all checks
                throw;
            }
            catch (OperationCanceledException) when (totalTimeoutCts.IsCancellationRequested)
            {
                _logger.LogWarning(
                    "Environment checks timed out after {TimeoutSeconds} seconds while running {CheckType}.",
                    _totalTimeout.TotalSeconds,
                    checkType.Name);
 
                results.Add(CreateTimeoutResult(
                    "environment-checks",
                    string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.EnvironmentChecksTimedOutMessageFormat, _totalTimeout.TotalSeconds),
                    checkType,
                    _totalTimeout));
                break;
            }
            catch (OperationCanceledException) when (checkTimeoutCts.IsCancellationRequested)
            {
                _logger.LogWarning(
                    "Environment check {CheckType} timed out after {TimeoutSeconds} seconds.",
                    checkType.Name,
                    _checkTimeout.TotalSeconds);
 
                results.Add(CreateTimeoutResult(
                    checkName,
                    string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.EnvironmentCheckTimedOutMessageFormat, checkName, _checkTimeout.TotalSeconds),
                    checkType,
                    _checkTimeout));
            }
            catch (Exception ex)
            {
                // Log the error but continue with other checks
                _logger.LogDebug(ex, "Environment check {CheckType} failed with exception", checkType.Name);
            }
        }
 
        return results;
    }
 
    private static EnvironmentCheckResult CreateTimeoutResult(string name, string message, Type checkType, TimeSpan timeout)
    {
        return new EnvironmentCheckResult
        {
            Category = EnvironmentCheckCategories.Environment,
            Name = name,
            Status = EnvironmentCheckStatus.Warning,
            Message = message,
            Metadata = new JsonObject
            {
                ["checkType"] = checkType.Name,
                ["timeoutSeconds"] = timeout.TotalSeconds,
            }
        };
    }
 
    private static string GetCheckName(Type checkType)
    {
        var typeName = checkType.Name.EndsWith("Check", StringComparison.Ordinal)
            ? checkType.Name[..^"Check".Length]
            : checkType.Name;
        var name = new System.Text.StringBuilder(typeName.Length + 4);
 
        for (var i = 0; i < typeName.Length; i++)
        {
            var character = typeName[i];
            if (i > 0 && char.IsUpper(character) && !char.IsUpper(typeName[i - 1]))
            {
                name.Append('-');
            }
 
            name.Append(char.ToLowerInvariant(character));
        }
 
        return name.ToString();
    }
}