| 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(); } }