< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Tasks.KestrunTaskService
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Tasks/KestrunTaskService.cs
Tag: Kestrun/Kestrun@5f1d2b981c9d7292c11fd448428c6ab6c811c5de
Line coverage
71%
Covered lines: 140
Uncovered lines: 55
Coverable lines: 195
Total lines: 497
Line coverage: 71.7%
Branch coverage
85%
Covered branches: 84
Total branches: 98
Branch coverage: 85.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 11/19/2025 - 17:40:50 Line coverage: 76.1% (102/134) Branch coverage: 91.4% (75/82) Total lines: 359 Tag: Kestrun/Kestrun@fcf33342333cef0516fe0d0912a86709874fd02603/27/2026 - 14:18:40 Line coverage: 71.7% (140/195) Branch coverage: 85.7% (84/98) Total lines: 497 Tag: Kestrun/Kestrun@63388ea9aed376ffbb41cd2727be2fb7646f6402 11/19/2025 - 17:40:50 Line coverage: 76.1% (102/134) Branch coverage: 91.4% (75/82) Total lines: 359 Tag: Kestrun/Kestrun@fcf33342333cef0516fe0d0912a86709874fd02603/27/2026 - 14:18:40 Line coverage: 71.7% (140/195) Branch coverage: 85.7% (84/98) Total lines: 497 Tag: Kestrun/Kestrun@63388ea9aed376ffbb41cd2727be2fb7646f6402

Coverage delta

Coverage delta 6 -6

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.cctor()100%11100%
get_TaskRunspacePool()100%11100%
get_Host()100%11100%
Create(...)91.67%121295.45%
SetTaskName(...)100%44100%
SetTaskDescription(...)100%44100%
Start(...)100%66100%
StartAsync()83.33%8660%
Get(...)100%22100%
GetState(...)100%22100%
GetResult(...)100%22100%
Cancel(...)70%201052.94%
ChildrenAreFinished(...)90%101085.71%
Remove(...)94.44%181894.44%
List()100%11100%
ThrowIfDisposed()100%11100%
TryGetTask(...)100%11100%
ExecuteAsync()100%9654.55%
Dispose()50%2290%
CancelTasks(...)100%2257.14%
WaitForRunnersToQuiesce(...)33.33%20626.32%
DisposeQuiescedCancellationSources(...)66.67%11650%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Tasks/KestrunTaskService.cs

#LineLine coverage
 1using System.Collections.Concurrent;
 2using Kestrun.Hosting;
 3using Kestrun.Hosting.Options;
 4using Kestrun.Scripting;
 5
 6namespace Kestrun.Tasks;
 7
 8/// <summary>
 9/// Service to run ad-hoc Kestrun tasks in PowerShell, C#, or VB.NET, with status, result, and cancellation.
 10/// </summary>
 11/// <param name="pool">PowerShell runspace pool manager.</param>
 12/// <param name="log">Logger instance.</param>
 1713public sealed class KestrunTaskService(KestrunRunspacePoolManager pool, Serilog.ILogger log) : IDisposable
 14{
 115    private static readonly TimeSpan DisposeWaitTimeout = TimeSpan.FromSeconds(5);
 1716    private readonly ConcurrentDictionary<string, KestrunTask> _tasks = new(StringComparer.OrdinalIgnoreCase);
 5217    internal KestrunRunspacePoolManager TaskRunspacePool { get; } = pool;
 1718    private readonly Serilog.ILogger _log = log;
 19    private int _disposed;
 20
 1621    private KestrunHost Host => TaskRunspacePool.Host;
 22
 23    /// <summary>
 24    /// Creates a task from a code snippet without starting it.
 25    /// </summary>
 26    /// <param name="id">Optional unique task identifier. If null or empty, a new GUID will be generated.</param>
 27    /// <param name="scriptCode">The scripting language and code configuration for this task.</param>
 28    /// <param name="autoStart">Whether to start the task automatically.</param>
 29    /// <param name="name">Optional human-friendly name of the task.</param>
 30    /// <param name="description">Optional description of the task.</param>
 31    /// <returns>The unique identifier of the created task.</returns>
 32    /// <exception cref="ArgumentNullException">Thrown if scriptCode is null.</exception>
 33    /// <exception cref="InvalidOperationException">Thrown if a task with the same id already exists.</exception>
 34    public string Create(string? id, LanguageOptions scriptCode, bool autoStart, string? name, string? description = nul
 35    {
 1836        ThrowIfDisposed();
 1837        ArgumentNullException.ThrowIfNull(scriptCode);
 38
 1739        if (string.IsNullOrWhiteSpace(id))
 40        {
 1541            id = Guid.NewGuid().ToString("n");
 42        }
 1743        if (_tasks.ContainsKey(id))
 44        {
 145            throw new InvalidOperationException($"Task id '{id}' already exists.");
 46        }
 47
 1648        var progress = new ProgressiveKestrunTaskState();
 1649        var cfg = new TaskJobFactory.TaskJobConfig(Host, id, scriptCode, TaskRunspacePool, progress);
 1650        var work = TaskJobFactory.Create(cfg);
 1651        var cts = new CancellationTokenSource();
 1652        var task = new KestrunTask(id, scriptCode, cts)
 1653        {
 1654            Work = work,
 1655            Progress = progress,
 1656            Name = string.IsNullOrWhiteSpace(name) ? ("Task " + id) : name,
 1657            Description = string.IsNullOrWhiteSpace(description) ? string.Empty : description
 1658        };
 59
 1660        if (!_tasks.TryAdd(id, task))
 61        {
 062            throw new InvalidOperationException($"Task id '{id}' already exists.");
 63        }
 1664        if (autoStart)
 65        {
 266            _ = Start(id);
 67        }
 1668        return id;
 69    }
 70
 71    /// <summary>
 72    /// Sets or updates the name of a task.
 73    /// </summary>
 74    /// <param name="id">The task identifier.</param>
 75    /// <param name="name">The new name for the task.</param>
 76    /// <returns>True if the task was found and updated; false if not found.</returns>
 77    public bool SetTaskName(string id, string name)
 78    {
 379        ThrowIfDisposed();
 380        if (string.IsNullOrWhiteSpace(name))
 81        {
 182            throw new ArgumentNullException(nameof(name));
 83        }
 84
 285        if (!_tasks.TryGetValue(id, out var task))
 86        {
 187            return false;
 88        }
 189        task.Name = name;
 190        return true;
 91    }
 92
 93    /// <summary>
 94    /// Sets or updates the description of a task.
 95    /// </summary>
 96    /// <param name="id">The task identifier.</param>
 97    /// <param name="description">The new description for the task.</param>
 98    /// <returns>True if the task was found and updated; false if not found.</returns>
 99    public bool SetTaskDescription(string id, string description)
 100    {
 3101        ThrowIfDisposed();
 3102        if (string.IsNullOrWhiteSpace(description))
 103        {
 1104            throw new ArgumentNullException(nameof(description));
 105        }
 2106        if (!_tasks.TryGetValue(id, out var task))
 107        {
 1108            return false;
 109        }
 1110        task.Description = description;
 1111        return true;
 112    }
 113
 114    /// <summary>
 115    /// Starts a previously created task by id.
 116    /// </summary>
 117    /// <param name="id">The task identifier.</param>
 118    /// <returns>True if the task was found and started; false if not found or already started.</returns>
 119    public bool Start(string id)
 120    {
 13121        ThrowIfDisposed();
 13122        if (!_tasks.TryGetValue(id, out var task))
 123        {
 1124            return false;
 125        }
 12126        if (task.State != TaskState.NotStarted || task.Runner != null)
 127        {
 1128            return false; // only start once from Created state
 129        }
 22130        task.Runner = Task.Run(async () => await ExecuteAsync(task).ConfigureAwait(false), task.Token);
 11131        return true;
 132    }
 133
 134    /// <summary>
 135    /// Starts a previously created task by id, and awaits its completion.
 136    /// </summary>
 137    /// <param name="id">The task identifier.</param>
 138    /// <returns>True if the task was found and started; false if not found or already started.</returns>
 139    public async Task<bool> StartAsync(string id)
 140    {
 2141        ThrowIfDisposed();
 2142        if (!_tasks.TryGetValue(id, out var task))
 143        {
 0144            return false;
 145        }
 146
 2147        if (task.State != TaskState.NotStarted || task.Runner != null)
 148        {
 1149            return false; // only start once from Created state
 150        }
 151
 152        // Launch the task asynchronously and store its runner
 2153        task.Runner = Task.Run(() => ExecuteAsync(task), task.Token);
 154
 155        try
 156        {
 1157            await task.Runner.ConfigureAwait(false);
 1158        }
 0159        catch (OperationCanceledException)
 160        {
 161            // Optional: handle cancellation gracefully
 0162        }
 0163        catch (Exception ex)
 164        {
 165            // Optional: handle or log errors
 0166            _log.Error(ex, "Task {Id} failed", id);
 0167        }
 168
 1169        return true;
 2170    }
 171
 172    /// <summary>
 173    /// Gets a task by id.
 174    /// </summary>
 175    /// <param name="id">The task identifier.</param>
 176    /// <returns>The task result, or null if not found.</returns>
 177    public KrTask? Get(string id)
 178    {
 8179        ThrowIfDisposed();
 8180        return _tasks.TryGetValue(id, out var t) ? t.ToKrTask() : null;
 181    }
 182
 183    /// <summary>
 184    /// Gets the current state for a task.
 185    /// </summary>
 186    /// <param name="id">The task identifier.</param>
 187    /// <returns>The task state, or null if not found.</returns>
 188    public TaskState? GetState(string id)
 189    {
 199190        ThrowIfDisposed();
 199191        return _tasks.TryGetValue(id, out var t) ? t.State : null;
 192    }
 193
 194    /// <summary>
 195    /// Gets the output object for a completed task.
 196    /// </summary>
 197    /// <param name="id">The task identifier.</param>
 198    /// <returns>The task output object, or null if not found or no output.</returns>
 199    public object? GetResult(string id)
 200    {
 6201        ThrowIfDisposed();
 6202        return _tasks.TryGetValue(id, out var t) ? t.Output : null;
 203    }
 204
 205    /// <summary>
 206    /// Attempts to cancel a task.
 207    /// </summary>
 208    /// <remarks>
 209    /// If the task has not been started (NotStarted) it is transitioned directly to the terminal
 210    /// Stopped state so it can be removed later and does not remain orphaned.
 211    /// </remarks>
 212    public bool Cancel(string id)
 213    {
 5214        ThrowIfDisposed();
 5215        if (!_tasks.TryGetValue(id, out var t))
 216        {
 1217            return false;
 218        }
 4219        if (t.State is TaskState.Completed or TaskState.Failed or TaskState.Stopped)
 220        {
 1221            return false;
 222        }
 223
 3224        if (t.State == TaskState.NotStarted)
 225        {
 0226            _log.Information("Cancelling task {Id} before start", id);
 227            // Transition to a terminal state so Remove() can succeed
 0228            t.State = TaskState.Stopped;
 0229            t.Progress.Cancel("Cancelled before start");
 0230            var now = DateTimeOffset.UtcNow;
 0231            t.StartedAtUtc ??= now;
 0232            t.CompletedAtUtc = now;
 0233            t.TokenSource.Cancel(); // ensure any future Start() attempt observes cancellation
 0234            return true;
 235        }
 236
 3237        _log.Information("Cancelling running task {Id}", id);
 3238        t.TokenSource.Cancel();
 3239        return true;
 240    }
 241
 242    /// <summary>
 243    /// Checks recursively if all children of a task are finished.
 244    /// </summary>
 245    /// <param name="task">The parent task to check.</param>
 246    /// <returns>True if all children are finished; false otherwise.</returns>
 247    private static bool ChildrenAreFinished(KestrunTask task)
 248    {
 17249        foreach (var child in task.Children)
 250        {
 2251            if (!ChildrenAreFinished(child))
 252            {
 0253                return false;
 254            }
 2255            if (child.State is not TaskState.Completed and not TaskState.Failed and not TaskState.Stopped)
 256            {
 1257                return false;
 258            }
 259        }
 6260        return true;
 1261    }
 262    /// <summary>
 263    /// Removes a finished task from the registry.
 264    /// </summary>
 265    /// <param name="id">The task identifier.</param>
 266    /// <returns>True if the task was found and removed; false if not found or not finished.</returns>
 267    /// <remarks>
 268    /// A task can only be removed if it is in a terminal state (Completed, Failed, Stopped)
 269    /// and all its child tasks are also in terminal states.
 270    /// </remarks>
 271    public bool Remove(string id)
 272    {
 7273        ThrowIfDisposed();
 7274        if (_tasks.TryGetValue(id, out var t))
 275        {
 6276            if (t.State is TaskState.Completed or TaskState.Failed or TaskState.Stopped)
 277            {
 5278                if (!ChildrenAreFinished(t))
 279                {
 1280                    _log.Warning("Cannot remove task {Id} because it has running child tasks", id);
 1281                    return false;
 282                }
 283
 4284                _log.Information("Removing task {Id}", id);
 4285                if (_tasks.TryRemove(id, out _))
 286                {
 287                    // Detach from parent first so recursive child removals can't mutate the list we iterate
 4288                    if (t.Parent is not null)
 289                    {
 1290                        _ = t.Parent.Children.Remove(t);
 291                    }
 292
 293                    // Take a point-in-time snapshot because recursive Remove(child.Id) will
 294                    // mutate the parent's Children collection (each child removes itself).
 4295                    if (t.Children.Count > 0)
 296                    {
 1297                        var snapshot = t.Children.ToArray();
 4298                        foreach (var child in snapshot)
 299                        {
 1300                            if (!Remove(child.Id))
 301                            {
 0302                                _log.Warning("Failed to remove child task {ChildId} of parent task {ParentId}", child.Id
 303                            }
 304                        }
 305                    }
 4306                    return true;
 307                }
 308            }
 1309            return false;
 310        }
 1311        return false;
 312    }
 313
 314    /// <summary>
 315    /// Lists all tasks with basic info.
 316    /// Does not include output or error details.
 317    /// </summary>
 318    public IReadOnlyCollection<KrTask> List()
 319    {
 2320        ThrowIfDisposed();
 5321        return [.. _tasks.Values.Select(v => v.ToKrTask())];
 322    }
 323
 324    /// <summary>
 325    /// Throws when the service has already been disposed.
 326    /// </summary>
 327    private void ThrowIfDisposed()
 266328        => ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, nameof(KestrunTaskService));
 329
 330    /// <summary>
 331    /// Tries to get the live task model for internal consumers such as tests.
 332    /// </summary>
 333    /// <param name="id">The task identifier.</param>
 334    /// <param name="task">The matching task when found; otherwise null.</param>
 335    /// <returns>True when the task exists; otherwise false.</returns>
 336    internal bool TryGetTask(string id, out KestrunTask? task)
 1337        => _tasks.TryGetValue(id, out task);
 338
 339    /// <summary>
 340    /// Executes the task's work function and updates its state accordingly.
 341    /// </summary>
 342    /// <param name="task">The task to execute.</param>
 343    /// <returns>A task representing the asynchronous operation.</returns>
 344    private async Task ExecuteAsync(KestrunTask task)
 345    {
 12346        var cancellationToken = task.Token;
 12347        task.State = TaskState.Running;
 12348        task.Progress.StatusMessage = "Running";
 12349        task.StartedAtUtc = DateTimeOffset.UtcNow;
 350        try
 351        {
 12352            var result = await task.Work(cancellationToken).ConfigureAwait(false);
 10353            task.Output = result;
 10354            task.State = cancellationToken.IsCancellationRequested ? TaskState.Stopped : TaskState.Completed;
 10355            if (task.State == TaskState.Completed)
 356            {
 9357                task.Progress.Complete("Completed");
 358            }
 1359            else if (task.State == TaskState.Stopped)
 360            {
 361                // If cancellation was requested but no exception was thrown (graceful exit), normalize progress
 1362                task.Progress.Cancel("Cancelled");
 363            }
 10364        }
 2365        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 366        {
 2367            task.State = TaskState.Stopped;
 2368            task.Progress.Cancel("Cancelled");
 2369        }
 0370        catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
 371        {
 372            // Some libraries throw TaskCanceledException instead of OperationCanceledException on cancellation
 0373            task.State = TaskState.Stopped;
 0374            task.Progress.Cancel("Cancelled");
 0375        }
 0376        catch (Exception ex) when (cancellationToken.IsCancellationRequested)
 377        {
 378            // During cancellation, certain engines (e.g., PowerShell) may surface non-cancellation exceptions
 379            // such as PipelineStoppedException. If cancellation was requested, normalize to Stopped.
 0380            task.State = TaskState.Stopped;
 0381            task.Progress.Cancel("Cancelled");
 0382            _log.Information(ex, "Task {Id} cancelled with exception after cancellation was requested", task.Id);
 0383        }
 0384        catch (Exception ex)
 385        {
 0386            task.Fault = ex;
 0387            task.State = TaskState.Failed;
 0388            _log.Error(ex, "Task {Id} failed", task.Id);
 0389            task.Progress.Fail("Failed");
 0390        }
 391        finally
 392        {
 12393            task.CompletedAtUtc = DateTimeOffset.UtcNow;
 394        }
 12395    }
 396
 397    /// <summary>
 398    /// Cancels active tasks, waits briefly for runners to quiesce, disposes quiesced cancellation sources,
 399    /// clears the task registry, and releases the task runspace pool.
 400    /// </summary>
 401    public void Dispose()
 402    {
 2403        if (Interlocked.Exchange(ref _disposed, 1) != 0)
 404        {
 0405            return;
 406        }
 407
 2408        var tasks = _tasks.Values.ToArray();
 2409        CancelTasks(tasks);
 2410        WaitForRunnersToQuiesce(tasks);
 2411        DisposeQuiescedCancellationSources(tasks);
 412
 2413        _tasks.Clear();
 2414        TaskRunspacePool.Dispose();
 2415        _log.Information("KestrunTaskService disposed");
 2416    }
 417
 418    /// <summary>
 419    /// Requests cancellation for each tracked task during service shutdown.
 420    /// </summary>
 421    /// <param name="tasks">The tasks being shut down.</param>
 422    private void CancelTasks(IReadOnlyCollection<KestrunTask> tasks)
 423    {
 6424        foreach (var task in tasks)
 425        {
 426            try
 427            {
 1428                task.TokenSource.Cancel();
 1429            }
 0430            catch (Exception ex)
 431            {
 0432                _log.Debug(ex, "Failed to cancel task {Id} during KestrunTaskService disposal", task.Id);
 0433            }
 434        }
 2435    }
 436
 437    /// <summary>
 438    /// Waits for active task runners to reach a terminal state within the shutdown timeout.
 439    /// </summary>
 440    /// <param name="tasks">The tasks being shut down.</param>
 441    private void WaitForRunnersToQuiesce(IReadOnlyCollection<KestrunTask> tasks)
 442    {
 2443        var activeRunners = tasks
 1444            .Where(task => task.Runner is { IsCompleted: false })
 0445            .Select(task => task.Runner!)
 2446            .ToArray();
 447
 2448        if (activeRunners.Length == 0)
 449        {
 2450            return;
 451        }
 452
 0453        var allRunnersCompleted = false;
 454        try
 455        {
 0456            allRunnersCompleted = Task.WhenAll(activeRunners).Wait(DisposeWaitTimeout);
 0457        }
 0458        catch (AggregateException ex)
 459        {
 0460            _log.Debug(ex, "One or more task runners faulted during KestrunTaskService disposal");
 0461            allRunnersCompleted = true;
 0462        }
 463
 0464        if (!allRunnersCompleted)
 465        {
 0466            _log.Debug(
 0467                "Timed out waiting for {ActiveCount} task runner(s) to stop during KestrunTaskService disposal after {Ti
 0468                activeRunners.Length,
 0469                DisposeWaitTimeout.TotalMilliseconds);
 470        }
 0471    }
 472
 473    /// <summary>
 474    /// Disposes cancellation token sources for tasks that are no longer executing.
 475    /// </summary>
 476    /// <param name="tasks">The tasks being shut down.</param>
 477    private void DisposeQuiescedCancellationSources(IReadOnlyCollection<KestrunTask> tasks)
 478    {
 6479        foreach (var task in tasks)
 480        {
 1481            if (task.Runner is { IsCompleted: false })
 482            {
 0483                _log.Debug("Skipping CancellationTokenSource disposal for still-running task {Id}", task.Id);
 0484                continue;
 485            }
 486
 487            try
 488            {
 1489                task.TokenSource.Dispose();
 1490            }
 0491            catch (Exception ex)
 492            {
 0493                _log.Debug(ex, "Failed to dispose CancellationTokenSource for task {Id} during KestrunTaskService dispos
 0494            }
 495        }
 2496    }
 497}