File: Commands\Test\MTP\CtrlCCancellationManager.cs
Web Access
Project: src\sdk\src\Cli\dotnet\dotnet.csproj (dotnet)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Concurrent;
using System.Diagnostics;
using Microsoft.DotNet.Cli.Utils;

namespace Microsoft.DotNet.Cli.Commands.Test;

/// <summary>
/// Mirrors the two-stage Ctrl+C cancellation UX implemented in Microsoft.Testing.Platform
/// (testfx PR #8581 / SDK issue https://github.com/dotnet/sdk/issues/50732) at the
/// <c>dotnet test</c> orchestrator level:
/// <list type="bullet">
///   <item>First Ctrl+C: cooperative cancellation. The <see cref="Token"/> is signaled so the
///     queue stops scheduling new test apps; running test apps are left alone so they can
///     gracefully report their final state via IPC (their own testfx-side Ctrl+C handler will
///     cancel the session inside the child process).</item>
///   <item>Second Ctrl+C: force exit. Every registered child process is killed
///     (<c>entireProcessTree: true</c>) and the host process exits with
///     <see cref="ExitCode.TestSessionAborted"/> (=3).</item>
///   <item>Any further presses are no-ops (the state machine is idempotent).</item>
/// </list>
/// </summary>
internal sealed class CtrlCCancellationManager : IDisposable
{
    private const int StateIdle = 0;
    private const int StateCancelling = 1;
    private const int StateForcing = 2;

    private readonly CancellationTokenSource _cancellationTokenSource = new();
    private readonly Action _onFirstCtrlC;
    private readonly Action<int> _exitAction;
    private readonly ConcurrentDictionary<Process, byte> _processes = new();
    private readonly bool _subscribedToConsole;
    private int _state = StateIdle;
    private int _disposed;

    public CtrlCCancellationManager(Action onFirstCtrlC, Action<int>? exitAction = null, bool subscribeToConsole = true)
    {
        _onFirstCtrlC = onFirstCtrlC ?? throw new ArgumentNullException(nameof(onFirstCtrlC));
        _exitAction = exitAction ?? Environment.Exit;

        if (subscribeToConsole)
        {
            Console.CancelKeyPress += OnConsoleCancelKeyPress;
            _subscribedToConsole = true;
        }
    }

    /// <summary>
    /// A token that is canceled when the user first presses Ctrl+C. Consumers should use it to
    /// stop scheduling new work but should not use it to tear down in-flight IPC for already
    /// running test apps (their cooperative cancellation is driven by their own Ctrl+C handler).
    /// </summary>
    public CancellationToken Token => _cancellationTokenSource.Token;

    /// <summary>
    /// Registers a child test-app process so it will be killed if the user requests a force exit
    /// (second Ctrl+C). If the manager is already in the forcing state when this is called, the
    /// process is killed immediately to avoid orphaning a child that started during the force
    /// exit race window.
    /// </summary>
    public void Register(Process process)
    {
        ArgumentNullException.ThrowIfNull(process);

        _processes.TryAdd(process, 0);

        if (Volatile.Read(ref _state) == StateForcing)
        {
            TryKill(process);
        }
    }

    public void Unregister(Process process)
    {
        ArgumentNullException.ThrowIfNull(process);

        _processes.TryRemove(process, out _);
    }

    /// <summary>
    /// Test-only hook to drive the state machine without depending on a real Console signal.
    /// </summary>
    internal void SimulateCtrlC() => HandleCtrlC();

    private void OnConsoleCancelKeyPress(object? sender, ConsoleCancelEventArgs e)
    {
        // Suppress the runtime's default Ctrl+C handling on every press, including the second one,
        // so that we (not the runtime) get to decide the exit code and ordering. This must happen
        // before any other work in this handler.
        e.Cancel = true;

        HandleCtrlC();
    }

    private void HandleCtrlC()
    {
        // First press: Idle -> Cancelling. Signal the token, invoke the UI callback.
        if (Interlocked.CompareExchange(ref _state, StateCancelling, StateIdle) == StateIdle)
        {
            // CancellationTokenSource.Cancel() can throw an AggregateException if any registered
            // callback throws, or an ObjectDisposedException if Dispose races with a Ctrl+C press
            // (e.g. user presses Ctrl+C while we're tearing down). Swallow both — we still want the
            // state machine to advance and the UI callback to run.
            try
            {
                _cancellationTokenSource.Cancel();
            }
            catch (Exception ex) when (ex is AggregateException or ObjectDisposedException)
            {
                Logger.LogTrace($"Exception during CtrlCCancellationManager cancel:\n{ex}");
            }

            // The UI callback (typically TerminalTestReporter.StartCancelling) must not be able to
            // affect cancellation state — wrap it best-effort.
            try
            {
                _onFirstCtrlC();
            }
            catch (Exception ex)
            {
                Logger.LogTrace($"Exception during CtrlCCancellationManager first-press UI callback:\n{ex}");
            }

            return;
        }

        // Second press: Cancelling -> Forcing. Kill every registered child and exit.
        // We intentionally do not print any additional message here because the user already saw
        // the "Press Ctrl+C again to force exit." hint and the exit itself is the confirmation.
        if (Interlocked.CompareExchange(ref _state, StateForcing, StateCancelling) == StateCancelling)
        {
            foreach (var process in _processes.Keys)
            {
                TryKill(process);
            }

            _exitAction(ExitCode.TestSessionAborted);
        }
    }

    private static void TryKill(Process process)
    {
        try
        {
            if (!process.HasExited)
            {
                process.Kill(entireProcessTree: true);
            }
        }
        catch (Exception ex)
        {
            // The process may have exited between the HasExited check and Kill, or we may lack
            // permissions to read its state. Either way, nothing useful we can do from here.
            Logger.LogTrace($"Exception killing child process during force exit:\n{ex}");
        }
    }

    public void Dispose()
    {
        if (Interlocked.Exchange(ref _disposed, 1) != 0)
        {
            return;
        }

        if (_subscribedToConsole)
        {
            Console.CancelKeyPress -= OnConsoleCancelKeyPress;
        }

        _cancellationTokenSource.Dispose();
    }
}