< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.ServiceHost.Program
Assembly: Kestrun.ServiceHost
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun.ServiceHost/Program.cs
Tag: Kestrun/Kestrun@09cad9a8fdafda7aca15f5f5e888b4bbcc8f0674
Line coverage
41%
Covered lines: 177
Uncovered lines: 246
Coverable lines: 423
Total lines: 942
Line coverage: 41.8%
Branch coverage
64%
Covered branches: 126
Total branches: 195
Branch coverage: 64.6%
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: 41.8% (177/423) Branch coverage: 64.6% (126/195) Total lines: 942 Tag: Kestrun/Kestrun@844b5179fb0492dc6b1182bae3ff65fa7365521d 03/26/2026 - 03:54:59 Line coverage: 41.8% (177/423) Branch coverage: 64.6% (126/195) Total lines: 942 Tag: Kestrun/Kestrun@844b5179fb0492dc6b1182bae3ff65fa7365521d

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_ServiceName()100%11100%
get_RunnerExecutablePath()100%11100%
get_ScriptPath()100%11100%
get_ModuleManifestPath()100%11100%
get_ScriptArguments()100%11100%
get_ServiceLogPath()100%210%
get_DirectRunMode()100%11100%
get_DiscoverPowerShellHome()100%11100%
get_ServiceName()100%11100%
get_RunnerExecutablePath()100%11100%
get_ScriptPath()100%11100%
get_ModuleManifestPath()100%11100%
get_ScriptArguments()100%11100%
get_ServiceLogPath()100%11100%
get_DirectRunMode()100%11100%
get_DiscoverPowerShellHome()100%11100%
get_ScriptOptionSeen()100%11100%
get_RunOptionSeen()100%11100%
Main(...)0%4260%
RunWindowsService(...)100%210%
TryParseArguments(...)100%22100%
TryParseOptionTokens(...)100%66100%
TryHandleArgumentToken(...)81.25%804875.86%
TryReadScriptPathOption(...)87.5%8892.86%
TryBuildParsedOptions(...)91.67%121292.59%
TryReadOptionValue(...)50%2275%
PrintUsage()100%210%
ResolveCurrentExecutablePath()25%7440%
BuildDefaultServiceNameFromScriptPath(...)100%66100%
RunForegroundDaemon(...)0%156120%
.ctor(...)100%210%
OnStart(...)0%620%
OnStop()100%210%
Dispose(...)0%620%
DisposeHostOnce()0%620%
.ctor(...)50%22100%
get_HasExited()0%620%
get_ExitCode()100%210%
Start()83.33%22624.44%
RegisterOnExit(...)100%22100%
Stop()100%11100%
StopForProcessExit()100%210%
StopCore(...)60%231050%
WriteBootstrapLog(...)100%1171.43%
Dispose()100%22100%
ExecuteScript(...)0%210140%
EnsureNet10Runtime()100%210%
ConfigurePowerShellHome(...)50%3242.86%
ResolveServiceRootFromManifestPath(...)50%8883.33%
EnsureKestrunAssemblyPreloaded(...)100%210%
EnsurePowerShellRuntimeHome()100%210%
HasKestrunHostManagerType()100%210%
RequestManagedStopAsync()100%11100%
WriteOutput(...)100%210%
WriteStreams(...)100%210%
ResolveBootstrapLogPath(...)100%210%
BuildDefaultServiceLogFileName(...)100%210%
SanitizeFileNameSegment(...)89.66%292991.67%
FormatScriptArguments(...)100%66100%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun.ServiceHost/Program.cs

#LineLine coverage
 1using System.Management.Automation;
 2using System.Management.Automation.Runspaces;
 3using Kestrun.Runner;
 4using Microsoft.PowerShell;
 5using System.Runtime.Versioning;
 6using System.ServiceProcess;
 7using System.Text;
 8
 9namespace Kestrun.ServiceHost;
 10
 11internal static class Program
 12{
 913    private sealed record ParsedOptions(
 914        string ServiceName,
 315        string RunnerExecutablePath,
 616        string ScriptPath,
 517        string ModuleManifestPath,
 318        string[] ScriptArguments,
 019        string? ServiceLogPath,
 920        bool DirectRunMode,
 1021        bool DiscoverPowerShellHome);
 22
 23    /// <summary>
 24    /// Mutable state used while parsing service-host command-line options.
 25    /// </summary>
 26    private sealed class ArgumentParseState
 27    {
 1728        public string ServiceName { get; set; } = string.Empty;
 29
 1530        public string RunnerExecutablePath { get; set; } = string.Empty;
 31
 2832        public string ScriptPath { get; set; } = string.Empty;
 33
 2134        public string ModuleManifestPath { get; set; } = string.Empty;
 35
 1236        public string[] ScriptArguments { get; set; } = [];
 37
 338        public string? ServiceLogPath { get; set; }
 39
 940        public bool DirectRunMode { get; set; }
 41
 442        public bool DiscoverPowerShellHome { get; set; }
 43
 744        public bool ScriptOptionSeen { get; set; }
 45
 746        public bool RunOptionSeen { get; set; }
 47    }
 48
 49    private static int Main(string[] args)
 50    {
 051        if (!TryParseArguments(args, out var options, out var error))
 52        {
 053            Console.Error.WriteLine(error);
 054            PrintUsage();
 055            return 2;
 56        }
 57        // If running on Windows and not in an interactive session, run as a Windows Service.
 058        if (OperatingSystem.IsWindows() && !Environment.UserInteractive)
 59        {
 060            return RunWindowsService(options!);
 61        }
 62        // For non-Windows or interactive sessions, run as a foreground daemon.
 063        return RunForegroundDaemon(options!);
 64    }
 65
 66    [SupportedOSPlatform("windows")]
 67    private static int RunWindowsService(ParsedOptions options)
 68    {
 069        ServiceBase.Run(new KestrunWindowsService(options));
 070        return 0;
 71    }
 72
 73    /// <summary>
 74    /// Parses command-line arguments into strongly typed service-host options.
 75    /// </summary>
 76    /// <param name="args">The command-line arguments passed to the application.</param>
 77    /// <param name="options">Parsed options when successful; otherwise null.</param>
 78    /// <param name="error">An error message if argument parsing fails.</param>
 79    /// <returns>True when arguments were successfully parsed; otherwise false.</returns>
 80    private static bool TryParseArguments(string[] args, out ParsedOptions? options, out string error)
 81    {
 882        options = null;
 83
 884        var parseState = new ArgumentParseState();
 885        return TryParseOptionTokens(args, parseState, out error)
 886            && TryBuildParsedOptions(parseState, out options, out error);
 87    }
 88
 89    /// <summary>
 90    /// Parses raw argument tokens into intermediate state.
 91    /// </summary>
 92    /// <param name="args">Raw argument list.</param>
 93    /// <param name="parseState">Mutable parse state.</param>
 94    /// <param name="error">Error message when parsing fails.</param>
 95    /// <returns>True when all tokens were parsed successfully; otherwise false.</returns>
 96    private static bool TryParseOptionTokens(string[] args, ArgumentParseState parseState, out string error)
 97    {
 898        error = string.Empty;
 899        var index = 0;
 26100        while (index < args.Length)
 101        {
 22102            if (!TryHandleArgumentToken(args, ref index, parseState, out var stopParsing, out error))
 103            {
 3104                return false;
 105            }
 106
 19107            if (stopParsing)
 108            {
 109                break;
 110            }
 111        }
 112
 5113        return true;
 114    }
 115
 116    /// <summary>
 117    /// Parses one argument token and updates parsing state.
 118    /// </summary>
 119    /// <param name="args">Raw argument list.</param>
 120    /// <param name="index">Current argument index.</param>
 121    /// <param name="parseState">Mutable parse state.</param>
 122    /// <param name="stopParsing">True when token consumes remaining arguments and parsing should stop.</param>
 123    /// <param name="error">Error message when parsing fails.</param>
 124    /// <returns>True when the token was parsed successfully; otherwise false.</returns>
 125    private static bool TryHandleArgumentToken(
 126        string[] args,
 127        ref int index,
 128        ArgumentParseState parseState,
 129        out bool stopParsing,
 130        out string error)
 131    {
 22132        stopParsing = false;
 22133        error = string.Empty;
 134
 22135        var current = args[index];
 136        switch (current)
 137        {
 138            case "--name":
 4139                if (!TryReadOptionValue(args, ref index, current, out var serviceName, out error))
 140                {
 0141                    return false;
 142                }
 143
 4144                parseState.ServiceName = serviceName;
 4145                return true;
 146
 147            case "--runner-exe":
 1148                if (!TryReadOptionValue(args, ref index, current, out var runnerExecutablePath, out error))
 149                {
 0150                    return false;
 151                }
 152
 1153                parseState.RunnerExecutablePath = runnerExecutablePath;
 1154                return true;
 155
 156            case "--script":
 4157                return TryReadScriptPathOption(args, ref index, current, parseState, directRunMode: false, out error);
 158
 159            case "--run":
 4160                return TryReadScriptPathOption(args, ref index, current, parseState, directRunMode: true, out error);
 161
 162            case "--kestrun-manifest":
 6163                if (!TryReadOptionValue(args, ref index, current, out var moduleManifestPath, out error))
 164                {
 0165                    return false;
 166                }
 167
 6168                parseState.ModuleManifestPath = moduleManifestPath;
 6169                return true;
 170
 171            case "--service-log-path":
 0172                if (!TryReadOptionValue(args, ref index, current, out var parsedLogPath, out error))
 173                {
 0174                    return false;
 175                }
 176
 0177                parseState.ServiceLogPath = parsedLogPath;
 0178                return true;
 179
 180            case "--discover-pshome":
 1181                parseState.DiscoverPowerShellHome = true;
 1182                index += 1;
 1183                return true;
 184
 185            case "--arguments":
 186            case "--":
 1187                parseState.ScriptArguments = [.. args.Skip(index + 1)];
 1188                stopParsing = true;
 1189                return true;
 190
 191            default:
 1192                error = $"Unknown option: {current}";
 1193                return false;
 194        }
 195    }
 196
 197    /// <summary>
 198    /// Reads the script path option value and enforces mutual exclusivity between <c>--script</c> and <c>--run</c>.
 199    /// </summary>
 200    /// <param name="args">Raw argument list.</param>
 201    /// <param name="index">Current argument index.</param>
 202    /// <param name="optionName">Option name being parsed.</param>
 203    /// <param name="parseState">Mutable parse state.</param>
 204    /// <param name="directRunMode">True when parsing <c>--run</c>; false for <c>--script</c>.</param>
 205    /// <param name="error">Error message when parsing fails.</param>
 206    /// <returns>True when parsing succeeds; otherwise false.</returns>
 207    private static bool TryReadScriptPathOption(
 208        string[] args,
 209        ref int index,
 210        string optionName,
 211        ArgumentParseState parseState,
 212        bool directRunMode,
 213        out string error)
 214    {
 8215        var conflictingOptionSeen = directRunMode
 8216            ? parseState.ScriptOptionSeen
 8217            : parseState.RunOptionSeen;
 8218        if (conflictingOptionSeen)
 219        {
 2220            error = "Options --script and --run are mutually exclusive.";
 2221            return false;
 222        }
 223
 6224        if (!TryReadOptionValue(args, ref index, optionName, out var scriptPath, out error))
 225        {
 0226            return false;
 227        }
 228
 6229        parseState.ScriptPath = scriptPath;
 6230        if (directRunMode)
 231        {
 3232            parseState.RunOptionSeen = true;
 3233            parseState.DirectRunMode = true;
 234        }
 235        else
 236        {
 3237            parseState.ScriptOptionSeen = true;
 238        }
 239
 6240        return true;
 241    }
 242
 243    /// <summary>
 244    /// Validates parsed state, applies defaults, and constructs final parsed options.
 245    /// </summary>
 246    /// <param name="parseState">Parsed intermediate state.</param>
 247    /// <param name="options">Final parsed options when successful; otherwise null.</param>
 248    /// <param name="error">Error message when validation fails.</param>
 249    /// <returns>True when final options can be built; otherwise false.</returns>
 250    private static bool TryBuildParsedOptions(ArgumentParseState parseState, out ParsedOptions? options, out string erro
 251    {
 5252        options = null;
 5253        error = string.Empty;
 254
 5255        var serviceName = parseState.ServiceName;
 5256        if (string.IsNullOrWhiteSpace(serviceName) && !string.IsNullOrWhiteSpace(parseState.ScriptPath))
 257        {
 3258            serviceName = BuildDefaultServiceNameFromScriptPath(parseState.ScriptPath, parseState.DirectRunMode);
 259        }
 260
 5261        if (string.IsNullOrWhiteSpace(serviceName))
 262        {
 0263            error = "Missing --name.";
 0264            return false;
 265        }
 266
 5267        var runnerExecutablePath = string.IsNullOrWhiteSpace(parseState.RunnerExecutablePath)
 5268            ? ResolveCurrentExecutablePath()
 5269            : parseState.RunnerExecutablePath;
 270
 5271        if (string.IsNullOrWhiteSpace(parseState.ScriptPath))
 272        {
 1273            error = "Missing --script or --run.";
 1274            return false;
 275        }
 276
 4277        if (string.IsNullOrWhiteSpace(parseState.ModuleManifestPath))
 278        {
 1279            error = "Missing --kestrun-manifest.";
 1280            return false;
 281        }
 282
 3283        options = new ParsedOptions(
 3284            serviceName,
 3285            Path.GetFullPath(runnerExecutablePath),
 3286            Path.GetFullPath(parseState.ScriptPath),
 3287            Path.GetFullPath(parseState.ModuleManifestPath),
 3288            parseState.ScriptArguments,
 3289            parseState.ServiceLogPath,
 3290            parseState.DirectRunMode,
 3291            parseState.DiscoverPowerShellHome);
 3292        return true;
 293    }
 294
 295    /// <summary>
 296    /// Reads a required value for the current option and advances the parse index.
 297    /// </summary>
 298    /// <param name="args">Raw argument list.</param>
 299    /// <param name="index">Current option index; advanced when a value is consumed.</param>
 300    /// <param name="optionName">Option name used in diagnostics.</param>
 301    /// <param name="value">Parsed option value.</param>
 302    /// <param name="error">Error message when value is missing.</param>
 303    /// <returns>True when a value exists; otherwise false.</returns>
 304    private static bool TryReadOptionValue(string[] args, ref int index, string optionName, out string value, out string
 305    {
 17306        value = string.Empty;
 17307        error = string.Empty;
 17308        if (index + 1 >= args.Length)
 309        {
 0310            error = $"Missing value for {optionName}.";
 0311            return false;
 312        }
 313
 17314        value = args[index + 1];
 17315        index += 2;
 17316        return true;
 317    }
 318
 0319    private static void PrintUsage() => Console.WriteLine("Usage: kestrun-service-host [--name <service>] [--runner-exe 
 320
 321    /// <summary>
 322    /// Resolves the path of the current executable for diagnostic and compatibility metadata.
 323    /// </summary>
 324    /// <returns>Absolute executable path when available; otherwise a fallback token.</returns>
 325    private static string ResolveCurrentExecutablePath()
 326    {
 4327        if (!string.IsNullOrWhiteSpace(Environment.ProcessPath))
 328        {
 4329            return Path.GetFullPath(Environment.ProcessPath);
 330        }
 331        // Fall back to a best-guess based on the current executable name and platform conventions.
 0332        return OperatingSystem.IsWindows()
 0333            ? Path.Combine(AppContext.BaseDirectory, "kestrun-service-host.exe")
 0334            : Path.Combine(AppContext.BaseDirectory, "kestrun-service-host");
 335    }
 336
 337    /// <summary>
 338    /// Builds the default service name when callers omit <c>--name</c>.
 339    /// </summary>
 340    /// <param name="scriptPath">Script path provided by the caller.</param>
 341    /// <param name="directRunMode">True when running in direct script mode.</param>
 342    /// <returns>Service name default derived from script path.</returns>
 343    private static string BuildDefaultServiceNameFromScriptPath(string scriptPath, bool directRunMode)
 344    {
 5345        var stem = Path.GetFileNameWithoutExtension(scriptPath);
 5346        if (string.IsNullOrWhiteSpace(stem))
 347        {
 3348            return directRunMode ? "kestrun-direct" : "kestrun-service";
 349        }
 350        // Sanitize the stem to ensure it's a valid filename segment, since it will be used in the default log file name
 2351        return directRunMode
 2352            ? $"kestrun-direct-{stem}"
 2353            : stem;
 354    }
 355
 356    private static int RunForegroundDaemon(ParsedOptions options)
 357    {
 0358        var logPath = ResolveBootstrapLogPath(options.ServiceLogPath, options.ServiceName);
 0359        using var host = new ScriptExecutionHost(options, logPath);
 360
 0361        using var shutdown = new CancellationTokenSource();
 0362        var processExitStopRequested = 0;
 0363        Console.CancelKeyPress += (_, eventArgs) =>
 0364        {
 0365            eventArgs.Cancel = true;
 0366            shutdown.Cancel();
 0367        };
 368
 0369        AppDomain.CurrentDomain.ProcessExit += (_, _) =>
 0370        {
 0371            if (Interlocked.Exchange(ref processExitStopRequested, 1) == 0)
 0372            {
 0373                host.WriteBootstrapLog("ProcessExit received. Triggering fast daemon shutdown.");
 0374                host.StopForProcessExit();
 0375            }
 0376
 0377            try
 0378            {
 0379                if (!shutdown.IsCancellationRequested)
 0380                {
 0381                    shutdown.Cancel();
 0382                }
 0383            }
 0384            catch (ObjectDisposedException)
 0385            {
 0386                // Process-exit can race with using-scope disposal; cancellation is best-effort.
 0387            }
 0388        };
 389
 0390        host.WriteBootstrapLog($"Daemon '{options.ServiceName}' starting.");
 391
 0392        var startCode = host.Start();
 0393        if (startCode != 0)
 394        {
 0395            return startCode;
 396        }
 397
 0398        while (!shutdown.IsCancellationRequested)
 399        {
 0400            if (host.HasExited)
 401            {
 0402                var exitCode = host.ExitCode;
 0403                host.WriteBootstrapLog($"Runner process exited with code {exitCode}.");
 0404                return exitCode;
 405            }
 406
 0407            Thread.Sleep(250);
 408        }
 409
 0410        host.WriteBootstrapLog($"Daemon '{options.ServiceName}' stopping.");
 0411        if (Volatile.Read(ref processExitStopRequested) == 0)
 412        {
 0413            host.Stop();
 414        }
 415        else
 416        {
 0417            host.WriteBootstrapLog("Daemon stop already requested from ProcessExit.");
 418        }
 419
 0420        host.WriteBootstrapLog($"Daemon '{options.ServiceName}' stopped.");
 0421        return 0;
 0422    }
 423
 424    [SupportedOSPlatform("windows")]
 425    private sealed class KestrunWindowsService : ServiceBase
 426    {
 427        private readonly ScriptExecutionHost _host;
 428        private int _hostDisposed;
 429
 0430        public KestrunWindowsService(ParsedOptions options)
 431        {
 0432            ServiceName = options.ServiceName;
 0433            CanStop = true;
 0434            AutoLog = true;
 0435            _host = new ScriptExecutionHost(options, ResolveBootstrapLogPath(options.ServiceLogPath, options.ServiceName
 0436        }
 437
 438        protected override void OnStart(string[] args)
 439        {
 0440            _host.WriteBootstrapLog($"Service '{ServiceName}' starting.");
 0441            var exitCode = _host.Start();
 0442            if (exitCode != 0)
 443            {
 0444                ExitCode = exitCode;
 0445                Stop();
 0446                return;
 447            }
 448
 0449            _host.RegisterOnExit(code =>
 0450            {
 0451                _host.WriteBootstrapLog($"Runner process exited with code {code}.");
 0452                ExitCode = code;
 0453                Stop();
 0454            });
 0455        }
 456
 457        /// <summary>
 458        /// Requests the script host to stop and waits for it to complete before allowing the service to stop. Also ensu
 459        /// </summary> <remarks>
 460        /// Windows Services have a complex lifecycle and can be stopped by the runtime in various ways, such as when th
 461        /// </remarks>
 462        protected override void OnStop()
 463        {
 0464            _host.WriteBootstrapLog($"Service '{ServiceName}' stopping.");
 0465            _host.Stop();
 0466            _host.WriteBootstrapLog($"Service '{ServiceName}' stopped.");
 0467            DisposeHostOnce();
 0468        }
 469
 470        /// <summary>
 471        /// Ensures the script host is disposed when the service is stopped or when the service object is disposed by th
 472        /// </summary>
 473        /// <param name="disposing">True when called from <see cref="IDisposable.Dispose"/>; false when called from the 
 474        protected override void Dispose(bool disposing)
 475        {
 0476            if (disposing)
 477            {
 0478                DisposeHostOnce();
 479            }
 480
 0481            base.Dispose(disposing);
 0482        }
 483
 484        /// <summary>
 485        /// Disposes the script host exactly once across <see cref="OnStop"/> and <see cref="Dispose(bool)"/>.
 486        /// </summary>
 487        private void DisposeHostOnce()
 488        {
 0489            if (Interlocked.Exchange(ref _hostDisposed, 1) == 0)
 490            {
 0491                _host.Dispose();
 492            }
 0493        }
 494    }
 495
 496    private sealed class ScriptExecutionHost : IDisposable
 497    {
 498        private readonly ParsedOptions _options;
 499        private readonly string _bootstrapLogDirectory;
 500        private readonly string _bootstrapLogPath;
 6501        private readonly Lock _sync = new();
 6502        private readonly CancellationTokenSource _shutdown = new();
 503        private Action<int>? _onExit;
 504        private Task<int>? _executionTask;
 505        private int? _exitCode;
 506        private int _stopRequested;
 507        private int _disposed;
 508
 6509        public ScriptExecutionHost(ParsedOptions options, string bootstrapLogPath)
 510        {
 6511            _options = options;
 6512            _bootstrapLogPath = bootstrapLogPath;
 6513            _bootstrapLogDirectory = Path.GetDirectoryName(_bootstrapLogPath) ?? Path.GetTempPath();
 6514            WriteBootstrapLog($"Initialized script host for service '{_options.ServiceName}' (directRun={_options.Direct
 6515        }
 516
 0517        public bool HasExited => _executionTask?.IsCompleted == true;
 518
 0519        public int ExitCode => _exitCode ?? 0;
 520
 521        public int Start()
 522        {
 3523            if (_executionTask is not null)
 524            {
 1525                WriteBootstrapLog("Start requested while execution task is already running.");
 1526                return 0;
 527            }
 528
 2529            WriteBootstrapLog(
 2530                $"Validating startup inputs. script='{_options.ScriptPath}', manifest='{_options.ModuleManifestPath}', r
 531
 2532            if (!File.Exists(_options.ScriptPath))
 533            {
 1534                WriteBootstrapLog($"Script file not found: {_options.ScriptPath}");
 1535                return 2;
 536            }
 537
 1538            if (!File.Exists(_options.ModuleManifestPath))
 539            {
 1540                WriteBootstrapLog($"Kestrun manifest file not found: {_options.ModuleManifestPath}");
 1541                return 2;
 542            }
 543
 544            try
 545            {
 0546                WriteBootstrapLog("Starting script execution task.");
 0547                _executionTask = Task.Run(() => ExecuteScript(
 0548                    _options.ScriptPath,
 0549                    _options.ScriptArguments,
 0550                    _options.ModuleManifestPath,
 0551                    _options.DiscoverPowerShellHome,
 0552                    WriteBootstrapLog,
 0553                    _shutdown.Token));
 554
 0555                _ = _executionTask.ContinueWith(task =>
 0556                {
 0557                    var code = task.IsFaulted ? 1 : task.Result;
 0558                    if (task.IsFaulted)
 0559                    {
 0560                        WriteBootstrapLog($"Script execution failed: {task.Exception?.GetBaseException()}");
 0561                    }
 0562                    else
 0563                    {
 0564                        WriteBootstrapLog($"Script execution task completed with exit code {code}.");
 0565                    }
 0566
 0567                    Action<int>? callback;
 0568                    lock (_sync)
 0569                    {
 0570                        _exitCode = code;
 0571                        callback = _onExit;
 0572                    }
 0573
 0574                    callback?.Invoke(code);
 0575                }, TaskScheduler.Default);
 576
 0577                return 0;
 578            }
 0579            catch (Exception ex)
 580            {
 0581                WriteBootstrapLog($"Failed to start script execution: {ex}");
 0582                return 1;
 583            }
 0584        }
 585
 586        public void RegisterOnExit(Action<int> onExit)
 1587        {
 588            lock (_sync)
 589            {
 1590                _onExit = onExit;
 1591                if (_exitCode.HasValue)
 592                {
 1593                    onExit(_exitCode.Value);
 594                }
 1595            }
 1596        }
 597
 598        public void Stop()
 3599            => StopCore(managedStopTimeoutMilliseconds: 5000, executionWaitTimeoutMilliseconds: 15000, reason: "Stop req
 600
 601        /// <summary>
 602        /// Requests a fast stop used during process-exit where shutdown time budgets are constrained by the host OS.
 603        /// </summary>
 604        public void StopForProcessExit()
 0605            => StopCore(managedStopTimeoutMilliseconds: 500, executionWaitTimeoutMilliseconds: 1500, reason: "Process-ex
 606
 607        /// <summary>
 608        /// Stops script execution, first requesting managed host shutdown, then waiting briefly for the execution task.
 609        /// </summary>
 610        /// <param name="managedStopTimeoutMilliseconds">Timeout for managed host stop coordination.</param>
 611        /// <param name="executionWaitTimeoutMilliseconds">Timeout waiting for execution task completion.</param>
 612        /// <param name="reason">Diagnostic reason written to bootstrap logs.</param>
 613        private void StopCore(int managedStopTimeoutMilliseconds, int executionWaitTimeoutMilliseconds, string reason)
 614        {
 615            try
 616            {
 3617                if (Interlocked.Exchange(ref _stopRequested, 1) != 0)
 618                {
 1619                    WriteBootstrapLog("Stop already requested; skipping duplicate stop operation.");
 1620                    return;
 621                }
 622
 2623                if (Volatile.Read(ref _disposed) != 0)
 624                {
 1625                    WriteBootstrapLog("Stop requested after host disposal; skipping shutdown cancellation.");
 1626                    return;
 627                }
 628
 1629                WriteBootstrapLog(reason);
 1630                _shutdown.Cancel();
 631
 1632                if (!RequestManagedStopAsync().Wait(managedStopTimeoutMilliseconds))
 633                {
 0634                    WriteBootstrapLog($"Managed host stop timed out after {managedStopTimeoutMilliseconds}ms.");
 635                }
 636                else
 637                {
 1638                    WriteBootstrapLog("Managed host stop completed.");
 639                }
 640
 1641                if (_executionTask is not null)
 642                {
 0643                    if (!_executionTask.Wait(executionWaitTimeoutMilliseconds))
 644                    {
 0645                        WriteBootstrapLog($"Execution task did not complete within {executionWaitTimeoutMilliseconds}ms 
 646                    }
 647                    else
 648                    {
 0649                        WriteBootstrapLog("Execution task completed after stop request.");
 650                    }
 651                }
 1652            }
 0653            catch (ObjectDisposedException ex)
 654            {
 0655                WriteBootstrapLog($"Failed to stop script execution: {ex.Message}");
 0656            }
 0657            catch (InvalidOperationException ex)
 658            {
 0659                WriteBootstrapLog($"Failed to stop script execution: {ex.Message}");
 0660            }
 0661            catch (AggregateException ex)
 662            {
 0663                WriteBootstrapLog($"Failed to stop script execution: {ex.GetBaseException().Message}");
 0664            }
 3665        }
 666
 667        public void WriteBootstrapLog(string message)
 668        {
 669            try
 670            {
 15671                _ = Directory.CreateDirectory(_bootstrapLogDirectory);
 15672                var line = $"{DateTime.UtcNow:O} {message}{Environment.NewLine}";
 15673                File.AppendAllText(_bootstrapLogPath, line, Encoding.UTF8);
 15674            }
 0675            catch
 676            {
 677                // Best-effort logging only.
 0678            }
 15679        }
 680
 681        public void Dispose()
 682        {
 1683            if (Interlocked.Exchange(ref _disposed, 1) == 0)
 684            {
 1685                _shutdown.Dispose();
 686            }
 1687        }
 688    }
 689
 690    /// <summary>
 691    /// Executes the target script in a runspace that has Kestrun imported by manifest path.
 692    /// </summary>
 693    /// <param name="scriptPath">Absolute path to the script to execute.</param>
 694    /// <param name="scriptArguments">Command-line arguments passed to the target script.</param>
 695    /// <param name="moduleManifestPath">Absolute path to Kestrun.psd1.</param>
 696    /// <param name="log">Best-effort service-host logger.</param>
 697    /// <param name="stopToken">Cancellation token signaled during service shutdown.</param>
 698    /// <returns>Process exit code.</returns>
 699    private static int ExecuteScript(
 700        string scriptPath,
 701        IReadOnlyList<string> scriptArguments,
 702        string moduleManifestPath,
 703        bool discoverPowerShellHome,
 704        Action<string> log,
 705        CancellationToken stopToken)
 706    {
 0707        log($"Preparing script execution. script='{scriptPath}', manifest='{moduleManifestPath}', args=[{FormatScriptArg
 0708        EnsureNet10Runtime();
 0709        log("Verified .NET 10 runtime.");
 0710        ConfigurePowerShellHome(discoverPowerShellHome, moduleManifestPath, log);
 0711        EnsurePowerShellRuntimeHome();
 0712        var psHome = Environment.GetEnvironmentVariable("PSHOME");
 0713        var psModulePath = Environment.GetEnvironmentVariable("PSModulePath");
 0714        log($"PowerShell runtime home prepared. PSHOME='{(string.IsNullOrWhiteSpace(psHome) ? "<null>" : psHome)}', PSMo
 0715        EnsureKestrunAssemblyPreloaded(moduleManifestPath, log);
 0716        log("Kestrun assembly preload completed.");
 717
 0718        var sessionState = InitialSessionState.CreateDefault2();
 0719        if (OperatingSystem.IsWindows())
 720        {
 0721            sessionState.ExecutionPolicy = ExecutionPolicy.Unrestricted;
 722        }
 723
 0724        sessionState.ImportPSModule([moduleManifestPath]);
 0725        log($"Imported module manifest '{moduleManifestPath}'.");
 726
 0727        using var runspace = RunspaceFactory.CreateRunspace(sessionState);
 0728        runspace.Open();
 0729        log("Runspace opened.");
 730
 0731        if (!HasKestrunHostManagerType())
 732        {
 0733            throw new RuntimeException("Failed to import Kestrun module: type Kestrun.KestrunHostManager was not loaded.
 734        }
 735
 0736        runspace.SessionStateProxy.SetVariable("__krRunnerScriptPath", scriptPath);
 0737        runspace.SessionStateProxy.SetVariable("__krRunnerScriptArgs", scriptArguments.ToArray());
 0738        runspace.SessionStateProxy.SetVariable("__krRunnerQuiet", true);
 0739        runspace.SessionStateProxy.SetVariable("__krRunnerManagedConsole", true);
 740
 0741        using var powershell = PowerShell.Create();
 0742        powershell.Runspace = runspace;
 743        // Dot-source the script into the current scope so function metadata used by OpenAPI discovery remains visible.
 0744        _ = powershell.AddScript(". $__krRunnerScriptPath @__krRunnerScriptArgs", useLocalScope: false);
 0745        log("PowerShell invocation configured. Starting asynchronous execution.");
 746
 747        IEnumerable<PSObject> output;
 0748        var asyncResult = powershell.BeginInvoke();
 0749        var stopRequested = false;
 750
 0751        while (!asyncResult.IsCompleted)
 752        {
 0753            _ = asyncResult.AsyncWaitHandle.WaitOne(200);
 0754            if (stopToken.IsCancellationRequested && !stopRequested)
 755            {
 0756                stopRequested = true;
 0757                log("Stop requested. Stopping Kestrun server...");
 0758                _ = Task.Run(RequestManagedStopAsync);
 759            }
 760        }
 761
 0762        output = powershell.EndInvoke(asyncResult);
 0763        log($"Script invocation completed. HadErrors={powershell.HadErrors}.");
 764
 0765        WriteOutput(output, log);
 0766        WriteStreams(powershell.Streams, log);
 767
 0768        return powershell.HadErrors ? 1 : 0;
 0769    }
 770
 771    /// <summary>
 772    /// Ensures the runner is executing on .NET 10.
 773    /// </summary>
 774    private static void EnsureNet10Runtime()
 0775        => RunnerRuntime.EnsureNet10Runtime("kestrun-service-host");
 776
 777    /// <summary>
 778    /// Configures <c>PSHOME</c> for service-host script execution.
 779    /// </summary>
 780    /// <param name="discoverPowerShellHome">When true, does not set <c>PSHOME</c> and lets runtime discovery resolve it
 781    /// <param name="moduleManifestPath">Absolute path to Kestrun.psd1.</param>
 782    /// <param name="log">Best-effort service-host logger.</param>
 783    private static void ConfigurePowerShellHome(bool discoverPowerShellHome, string moduleManifestPath, Action<string> l
 784    {
 1785        if (discoverPowerShellHome)
 786        {
 1787            log("PSHOME discovery mode enabled; skipping PSHOME override.");
 1788            return;
 789        }
 790
 0791        var serviceRoot = ResolveServiceRootFromManifestPath(moduleManifestPath);
 0792        Environment.SetEnvironmentVariable("PSHOME", serviceRoot);
 0793        log($"PSHOME set to service root '{serviceRoot}'.");
 0794    }
 795
 796    /// <summary>
 797    /// Resolves the service root path from the staged Kestrun module manifest.
 798    /// </summary>
 799    /// <param name="moduleManifestPath">Absolute path to Kestrun.psd1 under <c>Modules/Kestrun</c>.</param>
 800    /// <returns>Absolute service root path.</returns>
 801    private static string ResolveServiceRootFromManifestPath(string moduleManifestPath)
 802    {
 1803        var manifestDirectory = Path.GetDirectoryName(moduleManifestPath);
 1804        if (string.IsNullOrWhiteSpace(manifestDirectory))
 805        {
 0806            return AppContext.BaseDirectory;
 807        }
 808
 1809        var moduleRoot = Directory.GetParent(manifestDirectory);
 1810        var serviceRoot = moduleRoot?.Parent;
 1811        return serviceRoot?.FullName ?? AppContext.BaseDirectory;
 812    }
 813
 814    /// <summary>
 815    /// Ensures Kestrun.dll from the selected module root is loaded into the default context.
 816    /// </summary>
 817    /// <param name="moduleManifestPath">Absolute path to Kestrun.psd1.</param>
 818    /// <param name="log">Best-effort service-host logger.</param>
 819    private static void EnsureKestrunAssemblyPreloaded(string moduleManifestPath, Action<string> log)
 0820        => RunnerRuntime.EnsureKestrunAssemblyPreloaded(moduleManifestPath, message => log($"warning: {message}"));
 821
 822    /// <summary>
 823    /// Ensures PowerShell built-in modules are discoverable for embedded runspace execution.
 824    /// </summary>
 825    private static void EnsurePowerShellRuntimeHome()
 0826        => RunnerRuntime.EnsurePowerShellRuntimeHome(createFallbackDirectories: false);
 827
 828    /// <summary>
 829    /// Verifies that the loaded Kestrun assembly contains the expected host manager type.
 830    /// </summary>
 831    /// <returns>True when the expected Kestrun host manager type is available.</returns>
 832    private static bool HasKestrunHostManagerType()
 0833        => RunnerRuntime.HasKestrunHostManagerType();
 834
 835    /// <summary>
 836    /// Requests a graceful stop for all running Kestrun hosts managed in the current process.
 837    /// </summary>
 838    /// <returns>A task representing the stop attempt.</returns>
 839    private static Task RequestManagedStopAsync()
 1840        => RunnerRuntime.RequestManagedStopAsync();
 841
 842    /// <summary>
 843    /// Writes PowerShell pipeline output to stdout and service log.
 844    /// </summary>
 845    /// <param name="output">Pipeline output collection.</param>
 846    /// <param name="log">Best-effort service-host logger.</param>
 847    private static void WriteOutput(IEnumerable<PSObject> output, Action<string> log)
 0848        => RunnerRuntime.DispatchPowerShellOutput(
 0849            output,
 0850            value =>
 0851            {
 0852                Console.WriteLine(value);
 0853                log($"output: {value}");
 0854            },
 0855            skipWhitespace: true);
 856
 857    /// <summary>
 858    /// Writes non-output streams in a console-friendly format.
 859    /// </summary>
 860    /// <param name="streams">PowerShell data streams.</param>
 861    /// <param name="log">Best-effort service-host logger.</param>
 862    private static void WriteStreams(PSDataStreams streams, Action<string> log)
 863    {
 0864        RunnerRuntime.DispatchPowerShellStreams(
 0865            streams,
 0866            onWarning: message =>
 0867            {
 0868                Console.Error.WriteLine(message);
 0869                log($"warning: {message}");
 0870            },
 0871            onVerbose: message =>
 0872            {
 0873                Console.WriteLine(message);
 0874                log($"verbose: {message}");
 0875            },
 0876            onDebug: message =>
 0877            {
 0878                Console.WriteLine(message);
 0879                log($"debug: {message}");
 0880            },
 0881            onInformation: message =>
 0882            {
 0883                Console.WriteLine(message);
 0884                log($"info: {message}");
 0885            },
 0886            onError: message =>
 0887            {
 0888                Console.Error.WriteLine(message);
 0889                log($"error: {message}");
 0890            },
 0891            skipWhitespace: true);
 0892    }
 893
 894    private static string ResolveBootstrapLogPath(string? configuredPath, string serviceName)
 0895        => RunnerRuntime.ResolveBootstrapLogPath(configuredPath, BuildDefaultServiceLogFileName(serviceName));
 896
 897    /// <summary>
 898    /// Builds a default service log file name using the configured service name.
 899    /// </summary>
 900    /// <param name="serviceName">Configured service name.</param>
 901    /// <returns>Service-specific log file name.</returns>
 902    private static string BuildDefaultServiceLogFileName(string serviceName)
 0903        => $"kestrun-tool-service-{SanitizeFileNameSegment(serviceName)}.log";
 904
 905    /// <summary>
 906    /// Converts arbitrary service names to a filesystem-safe filename segment.
 907    /// </summary>
 908    /// <param name="value">Raw value to sanitize.</param>
 909    /// <returns>Safe filename segment.</returns>
 910    private static string SanitizeFileNameSegment(string value)
 911    {
 1912        if (string.IsNullOrWhiteSpace(value))
 913        {
 0914            return "default";
 915        }
 916
 1917        var invalidChars = Path.GetInvalidFileNameChars();
 1918        var sanitized = new string([..
 1919            value.Select(c =>
 12920                c < 32
 12921                || invalidChars.Contains(c)
 12922                || c is '<' or '>' or ':' or '"' or '/' or '\\' or '|' or '?' or '*'
 12923                    ? '-'
 12924                    : c)])
 1925            .Trim();
 1926        return string.IsNullOrWhiteSpace(sanitized) ? "default" : sanitized;
 927    }
 928
 929    /// <summary>
 930    /// Formats script arguments for diagnostic logging.
 931    /// </summary>
 932    /// <param name="scriptArguments">Script argument values.</param>
 933    /// <returns>Comma-separated argument list with shell-safe quoting.</returns>
 934    private static string FormatScriptArguments(IReadOnlyList<string> scriptArguments)
 3935        => scriptArguments.Count == 0
 3936            ? ""
 3937            : string.Join(", ",
 3938                scriptArguments.Select(static arg =>
 7939                    string.IsNullOrEmpty(arg)
 7940                        ? "\"\""
 7941                        : arg.Contains(' ') ? $"\"{arg}\"" : arg));
 942}

Methods/Properties

.ctor(System.String,System.String,System.String,System.String,System.String[],System.String,System.Boolean,System.Boolean)
get_ServiceName()
get_RunnerExecutablePath()
get_ScriptPath()
get_ModuleManifestPath()
get_ScriptArguments()
get_ServiceLogPath()
get_DirectRunMode()
get_DiscoverPowerShellHome()
get_ServiceName()
get_RunnerExecutablePath()
get_ScriptPath()
get_ModuleManifestPath()
get_ScriptArguments()
get_ServiceLogPath()
get_DirectRunMode()
get_DiscoverPowerShellHome()
get_ScriptOptionSeen()
get_RunOptionSeen()
Main(System.String[])
RunWindowsService(Kestrun.ServiceHost.Program/ParsedOptions)
TryParseArguments(System.String[],Kestrun.ServiceHost.Program/ParsedOptions&,System.String&)
TryParseOptionTokens(System.String[],Kestrun.ServiceHost.Program/ArgumentParseState,System.String&)
TryHandleArgumentToken(System.String[],System.Int32&,Kestrun.ServiceHost.Program/ArgumentParseState,System.Boolean&,System.String&)
TryReadScriptPathOption(System.String[],System.Int32&,System.String,Kestrun.ServiceHost.Program/ArgumentParseState,System.Boolean,System.String&)
TryBuildParsedOptions(Kestrun.ServiceHost.Program/ArgumentParseState,Kestrun.ServiceHost.Program/ParsedOptions&,System.String&)
TryReadOptionValue(System.String[],System.Int32&,System.String,System.String&,System.String&)
PrintUsage()
ResolveCurrentExecutablePath()
BuildDefaultServiceNameFromScriptPath(System.String,System.Boolean)
RunForegroundDaemon(Kestrun.ServiceHost.Program/ParsedOptions)
.ctor(Kestrun.ServiceHost.Program/ParsedOptions)
OnStart(System.String[])
OnStop()
Dispose(System.Boolean)
DisposeHostOnce()
.ctor(Kestrun.ServiceHost.Program/ParsedOptions,System.String)
get_HasExited()
get_ExitCode()
Start()
RegisterOnExit(System.Action`1<System.Int32>)
Stop()
StopForProcessExit()
StopCore(System.Int32,System.Int32,System.String)
WriteBootstrapLog(System.String)
Dispose()
ExecuteScript(System.String,System.Collections.Generic.IReadOnlyList`1<System.String>,System.String,System.Boolean,System.Action`1<System.String>,System.Threading.CancellationToken)
EnsureNet10Runtime()
ConfigurePowerShellHome(System.Boolean,System.String,System.Action`1<System.String>)
ResolveServiceRootFromManifestPath(System.String)
EnsureKestrunAssemblyPreloaded(System.String,System.Action`1<System.String>)
EnsurePowerShellRuntimeHome()
HasKestrunHostManagerType()
RequestManagedStopAsync()
WriteOutput(System.Collections.Generic.IEnumerable`1<System.Management.Automation.PSObject>,System.Action`1<System.String>)
WriteStreams(System.Management.Automation.PSDataStreams,System.Action`1<System.String>)
ResolveBootstrapLogPath(System.String,System.String)
BuildDefaultServiceLogFileName(System.String)
SanitizeFileNameSegment(System.String)
FormatScriptArguments(System.Collections.Generic.IReadOnlyList`1<System.String>)