< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Runner.RunnerRuntime
Assembly: Kestrun.Runner
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun.Runner/RunnerRuntime.cs
Tag: Kestrun/Kestrun@09cad9a8fdafda7aca15f5f5e888b4bbcc8f0674
Line coverage
55%
Covered lines: 166
Uncovered lines: 131
Coverable lines: 297
Total lines: 774
Line coverage: 55.8%
Branch coverage
52%
Covered branches: 84
Total branches: 160
Branch coverage: 52.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 03/26/2026 - 03:54:59 Line coverage: 55.8% (166/297) Branch coverage: 52.5% (84/160) Total lines: 774 Tag: Kestrun/Kestrun@844b5179fb0492dc6b1182bae3ff65fa7365521d 03/26/2026 - 03:54:59 Line coverage: 55.8% (166/297) Branch coverage: 52.5% (84/160) Total lines: 774 Tag: Kestrun/Kestrun@844b5179fb0492dc6b1182bae3ff65fa7365521d

Metrics

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun.Runner/RunnerRuntime.cs

#LineLine coverage
 1using System.Diagnostics;
 2using System.Management.Automation;
 3using System.Reflection;
 4using System.Runtime.InteropServices;
 5using System.Runtime.Loader;
 6
 7namespace Kestrun.Runner;
 8
 9/// <summary>
 10/// Provides shared runtime and process helpers for Kestrun script runner hosts.
 11/// </summary>
 12public static class RunnerRuntime
 13{
 114    private static readonly Lock AssemblyLoadSync = new();
 15    private static string? s_kestrunModuleLibPath;
 16    private static bool s_dependencyResolverRegistered;
 817    private sealed record KestrunAssemblyLoadInfo(string ModuleLibPath, string ExpectedAssemblyPath, Version? ExpectedVe
 18
 19    /// <summary>
 20    /// Ensures the runner is executing on .NET 10.
 21    /// </summary>
 22    /// <param name="productName">Product name used in the exception message.</param>
 23    public static void EnsureNet10Runtime(string productName)
 24    {
 125        var framework = RuntimeInformation.FrameworkDescription;
 126        var runtimeVersion = Environment.Version;
 127        if (runtimeVersion.Major < 10)
 28        {
 029            throw new RuntimeException(
 030                $"{productName} requires .NET 10 runtime (Environment.Version.Major >= 10). Current runtime: {framework}
 31        }
 132    }
 33
 34    /// <summary>
 35    /// Ensures Kestrun.dll from the selected module root is loaded into the default context.
 36    /// </summary>
 37    /// <param name="moduleManifestPath">Absolute path to Kestrun.psd1.</param>
 38    /// <param name="onWarning">Optional warning callback for non-fatal assembly preload conditions.</param>
 39    public static void EnsureKestrunAssemblyPreloaded(string moduleManifestPath, Action<string>? onWarning = null)
 40    {
 241        var expected = ResolveExpectedKestrunAssemblyLoadInfo(moduleManifestPath);
 42        lock (AssemblyLoadSync)
 43        {
 244            EnsureDependencyResolverRegistered();
 45
 246            var alreadyLoaded = GetLoadedKestrunAssembly();
 247            if (alreadyLoaded is not null && TryHandleAlreadyLoadedKestrunAssembly(alreadyLoaded, expected, onWarning))
 48            {
 249                return;
 50            }
 51
 052            s_kestrunModuleLibPath = expected.ModuleLibPath;
 053            _ = AssemblyLoadContext.Default.LoadFromAssemblyPath(expected.ExpectedAssemblyPath);
 054        }
 255    }
 56
 57    /// <summary>
 58    /// Resolves and validates the expected Kestrun assembly load information from a module manifest path.
 59    /// </summary>
 60    /// <param name="moduleManifestPath">Absolute path to Kestrun.psd1.</param>
 61    /// <returns>Resolved Kestrun assembly load information.</returns>
 62    private static KestrunAssemblyLoadInfo ResolveExpectedKestrunAssemblyLoadInfo(string moduleManifestPath)
 63    {
 264        var manifestDirectory = Path.GetDirectoryName(moduleManifestPath);
 265        if (string.IsNullOrWhiteSpace(manifestDirectory))
 66        {
 067            throw new RuntimeException($"Unable to resolve manifest directory from: {moduleManifestPath}");
 68        }
 69
 270        var moduleLibPath = Path.Combine(manifestDirectory, "lib", "net10.0");
 271        var expectedAssemblyPath = Path.Combine(moduleLibPath, "Kestrun.dll");
 272        if (!File.Exists(expectedAssemblyPath))
 73        {
 074            throw new RuntimeException($"Kestrun assembly not found at expected path: {expectedAssemblyPath}");
 75        }
 76
 277        var expectedFullPath = Path.GetFullPath(expectedAssemblyPath);
 278        var expectedAssemblyName = AssemblyName.GetAssemblyName(expectedFullPath);
 279        return new KestrunAssemblyLoadInfo(moduleLibPath, expectedFullPath, expectedAssemblyName.Version);
 80    }
 81
 82    /// <summary>
 83    /// Registers the dependency resolver for assemblies loaded from the selected Kestrun module folder.
 84    /// </summary>
 85    private static void EnsureDependencyResolverRegistered()
 86    {
 287        if (s_dependencyResolverRegistered)
 88        {
 189            return;
 90        }
 91
 192        AssemblyLoadContext.Default.Resolving += ResolveKestrunModuleDependency;
 193        s_dependencyResolverRegistered = true;
 194    }
 95
 96    /// <summary>
 97    /// Returns the already-loaded Kestrun assembly from the current application domain when available.
 98    /// </summary>
 99    /// <returns>The loaded Kestrun assembly when present; otherwise null.</returns>
 100    private static Assembly? GetLoadedKestrunAssembly()
 2101        => AppDomain.CurrentDomain.GetAssemblies()
 70102            .FirstOrDefault(a => string.Equals(a.GetName().Name, "Kestrun", StringComparison.Ordinal));
 103
 104    /// <summary>
 105    /// Validates an already-loaded Kestrun assembly against the expected module assembly and handles compatibility warn
 106    /// </summary>
 107    /// <param name="loadedAssembly">Already-loaded Kestrun assembly.</param>
 108    /// <param name="expected">Expected Kestrun assembly load information.</param>
 109    /// <param name="onWarning">Optional warning callback for compatible preloaded assemblies from a different path.</pa
 110    /// <returns>True when startup should continue without loading another assembly; otherwise false.</returns>
 111    private static bool TryHandleAlreadyLoadedKestrunAssembly(
 112        Assembly loadedAssembly,
 113        KestrunAssemblyLoadInfo expected,
 114        Action<string>? onWarning)
 115    {
 2116        var loadedPath = GetNormalizedAssemblyLocation(loadedAssembly);
 2117        if (string.Equals(loadedPath, expected.ExpectedAssemblyPath, StringComparison.OrdinalIgnoreCase))
 118        {
 0119            return true;
 120        }
 121
 2122        var loadedVersion = loadedAssembly.GetName().Version;
 2123        if (!IsKestrunAssemblyVersionCompatible(loadedVersion, expected.ExpectedVersion))
 124        {
 0125            throw new RuntimeException(
 0126                "Kestrun assembly was already loaded from a different location with an incompatible version. "
 0127                + $"Loaded version: {FormatVersionForDiagnostics(loadedVersion)}; "
 0128                + $"expected version: {FormatVersionForDiagnostics(expected.ExpectedVersion)}.");
 129        }
 130
 2131        onWarning?.Invoke(
 2132            $"Kestrun assembly was already loaded from a different location; continuing with loaded assembly version "
 2133            + $"'{FormatVersionForDiagnostics(loadedVersion)}' "
 2134            + $"(expected '{FormatVersionForDiagnostics(expected.ExpectedVersion)}'). "
 2135            + $"Loaded path: '{loadedPath}'. Expected path: '{expected.ExpectedAssemblyPath}'.");
 2136        return true;
 137    }
 138
 139    /// <summary>
 140    /// Returns a normalized assembly location path suitable for diagnostics and comparisons.
 141    /// </summary>
 142    /// <param name="assembly">Assembly to inspect.</param>
 143    /// <returns>Full path when available; otherwise an empty string.</returns>
 144    private static string GetNormalizedAssemblyLocation(Assembly assembly)
 2145        => string.IsNullOrWhiteSpace(assembly.Location)
 2146            ? string.Empty
 2147            : Path.GetFullPath(assembly.Location);
 148
 149    /// <summary>
 150    /// Formats assembly versions for diagnostics and warning messages.
 151    /// </summary>
 152    /// <param name="version">Version value to format.</param>
 153    /// <returns>Formatted version text, or <c>unknown</c> when version is unavailable.</returns>
 154    private static string FormatVersionForDiagnostics(Version? version)
 3155        => version is null ? "unknown" : version.ToString();
 156
 157    /// <summary>
 158    /// Determines whether an already-loaded Kestrun assembly version is compatible with the expected module version.
 159    /// </summary>
 160    /// <param name="loadedVersion">Version of the assembly already loaded in the process.</param>
 161    /// <param name="expectedVersion">Version of the assembly selected from the module path.</param>
 162    /// <returns>True when versions are considered compatible for startup continuation; otherwise false.</returns>
 163    private static bool IsKestrunAssemblyVersionCompatible(Version? loadedVersion, Version? expectedVersion)
 164    {
 7165        if (loadedVersion is null || expectedVersion is null)
 166        {
 2167            return false;
 168        }
 169
 5170        if (loadedVersion.Major != expectedVersion.Major || loadedVersion.Minor != expectedVersion.Minor)
 171        {
 1172            return false;
 173        }
 174
 175        static int NormalizeBuild(int value)
 176        {
 8177            return value < 0 ? 0 : value;
 178        }
 179
 4180        var loadedBuild = NormalizeBuild(loadedVersion.Build);
 4181        var expectedBuild = NormalizeBuild(expectedVersion.Build);
 4182        return loadedBuild >= expectedBuild;
 183    }
 184
 185    /// <summary>
 186    /// Ensures PowerShell built-in modules are discoverable for embedded runspace execution.
 187    /// </summary>
 188    /// <param name="createFallbackDirectories">When true, creates a writable fallback PSHOME and module folder if no in
 189    public static void EnsurePowerShellRuntimeHome(bool createFallbackDirectories)
 190    {
 0191        var currentPsHome = Environment.GetEnvironmentVariable("PSHOME");
 0192        var existingPsHome = HasPowerShellManagementModule(currentPsHome)
 0193            ? currentPsHome
 0194            : null;
 0195        if (existingPsHome is not null)
 196        {
 0197            EnsurePsModulePathContains(Path.Combine(existingPsHome, "Modules"));
 0198            return;
 199        }
 200
 0201        foreach (var candidate in EnumeratePowerShellHomeCandidates())
 202        {
 0203            if (!HasPowerShellManagementModule(candidate))
 204            {
 205                continue;
 206            }
 207
 0208            Environment.SetEnvironmentVariable("PSHOME", candidate);
 0209            EnsurePsModulePathContains(Path.Combine(candidate, "Modules"));
 0210            return;
 211        }
 212
 0213        if (!createFallbackDirectories)
 214        {
 0215            return;
 216        }
 217
 0218        var fallbackPsHome = GetFallbackPowerShellHomePath();
 0219        TryEnsureDirectory(fallbackPsHome);
 220
 0221        var modulesPath = Path.Combine(fallbackPsHome, "Modules");
 0222        TryEnsureDirectory(modulesPath);
 223
 0224        Environment.SetEnvironmentVariable("PSHOME", fallbackPsHome);
 0225        EnsurePsModulePathContains(modulesPath);
 0226    }
 227
 228    /// <summary>
 229    /// Verifies that the loaded Kestrun assembly contains the expected host manager type.
 230    /// </summary>
 231    /// <returns>True when the expected Kestrun host manager type is available.</returns>
 232    public static bool HasKestrunHostManagerType()
 233    {
 0234        var kestrunAssembly = AppDomain.CurrentDomain
 0235            .GetAssemblies()
 0236            .FirstOrDefault(a => string.Equals(a.GetName().Name, "Kestrun", StringComparison.Ordinal));
 237
 0238        return kestrunAssembly?.GetType("Kestrun.KestrunHostManager", throwOnError: false, ignoreCase: false) is not nul
 239    }
 240
 241    /// <summary>
 242    /// Requests a graceful stop for all running Kestrun hosts managed in the current process.
 243    /// </summary>
 244    /// <returns>A task representing the stop attempt.</returns>
 245    public static async Task RequestManagedStopAsync()
 246    {
 1247        var hostManagerType = ResolveKestrunHostManagerType();
 1248        if (hostManagerType is null)
 249        {
 1250            return;
 251        }
 252
 253        try
 254        {
 0255            var stopAllAsyncMethod = hostManagerType.GetMethod(
 0256                "StopAllAsync",
 0257                BindingFlags.Public | BindingFlags.Static,
 0258                binder: null,
 0259                types: [typeof(CancellationToken)],
 0260                modifiers: null);
 0261            if (stopAllAsyncMethod is not null)
 262            {
 0263                if (stopAllAsyncMethod.Invoke(null, [CancellationToken.None]) is Task stopTask)
 264                {
 0265                    await stopTask.ConfigureAwait(false);
 266                }
 267            }
 268
 0269            var destroyAllMethod = hostManagerType.GetMethod("DestroyAll", BindingFlags.Public | BindingFlags.Static);
 0270            _ = destroyAllMethod?.Invoke(null, null);
 0271        }
 0272        catch
 273        {
 274            // Best-effort shutdown: ignore reflection/host state errors.
 0275        }
 1276    }
 277
 278    /// <summary>
 279    /// Resolves the Kestrun host manager type from the current process.
 280    /// </summary>
 281    /// <returns>The host manager type when available; otherwise null.</returns>
 282    private static Type? ResolveKestrunHostManagerType()
 283    {
 1284        var hostManagerType = Type.GetType("Kestrun.KestrunHostManager, Kestrun", throwOnError: false, ignoreCase: false
 1285        if (hostManagerType is not null)
 286        {
 0287            return hostManagerType;
 288        }
 289
 290        // Fallback: inspect loaded assemblies in case the simple assembly-qualified lookup misses.
 1291        return AppDomain.CurrentDomain
 1292            .GetAssemblies()
 38293            .Select(static assembly => assembly.GetType("Kestrun.KestrunHostManager", throwOnError: false, ignoreCase: f
 39294            .FirstOrDefault(static type => type is not null);
 295    }
 296
 297    /// <summary>
 298    /// Resolves a bootstrap log path from an optional configured path and default file name.
 299    /// </summary>
 300    /// <param name="configuredPath">Configured file or directory path.</param>
 301    /// <param name="defaultFileName">Default log file name when no path is configured.</param>
 302    /// <returns>Resolved absolute log file path.</returns>
 303    public static string ResolveBootstrapLogPath(string? configuredPath, string defaultFileName)
 304    {
 9305        var defaultDirectory = GetDefaultBootstrapLogDirectory();
 9306        var defaultPath = Path.Combine(defaultDirectory, defaultFileName);
 307
 9308        if (string.IsNullOrWhiteSpace(configuredPath))
 309        {
 7310            return defaultPath;
 311        }
 312
 2313        var fullPath = Path.GetFullPath(configuredPath);
 2314        return Directory.Exists(fullPath)
 2315            ? Path.Combine(fullPath, defaultFileName)
 2316            : fullPath;
 317    }
 318
 319    /// <summary>
 320    /// Returns the default bootstrap log directory for the current platform.
 321    /// </summary>
 322    /// <returns>Writable preferred log directory path.</returns>
 323    private static string GetDefaultBootstrapLogDirectory()
 324    {
 9325        if (OperatingSystem.IsWindows())
 326        {
 0327            return Path.Combine(
 0328                Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
 0329                "Kestrun",
 0330                "logs");
 331        }
 332
 9333        if (OperatingSystem.IsLinux())
 334        {
 9335            var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
 9336            return Path.Combine(userProfile, ".local", "share", "kestrun", "logs");
 337        }
 338
 0339        if (OperatingSystem.IsMacOS())
 340        {
 0341            var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
 0342            return Path.Combine(userProfile, "Library", "Application Support", "Kestrun", "logs");
 343        }
 344
 0345        return Path.Combine(
 0346            Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
 0347            "Kestrun",
 0348            "logs");
 349    }
 350
 351    /// <summary>
 352    /// Dispatches non-empty PowerShell pipeline output text to a caller-provided sink.
 353    /// </summary>
 354    /// <param name="output">PowerShell pipeline output collection.</param>
 355    /// <param name="onOutput">Callback invoked for each selected output line.</param>
 356    /// <param name="skipWhitespace">When true, whitespace-only values are ignored.</param>
 357    public static void DispatchPowerShellOutput(IEnumerable<PSObject> output, Action<string> onOutput, bool skipWhitespa
 358    {
 1359        ArgumentNullException.ThrowIfNull(output);
 1360        ArgumentNullException.ThrowIfNull(onOutput);
 361
 8362        foreach (var item in output)
 363        {
 3364            if (item is null)
 365            {
 366                continue;
 367            }
 368
 2369            var value = item.BaseObject?.ToString() ?? item.ToString();
 2370            if (skipWhitespace && string.IsNullOrWhiteSpace(value))
 371            {
 372                continue;
 373            }
 374
 1375            onOutput(value ?? string.Empty);
 376        }
 1377    }
 378
 379    /// <summary>
 380    /// Dispatches PowerShell non-output streams to caller-provided handlers.
 381    /// </summary>
 382    /// <param name="streams">PowerShell data streams.</param>
 383    /// <param name="onWarning">Optional warning message handler.</param>
 384    /// <param name="onVerbose">Optional verbose message handler.</param>
 385    /// <param name="onDebug">Optional debug message handler.</param>
 386    /// <param name="onInformation">Optional information message handler.</param>
 387    /// <param name="onError">Optional error message handler.</param>
 388    /// <param name="skipWhitespace">When true, whitespace-only values are ignored.</param>
 389    public static void DispatchPowerShellStreams(
 390        PSDataStreams streams,
 391        Action<string>? onWarning,
 392        Action<string>? onVerbose,
 393        Action<string>? onDebug,
 394        Action<string>? onInformation,
 395        Action<string>? onError,
 396        bool skipWhitespace)
 397    {
 1398        ArgumentNullException.ThrowIfNull(streams);
 399
 2400        DispatchMessages(streams.Warning, static record => record.Message, onWarning, skipWhitespace);
 2401        DispatchMessages(streams.Verbose, static record => record.Message, onVerbose, skipWhitespace);
 2402        DispatchMessages(streams.Debug, static record => record.Message, onDebug, skipWhitespace);
 2403        DispatchMessages(streams.Information, static record => record.MessageData?.ToString() ?? record.ToString(), onIn
 2404        DispatchMessages(streams.Error, static record => record.ToString(), onError, skipWhitespace);
 1405    }
 406
 407    /// <summary>
 408    /// Resolves Kestrun module dependencies from the selected module lib folder.
 409    /// </summary>
 410    /// <param name="context">Assembly load context that requested the assembly.</param>
 411    /// <param name="assemblyName">Requested assembly identity.</param>
 412    /// <returns>Loaded assembly when available; otherwise null.</returns>
 413    private static Assembly? ResolveKestrunModuleDependency(AssemblyLoadContext context, AssemblyName assemblyName)
 414    {
 0415        if (string.IsNullOrWhiteSpace(assemblyName.Name))
 416        {
 0417            return null;
 418        }
 419
 0420        var moduleLibPath = s_kestrunModuleLibPath;
 0421        if (string.IsNullOrWhiteSpace(moduleLibPath))
 422        {
 0423            return null;
 424        }
 425
 0426        var candidatePath = Path.Combine(moduleLibPath, $"{assemblyName.Name}.dll");
 0427        if (!File.Exists(candidatePath))
 428        {
 0429            return null;
 430        }
 431
 432        try
 433        {
 0434            return context.LoadFromAssemblyPath(Path.GetFullPath(candidatePath));
 435        }
 0436        catch
 437        {
 0438            return null;
 439        }
 0440    }
 441
 442    /// <summary>
 443    /// Dispatches formatted stream records through an optional callback.
 444    /// </summary>
 445    /// <typeparam name="TRecord">PowerShell stream record type.</typeparam>
 446    /// <param name="records">Record sequence.</param>
 447    /// <param name="formatter">Record-to-message formatter.</param>
 448    /// <param name="callback">Optional callback invoked for each message.</param>
 449    /// <param name="skipWhitespace">When true, whitespace-only values are ignored.</param>
 450    private static void DispatchMessages<TRecord>(
 451        IEnumerable<TRecord> records,
 452        Func<TRecord, string?> formatter,
 453        Action<string>? callback,
 454        bool skipWhitespace)
 455    {
 5456        if (callback is null)
 457        {
 0458            return;
 459        }
 460
 20461        foreach (var record in records)
 462        {
 5463            var message = formatter(record);
 5464            if (skipWhitespace && string.IsNullOrWhiteSpace(message))
 465            {
 466                continue;
 467            }
 468
 5469            callback(message ?? string.Empty);
 470        }
 5471    }
 472
 473    /// <summary>
 474    /// Ensures a module path exists in PSModulePath.
 475    /// </summary>
 476    /// <param name="path">Path to include.</param>
 477    private static void EnsurePsModulePathContains(string path)
 478    {
 2479        if (!Directory.Exists(path))
 480        {
 0481            return;
 482        }
 483
 2484        var modulePath = Environment.GetEnvironmentVariable("PSModulePath") ?? string.Empty;
 2485        var entries = modulePath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.Tr
 2486        if (entries.Contains(path, StringComparer.OrdinalIgnoreCase))
 487        {
 1488            return;
 489        }
 490
 1491        var updated = string.IsNullOrWhiteSpace(modulePath)
 1492            ? path
 1493            : string.Join(Path.PathSeparator, new[] { path }.Concat(entries));
 1494        Environment.SetEnvironmentVariable("PSModulePath", updated);
 1495    }
 496
 497    /// <summary>
 498    /// Determines whether a path contains the built-in Microsoft.PowerShell.Management module.
 499    /// </summary>
 500    /// <param name="psHome">PowerShell home candidate.</param>
 501    /// <returns>True when the module path exists.</returns>
 502    private static bool HasPowerShellManagementModule(string? psHome)
 503    {
 4504        if (string.IsNullOrWhiteSpace(psHome))
 505        {
 0506            return false;
 507        }
 508
 4509        var moduleDirectory = Path.Combine(psHome, "Modules", "Microsoft.PowerShell.Management");
 4510        if (!Directory.Exists(moduleDirectory))
 511        {
 1512            return false;
 513        }
 514
 3515        var manifestPath = Path.Combine(moduleDirectory, "Microsoft.PowerShell.Management.psd1");
 3516        if (!File.Exists(manifestPath))
 517        {
 0518            return false;
 519        }
 520
 521        try
 522        {
 3523            var manifestText = File.ReadAllText(manifestPath);
 3524            return manifestText.Contains("CompatiblePSEditions", StringComparison.Ordinal)
 3525                && manifestText.Contains("Core", StringComparison.OrdinalIgnoreCase);
 526        }
 0527        catch
 528        {
 0529            return false;
 530        }
 3531    }
 532
 533    /// <summary>
 534    /// Enumerates likely PowerShell installation roots.
 535    /// </summary>
 536    /// <returns>Distinct absolute PowerShell home candidates.</returns>
 537    private static IEnumerable<string> EnumeratePowerShellHomeCandidates()
 538    {
 0539        var candidates = new List<string>();
 540
 0541        var envPsHome = Environment.GetEnvironmentVariable("PSHOME");
 0542        if (!string.IsNullOrWhiteSpace(envPsHome))
 543        {
 0544            candidates.Add(Path.GetFullPath(envPsHome));
 545        }
 546
 0547        if (OperatingSystem.IsWindows())
 548        {
 0549            var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
 0550            if (!string.IsNullOrWhiteSpace(programFiles))
 551            {
 0552                candidates.Add(Path.Combine(programFiles, "PowerShell", "7"));
 0553                candidates.Add(Path.Combine(programFiles, "PowerShell", "7-preview"));
 554            }
 555
 0556            var whereResult = RunProcessCapture("where.exe", ["pwsh"]);
 0557            if (whereResult.ExitCode == 0)
 558            {
 0559                var discovered = whereResult.Output
 0560                    .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 0561                    .Select(Path.GetDirectoryName)
 0562                    .Where(static p => !string.IsNullOrWhiteSpace(p))
 0563                    .Select(static p => Path.GetFullPath(p!));
 0564                candidates.AddRange(discovered);
 565            }
 566        }
 567        else
 568        {
 0569            candidates.Add("/opt/microsoft/powershell/7");
 570
 0571            var whichResult = RunProcessCapture("which", ["pwsh"]);
 0572            if (whichResult.ExitCode == 0)
 573            {
 0574                var discovered = whichResult.Output
 0575                    .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
 0576                candidates.AddRange(discovered);
 577            }
 578        }
 579
 0580        candidates = [
 0581            .. candidates
 0582                .Select(NormalizePowerShellHomeCandidate)
 0583                .Where(static path => !string.IsNullOrWhiteSpace(path))
 0584        ];
 585
 0586        return candidates
 0587            .Where(Directory.Exists)
 0588            .Distinct(StringComparer.OrdinalIgnoreCase);
 589    }
 590
 591    /// <summary>
 592    /// Normalizes a PowerShell home candidate, resolving executable symlinks to their installation directory.
 593    /// </summary>
 594    /// <param name="path">Candidate path (directory or pwsh executable path).</param>
 595    /// <returns>Normalized directory path when possible; otherwise an empty string.</returns>
 596    private static string NormalizePowerShellHomeCandidate(string path)
 597    {
 4598        if (string.IsNullOrWhiteSpace(path))
 599        {
 1600            return string.Empty;
 601        }
 602
 3603        var trimmedPath = path.Trim();
 3604        var fullPath = Path.GetFullPath(trimmedPath);
 605
 3606        if (!IsPowerShellExecutablePath(fullPath))
 607        {
 1608            return fullPath;
 609        }
 610
 2611        if (!File.Exists(fullPath))
 612        {
 0613            return string.Empty;
 614        }
 615
 2616        var executableCandidates = new List<string> { fullPath };
 2617        var resolvedPath = TryResolveFinalPath(fullPath);
 2618        if (!string.IsNullOrWhiteSpace(resolvedPath)
 2619            && !string.Equals(resolvedPath, fullPath, StringComparison.OrdinalIgnoreCase))
 620        {
 0621            executableCandidates.Insert(0, resolvedPath);
 622        }
 623
 7624        foreach (var executablePath in executableCandidates)
 625        {
 2626            var candidateHome = Path.GetDirectoryName(executablePath);
 2627            if (string.IsNullOrWhiteSpace(candidateHome))
 628            {
 629                continue;
 630            }
 631
 2632            var normalizedHome = Path.GetFullPath(candidateHome);
 2633            if (HasPowerShellManagementModule(normalizedHome))
 634            {
 1635                return normalizedHome;
 636            }
 637        }
 638
 1639        return string.Empty;
 1640    }
 641
 642    /// <summary>
 643    /// Determines whether a path points to the pwsh executable.
 644    /// </summary>
 645    /// <param name="path">Path to evaluate.</param>
 646    /// <returns>True when the file name is pwsh or pwsh.exe.</returns>
 647    private static bool IsPowerShellExecutablePath(string path)
 648    {
 6649        if (string.IsNullOrWhiteSpace(path))
 650        {
 0651            return false;
 652        }
 653
 6654        var trimmedPath = path.TrimEnd('/', '\\');
 6655        var separatorIndex = trimmedPath.LastIndexOfAny(['/', '\\']);
 6656        var fileName = separatorIndex >= 0
 6657            ? trimmedPath[(separatorIndex + 1)..]
 6658            : trimmedPath;
 659
 6660        return string.Equals(fileName, "pwsh", StringComparison.OrdinalIgnoreCase)
 6661            || string.Equals(fileName, "pwsh.exe", StringComparison.OrdinalIgnoreCase);
 662    }
 663
 664    /// <summary>
 665    /// Resolves a symlink path to its final target when supported by the platform.
 666    /// </summary>
 667    /// <param name="path">Path that may be a symbolic link.</param>
 668    /// <returns>Resolved full target path when available; otherwise null.</returns>
 669    private static string? TryResolveFinalPath(string path)
 670    {
 671        try
 672        {
 3673            var resolved = File.ResolveLinkTarget(path, returnFinalTarget: true);
 3674            return resolved is null ? null : Path.GetFullPath(resolved.FullName);
 675        }
 0676        catch
 677        {
 0678            return null;
 679        }
 3680    }
 681
 682    /// <summary>
 683    /// Returns a writable fallback PSHOME location based on operating system.
 684    /// </summary>
 685    /// <returns>Fallback PSHOME absolute path.</returns>
 686    private static string GetFallbackPowerShellHomePath()
 687    {
 0688        if (OperatingSystem.IsWindows())
 689        {
 0690            var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
 0691            return Path.Combine(localAppData, "PowerShell", "7");
 692        }
 693
 0694        if (OperatingSystem.IsLinux())
 695        {
 0696            var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
 0697            return Path.Combine(userProfile, ".local", "share", "powershell", "7");
 698        }
 699
 0700        if (OperatingSystem.IsMacOS())
 701        {
 0702            var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
 0703            return Path.Combine(userProfile, "Library", "Application Support", "powershell", "7");
 704        }
 705
 0706        var localFallback = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
 0707        return Path.Combine(localFallback, "powershell", "7");
 708    }
 709
 710    /// <summary>
 711    /// Ensures a directory exists without throwing.
 712    /// </summary>
 713    /// <param name="path">Directory path to create.</param>
 714    private static void TryEnsureDirectory(string path)
 715    {
 2716        if (string.IsNullOrWhiteSpace(path))
 717        {
 1718            return;
 719        }
 720
 721        try
 722        {
 1723            _ = Directory.CreateDirectory(path);
 1724        }
 0725        catch
 726        {
 727            // Best-effort bootstrap path creation.
 0728        }
 1729    }
 730
 731    /// <summary>
 732    /// Runs a process and captures output for diagnostics.
 733    /// </summary>
 734    /// <param name="fileName">Executable to run.</param>
 735    /// <param name="arguments">Argument tokens.</param>
 736    /// <returns>Process result data.</returns>
 737    private static ProcessResult RunProcessCapture(string fileName, IReadOnlyList<string> arguments)
 738    {
 2739        var startInfo = new ProcessStartInfo
 2740        {
 2741            FileName = fileName,
 2742            UseShellExecute = false,
 2743            RedirectStandardOutput = true,
 2744            RedirectStandardError = true,
 2745            CreateNoWindow = true,
 2746        };
 747
 12748        foreach (var argument in arguments)
 749        {
 4750            startInfo.ArgumentList.Add(argument);
 751        }
 752
 2753        using var process = Process.Start(startInfo);
 2754        if (process is null)
 755        {
 0756            return new ProcessResult(1, string.Empty, $"Failed to start process: {fileName}");
 757        }
 758
 2759        var outputTask = process.StandardOutput.ReadToEndAsync();
 2760        var errorTask = process.StandardError.ReadToEndAsync();
 761
 2762        process.WaitForExit();
 2763        Task.WaitAll(outputTask, errorTask);
 2764        return new ProcessResult(process.ExitCode, outputTask.Result, errorTask.Result);
 2765    }
 766
 767    /// <summary>
 768    /// Captures child process execution results.
 769    /// </summary>
 770    /// <param name="ExitCode">Process exit code.</param>
 771    /// <param name="Output">Captured standard output.</param>
 772    /// <param name="Error">Captured standard error.</param>
 7773    private sealed record ProcessResult(int ExitCode, string Output, string Error);
 774}

Methods/Properties

.cctor()
get_ModuleLibPath()
EnsureNet10Runtime(System.String)
EnsureKestrunAssemblyPreloaded(System.String,System.Action`1<System.String>)
ResolveExpectedKestrunAssemblyLoadInfo(System.String)
EnsureDependencyResolverRegistered()
GetLoadedKestrunAssembly()
TryHandleAlreadyLoadedKestrunAssembly(System.Reflection.Assembly,Kestrun.Runner.RunnerRuntime/KestrunAssemblyLoadInfo,System.Action`1<System.String>)
GetNormalizedAssemblyLocation(System.Reflection.Assembly)
FormatVersionForDiagnostics(System.Version)
IsKestrunAssemblyVersionCompatible(System.Version,System.Version)
NormalizeBuild()
EnsurePowerShellRuntimeHome(System.Boolean)
HasKestrunHostManagerType()
RequestManagedStopAsync()
ResolveKestrunHostManagerType()
ResolveBootstrapLogPath(System.String,System.String)
GetDefaultBootstrapLogDirectory()
DispatchPowerShellOutput(System.Collections.Generic.IEnumerable`1<System.Management.Automation.PSObject>,System.Action`1<System.String>,System.Boolean)
DispatchPowerShellStreams(System.Management.Automation.PSDataStreams,System.Action`1<System.String>,System.Action`1<System.String>,System.Action`1<System.String>,System.Action`1<System.String>,System.Action`1<System.String>,System.Boolean)
ResolveKestrunModuleDependency(System.Runtime.Loader.AssemblyLoadContext,System.Reflection.AssemblyName)
DispatchMessages(System.Collections.Generic.IEnumerable`1<TRecord>,System.Func`2<TRecord,System.String>,System.Action`1<System.String>,System.Boolean)
EnsurePsModulePathContains(System.String)
HasPowerShellManagementModule(System.String)
EnumeratePowerShellHomeCandidates()
NormalizePowerShellHomeCandidate(System.String)
IsPowerShellExecutablePath(System.String)
TryResolveFinalPath(System.String)
GetFallbackPowerShellHomePath()
TryEnsureDirectory(System.String)
RunProcessCapture(System.String,System.Collections.Generic.IReadOnlyList`1<System.String>)
get_ExitCode()