diff --git a/src/Platform/Microsoft.Testing.Platform/Services/CTRLPlusCCancellationTokenSource.cs b/src/Platform/Microsoft.Testing.Platform/Services/CTRLPlusCCancellationTokenSource.cs
index a2c2844895..b60ba66d43 100644
--- a/src/Platform/Microsoft.Testing.Platform/Services/CTRLPlusCCancellationTokenSource.cs
+++ b/src/Platform/Microsoft.Testing.Platform/Services/CTRLPlusCCancellationTokenSource.cs
@@ -6,29 +6,73 @@
namespace Microsoft.Testing.Platform.Services;
+///
+/// Two-phase, Ctrl+C-aware .
+///
+///
+/// Phase machine (see RFC "Phased graceful shutdown for MTP", issue #5345):
+///
+/// RUNNING ──Ctrl+C / Cancel()──▶ DRAINING ──grace elapsed / 2nd Ctrl+C / Abort()──▶ ABORTING
+/// ──3rd Ctrl+C──▶ (process terminated by runtime)
+///
+///
+/// Transitions are idempotent and one-way. Existing consumers reading
+/// automatically observe Draining (back-compat).
+///
+///
internal sealed class CTRLPlusCCancellationTokenSource : ITestApplicationCancellationTokenSource, IDisposable
{
- private const int StateIdle = 0;
- private const int StateCancelling = 1;
- private const int StateForcing = 2;
+ // Conservative defaults inspired by .NET HostOptions.ShutdownTimeout (30s) and
+ // Vitest's teardownTimeout (10s). Will become CLI options in a follow-up
+ // (--shutdown-grace-period, --shutdown-abort-timeout).
+ // TODO(#5345): wire to PlatformCommandLineProvider.
+ internal static readonly TimeSpan DefaultGracePeriod = TimeSpan.FromSeconds(30);
+ internal static readonly TimeSpan DefaultAbortTimeout = TimeSpan.FromSeconds(10);
- private readonly CancellationTokenSource _cancellationTokenSource = new();
+ private const int PhaseRunning = 0;
+ private const int PhaseDraining = 1;
+ private const int PhaseAborting = 2;
+
+ private readonly CancellationTokenSource _drainingCts = new();
+ private readonly CancellationTokenSource _abortingCts = new();
+ private readonly TimeSpan _gracePeriod;
+ private readonly TimeSpan _abortTimeout;
private readonly IEnvironment _environment;
private readonly ILogger? _logger;
- private readonly IConsole? _subscribedConsole;
- private int _state = StateIdle;
+ private readonly IConsole? _console;
+ private readonly object _escalationLock = new();
+
+ // Fire-and-forget escalation timers, retained so we can dispose them in
+ // Dispose() and prevent callbacks from firing after the host has shut down.
+ private List? _escalations;
+
+ private int _phase = PhaseRunning;
+ private int _ctrlCCount;
private int _disposed;
- public CTRLPlusCCancellationTokenSource(IConsole? console = null, ILogger? logger = null, IEnvironment? environment = null)
+ public CTRLPlusCCancellationTokenSource(IConsole? console = null, ILogger? logger = null)
+ : this(console, logger, DefaultGracePeriod, DefaultAbortTimeout, environment: null)
+ {
+ }
+
+ // Test-friendly overload so we can exercise the phase machine without waiting 30s.
+ internal CTRLPlusCCancellationTokenSource(
+ IConsole? console,
+ ILogger? logger,
+ TimeSpan gracePeriod,
+ TimeSpan abortTimeout,
+ IEnvironment? environment = null)
{
+ _gracePeriod = gracePeriod;
+ _abortTimeout = abortTimeout;
+ _environment = environment ?? new SystemEnvironment();
+ _logger = logger;
+
if (console is not null && !IsCancelKeyPressNotSupported())
{
+ _console = console;
console.CancelKeyPress += OnConsoleCancelKeyPressed;
- _subscribedConsole = console;
}
-
- _environment = environment ?? new SystemEnvironment();
- _logger = logger;
}
[SupportedOSPlatformGuard("android")]
@@ -43,68 +87,182 @@ private static bool IsCancelKeyPressNotSupported()
OperatingSystem.IsWasi() ||
OperatingSystem.IsBrowser();
- public void CancelAfter(TimeSpan timeout) => _cancellationTokenSource.CancelAfter(timeout);
+ ///
+ public CancellationToken CancellationToken => _drainingCts.Token;
- public CancellationToken CancellationToken
- => _cancellationTokenSource.Token;
+ ///
+ public CancellationToken DrainingToken => _drainingCts.Token;
- private void OnConsoleCancelKeyPressed(object? sender, ConsoleCancelEventArgs e)
+ ///
+ public CancellationToken AbortingToken => _abortingCts.Token;
+
+ internal int CurrentPhase => Volatile.Read(ref _phase);
+
+ public void CancelAfter(TimeSpan timeout) => _drainingCts.CancelAfter(timeout);
+
+ ///
+ public void Cancel() => EnterDraining();
+
+ ///
+ public void Abort()
{
- // Suppress the runtime's default Ctrl+C handling so we control the exit code on
- // both the first (cooperative) and the second (force-exit) press.
- e.Cancel = true;
-
- // The state machine counts user Ctrl+C presses *independently* of external cancellation
- // sources (timeout, max-failed-tests, explicit Cancel()). This honors the contract
- // advertised next to the "Cancelling..." message ("Press Ctrl+C again to force exit.")
- // regardless of who initiated the cancellation: the user must always press Ctrl+C twice
- // to force-exit.
- if (Interlocked.CompareExchange(ref _state, StateCancelling, StateIdle) == StateIdle)
- {
- // First user Ctrl+C: cooperative cancellation. If the token was already cancelled
- // by an external source this is effectively a no-op, but we still transitioned the
- // state so the next press goes to force-exit.
- try
- {
- _cancellationTokenSource.Cancel();
- }
- catch (AggregateException ex)
+ EnterDraining();
+ EnterAborting();
+ }
+
+ public void Dispose()
+ {
+ if (Interlocked.Exchange(ref _disposed, 1) != 0)
+ {
+ return;
+ }
+
+ // Detach from Console.CancelKeyPress so we don't keep this instance alive
+ // through the static event and don't invoke the handler after disposal.
+ if (_console is not null && !IsCancelKeyPressNotSupported())
+ {
+ _console.CancelKeyPress -= OnConsoleCancelKeyPressed;
+ }
+
+ List? escalations;
+ lock (_escalationLock)
+ {
+ escalations = _escalations;
+ _escalations = null;
+ }
+
+ if (escalations is not null)
+ {
+ foreach (CancellationTokenSource escalation in escalations)
{
- _logger?.LogWarning($"Exception during CTRLPlusCCancellationTokenSource cancel:\n{ex}");
+ escalation.Dispose();
}
+ }
+
+ _drainingCts.Dispose();
+ _abortingCts.Dispose();
+ }
+ private void OnConsoleCancelKeyPressed(object? sender, ConsoleCancelEventArgs e)
+ {
+ // Guard against handlers that may race with Dispose (the unsubscribe in
+ // Dispose() is best-effort: the event invocation list could already have
+ // captured this delegate before we removed it).
+ if (Volatile.Read(ref _disposed) != 0)
+ {
return;
}
- // Second user Ctrl+C: force-exit. We intentionally do not print an extra
- // "Forcing exit..." message here because the IConsole abstraction has no stderr channel
- // and writing to stdout would corrupt the JSON document produced by --list-tests json.
- // The user already saw the "Press Ctrl+C again to force exit." hint, so the exit itself
- // is the confirmation. Any subsequent presses are suppressed by the StateForcing guard.
- if (Interlocked.CompareExchange(ref _state, StateForcing, StateCancelling) == StateCancelling)
+ int count = Interlocked.Increment(ref _ctrlCCount);
+
+ switch (count)
{
- _environment.Exit((int)ExitCode.TestSessionAborted);
+ case 1:
+ // 1st Ctrl+C: cooperative cancel.
+ e.Cancel = true;
+ EnterDraining();
+ break;
+ case 2:
+ // 2nd Ctrl+C: escalate to abort. Go through Abort() (which calls
+ // EnterDraining() then EnterAborting()) so the
+ // Running → Draining → Aborting invariant holds even if a future
+ // refactor lets a 2nd Ctrl+C arrive before the 1st has been
+ // processed (or before EnterDraining was reached at all).
+ e.Cancel = true;
+ Abort();
+ break;
+ default:
+ // 3rd+ Ctrl+C: stop intercepting and let the runtime terminate
+ // the process. This matches docker compose / kubectl / npm UX:
+ // the user has explicitly asked us to die.
+ e.Cancel = false;
+ break;
}
}
- public void Dispose()
+ private void EnterDraining()
{
- if (Interlocked.Exchange(ref _disposed, 1) != 0)
+ if (Interlocked.CompareExchange(ref _phase, PhaseDraining, PhaseRunning) != PhaseRunning)
+ {
+ return;
+ }
+
+ try
+ {
+ _drainingCts.Cancel();
+ }
+ catch (AggregateException ex)
+ {
+ _logger?.LogWarning($"Exception during shutdown (Draining):\n{ex}");
+ }
+
+ // Auto-escalate to Aborting after the grace period.
+ if (_gracePeriod > TimeSpan.Zero && _gracePeriod != Timeout.InfiniteTimeSpan)
+ {
+ ScheduleEscalation(_gracePeriod, EnterAborting);
+ }
+ else if (_gracePeriod == TimeSpan.Zero)
+ {
+ EnterAborting();
+ }
+ }
+
+ private void EnterAborting()
+ {
+ // Aborting implies Draining: ensure the legacy CancellationToken /
+ // DrainingToken are always signaled when Aborting is signaled, even if
+ // EnterAborting is invoked from a Running state (e.g., via Abort() or a
+ // future direct trigger). EnterDraining is idempotent.
+ EnterDraining();
+
+ if (Interlocked.CompareExchange(ref _phase, PhaseAborting, PhaseDraining) != PhaseDraining)
{
return;
}
- // We stored the console reference only when subscription was actually performed
- // (i.e. when CancelKeyPress is supported on this platform), so we can safely call -=
- // on the same supported-platform paths.
- if (_subscribedConsole is not null && !IsCancelKeyPressNotSupported())
+ try
+ {
+ _abortingCts.Cancel();
+ }
+ catch (AggregateException ex)
{
- _subscribedConsole.CancelKeyPress -= OnConsoleCancelKeyPressed;
+ _logger?.LogWarning($"Exception during shutdown (Aborting):\n{ex}");
}
- _cancellationTokenSource.Dispose();
+ // After abort timeout, if the host is still alive, hard-terminate.
+ // FailFast is intentional: at this point we asked twice and waited; any
+ // remaining work has had its chance. This is the safety net that breaks
+ // hangs in non-cooperative frameworks (issue #5345).
+ if (_abortTimeout > TimeSpan.Zero && _abortTimeout != Timeout.InfiniteTimeSpan)
+ {
+ ScheduleEscalation(_abortTimeout, ForceTerminate);
+ }
}
- public void Cancel()
- => _cancellationTokenSource.Cancel();
+ private void ScheduleEscalation(TimeSpan delay, Action action)
+ {
+ var timerCts = new CancellationTokenSource(delay);
+ timerCts.Token.Register(action);
+
+ // Retain the CTS so Dispose() can cancel pending escalations and
+ // release the underlying timer. Without this, the timer would run to
+ // completion and could invoke the callback after the source is disposed.
+ lock (_escalationLock)
+ {
+ if (Volatile.Read(ref _disposed) != 0)
+ {
+ timerCts.Dispose();
+ return;
+ }
+
+ (_escalations ??= []).Add(timerCts);
+ }
+ }
+
+ private void ForceTerminate()
+ {
+ _logger?.LogWarning(
+ $"Shutdown grace exhausted ({_gracePeriod} + {_abortTimeout}); terminating host.");
+ _environment.FailFast("Test platform shutdown grace period exhausted.");
+ }
}
diff --git a/src/Platform/Microsoft.Testing.Platform/Services/ITestApplicationCancellationTokenSource.cs b/src/Platform/Microsoft.Testing.Platform/Services/ITestApplicationCancellationTokenSource.cs
index 4d194e02f6..c5a18c0dcc 100644
--- a/src/Platform/Microsoft.Testing.Platform/Services/ITestApplicationCancellationTokenSource.cs
+++ b/src/Platform/Microsoft.Testing.Platform/Services/ITestApplicationCancellationTokenSource.cs
@@ -3,9 +3,46 @@
namespace Microsoft.Testing.Platform.Services;
+///
+/// Source of the platform's lifetime cancellation tokens. Exposes a two-phase
+/// shutdown model (see Issue #5345 / RFC "Phased graceful shutdown for MTP"):
+///
+/// - — set when the platform
+/// enters the Draining phase (Ctrl+C, programmatic ,
+/// test session abort). Consumers should stop dispatching new work and
+/// flush in-flight state.
+/// - — set when the platform
+/// enters the Aborting phase (2nd Ctrl+C, grace period elapsed,
+/// programmatic ). Consumers should bail out of
+/// long-running work as fast as possible.
+///
+/// is kept as the back-compat alias for
+/// ; existing consumers do not need to change.
+///
internal interface ITestApplicationCancellationTokenSource
{
+ ///
+ /// Gets the back-compat alias for .
+ ///
CancellationToken CancellationToken { get; }
+ ///
+ /// Gets the token that is signalled when the platform enters the Draining phase.
+ ///
+ CancellationToken DrainingToken { get; }
+
+ ///
+ /// Gets the token that is signalled when the platform enters the Aborting phase.
+ ///
+ CancellationToken AbortingToken { get; }
+
+ ///
+ /// Request the Draining phase. Idempotent.
+ ///
void Cancel();
+
+ ///
+ /// Request the Aborting phase. Idempotent. Equivalent to a second Ctrl+C.
+ ///
+ void Abort();
}
diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Hosts/CommonHostTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Hosts/CommonHostTests.cs
index b8d5f76a62..9a1b1aa72f 100644
--- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Hosts/CommonHostTests.cs
+++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Hosts/CommonHostTests.cs
@@ -112,9 +112,17 @@ private sealed class TestApplicationCancellationTokenSource : ITestApplicationCa
{
public CancellationToken CancellationToken => CancellationToken.None;
+ public CancellationToken DrainingToken => CancellationToken.None;
+
+ public CancellationToken AbortingToken => CancellationToken.None;
+
public void Cancel()
{
}
+
+ public void Abort()
+ {
+ }
}
private sealed class AsyncCleanableTestHostApplicationLifetime : ITestHostApplicationLifetime, IAsyncCleanableExtension
diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/CTRLPlusCCancellationTokenSourceTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/CTRLPlusCCancellationTokenSourceTests.cs
index 3eb9b00b4c..d8d7eced5e 100644
--- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/CTRLPlusCCancellationTokenSourceTests.cs
+++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/CTRLPlusCCancellationTokenSourceTests.cs
@@ -1,8 +1,6 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
-using Microsoft.Testing.Platform.Helpers;
-using Microsoft.Testing.Platform.Logging;
using Microsoft.Testing.Platform.Services;
namespace Microsoft.Testing.Platform.UnitTests;
@@ -11,282 +9,107 @@ namespace Microsoft.Testing.Platform.UnitTests;
public sealed class CTRLPlusCCancellationTokenSourceTests
{
[TestMethod]
- public void FirstCtrlC_CancelsToken_AndDoesNotExitProcess()
+ public void Initial_State_NeitherTokenIsCancelled()
{
- var console = new CancelableConsole();
- var environment = new RecordingEnvironment();
- using var source = new CTRLPlusCCancellationTokenSource(console, logger: null, environment);
-
- console.FireCancelKeyPress();
-
- Assert.IsTrue(source.CancellationToken.IsCancellationRequested);
- Assert.IsNull(environment.ExitCode, "Environment.Exit must not be called on the first Ctrl+C press.");
+ using var source = new CTRLPlusCCancellationTokenSource(
+ console: null,
+ logger: null,
+ gracePeriod: Timeout.InfiniteTimeSpan,
+ abortTimeout: Timeout.InfiniteTimeSpan);
+
+ Assert.IsFalse(source.CancellationToken.IsCancellationRequested);
+ Assert.IsFalse(source.DrainingToken.IsCancellationRequested);
+ Assert.IsFalse(source.AbortingToken.IsCancellationRequested);
}
[TestMethod]
- public void SecondCtrlC_TriggersForceExit_WithTestSessionAbortedExitCode()
+ public void Cancel_OnlySignalsDrainingToken()
{
- var console = new CancelableConsole();
- var environment = new RecordingEnvironment();
- using var source = new CTRLPlusCCancellationTokenSource(console, logger: null, environment);
-
- console.FireCancelKeyPress();
- console.FireCancelKeyPress();
-
- Assert.IsTrue(source.CancellationToken.IsCancellationRequested);
- Assert.AreEqual((int)ExitCode.TestSessionAborted, environment.ExitCode);
- }
+ using var source = new CTRLPlusCCancellationTokenSource(
+ console: null,
+ logger: null,
+ gracePeriod: Timeout.InfiniteTimeSpan,
+ abortTimeout: Timeout.InfiniteTimeSpan);
- [TestMethod]
- public void CtrlC_AfterExternalCancel_DoesNotForceExit_ButSecondCtrlCDoes()
- {
- var console = new CancelableConsole();
- var environment = new RecordingEnvironment();
- using var source = new CTRLPlusCCancellationTokenSource(console, logger: null, environment);
-
- // Simulate cancellation from another source (timeout, max-failed-tests, etc.).
source.Cancel();
- // The user has not yet seen the "Press Ctrl+C again to force exit." hint, so the first
- // user Ctrl+C must not force-exit — it should be treated as the first cooperative press.
- console.FireCancelKeyPress();
- Assert.IsNull(environment.ExitCode, "First user Ctrl+C must not force-exit even when cancellation was already requested externally.");
-
- // The second user Ctrl+C should then force-exit.
- console.FireCancelKeyPress();
- Assert.AreEqual((int)ExitCode.TestSessionAborted, environment.ExitCode);
- }
-
- [TestMethod]
- public void RepeatedCtrlC_AfterForceExit_DoesNotCallExitAgain()
- {
- var console = new CancelableConsole();
- var environment = new RecordingEnvironment();
- using var source = new CTRLPlusCCancellationTokenSource(console, logger: null, environment);
-
- console.FireCancelKeyPress();
- console.FireCancelKeyPress();
- console.FireCancelKeyPress();
- console.FireCancelKeyPress();
-
- Assert.AreEqual(1, environment.ExitCallCount);
- Assert.AreEqual((int)ExitCode.TestSessionAborted, environment.ExitCode);
- }
-
- [TestMethod]
- public void FirstCtrlC_WhenCancelCallbackThrows_LogsWarningAndSuppressesException()
- {
- var console = new CancelableConsole();
- var environment = new RecordingEnvironment();
- var logger = new RecordingLogger();
- using var source = new CTRLPlusCCancellationTokenSource(console, logger, environment);
-
- // Registering a throwing callback on the token causes CancellationTokenSource.Cancel()
- // to throw AggregateException, exercising the catch/log path in OnConsoleCancelKeyPressed.
- source.CancellationToken.Register(() => throw new InvalidOperationException("boom"));
-
- console.FireCancelKeyPress();
-
- Assert.IsTrue(source.CancellationToken.IsCancellationRequested);
- Assert.IsNull(environment.ExitCode, "First Ctrl+C must not force-exit even when the cancel callback throws.");
- Assert.AreEqual(1, logger.WarningCount, "The AggregateException must be logged as a warning.");
- Assert.IsNotNull(logger.LastWarning);
- Assert.Contains("CTRLPlusCCancellationTokenSource cancel", logger.LastWarning!);
+ Assert.IsTrue(source.DrainingToken.IsCancellationRequested);
+ Assert.IsTrue(source.CancellationToken.IsCancellationRequested, "Legacy alias must follow DrainingToken.");
+ Assert.IsFalse(source.AbortingToken.IsCancellationRequested);
}
[TestMethod]
- public void ConcurrentFirstCtrlC_OnlyTransitionsStateOnce_AndAllPressesCombinedNeverExitMoreThanOnce()
+ public void Abort_SignalsBothTokens()
{
- var console = new CancelableConsole();
- var environment = new RecordingEnvironment();
- using var source = new CTRLPlusCCancellationTokenSource(console, logger: null, environment);
+ using var source = new CTRLPlusCCancellationTokenSource(
+ console: null,
+ logger: null,
+ gracePeriod: Timeout.InfiniteTimeSpan,
+ abortTimeout: Timeout.InfiniteTimeSpan);
- // Registering a callback on the token lets us count how many times the underlying
- // CancellationTokenSource.Cancel() was actually invoked. The state machine guarantees
- // only the first thread that wins the StateIdle -> StateCancelling transition calls
- // Cancel(), so the callback must fire exactly once even under heavy contention.
- int cancelCallbackInvocations = 0;
- source.CancellationToken.Register(() => Interlocked.Increment(ref cancelCallbackInvocations));
+ source.Abort();
- Parallel.For(0, 16, _ => console.FireCancelKeyPress());
-
- Assert.IsTrue(source.CancellationToken.IsCancellationRequested);
- Assert.AreEqual(1, cancelCallbackInvocations, "Only the thread that wins the state transition must call Cancel().");
- Assert.AreEqual(1, environment.ExitCallCount, "Across many concurrent presses Exit() must be called at most once.");
+ Assert.IsTrue(source.DrainingToken.IsCancellationRequested);
+ Assert.IsTrue(source.AbortingToken.IsCancellationRequested);
}
[TestMethod]
- public void Constructor_WithNullConsole_DoesNotCrash_AndCancelStillWorks()
+ public void Cancel_IsIdempotent()
{
- var environment = new RecordingEnvironment();
- using var source = new CTRLPlusCCancellationTokenSource(console: null, logger: null, environment);
+ using var source = new CTRLPlusCCancellationTokenSource(
+ console: null,
+ logger: null,
+ gracePeriod: Timeout.InfiniteTimeSpan,
+ abortTimeout: Timeout.InfiniteTimeSpan);
source.Cancel();
+ source.Cancel();
+ source.Cancel();
- Assert.IsTrue(source.CancellationToken.IsCancellationRequested);
- Assert.IsNull(environment.ExitCode, "Without a console there is no Ctrl+C handler and Exit must never be called.");
+ Assert.IsTrue(source.DrainingToken.IsCancellationRequested);
+ Assert.IsFalse(source.AbortingToken.IsCancellationRequested);
}
[TestMethod]
- public void Dispose_UnsubscribesCancelKeyPressHandler()
+ public async Task GracePeriodElapse_EscalatesToAborting()
{
- var console = new CancelableConsole();
- var environment = new RecordingEnvironment();
- var source = new CTRLPlusCCancellationTokenSource(console, logger: null, environment);
-
- source.Dispose();
-
- // After disposal the handler must be detached so a late Ctrl+C cannot touch the disposed
- // CancellationTokenSource (which would throw ObjectDisposedException).
- Assert.IsFalse(console.HasCancelKeyPressSubscribers);
-
- // Firing the event after dispose must be a no-op.
- console.FireCancelKeyPress();
- Assert.IsNull(environment.ExitCode);
- }
-
- private sealed class CancelableConsole : IConsole
- {
- public event ConsoleCancelEventHandler? CancelKeyPress;
-
- public int BufferHeight => int.MaxValue;
-
- public int BufferWidth => int.MaxValue;
-
- public int WindowHeight => int.MaxValue;
+ using var source = new CTRLPlusCCancellationTokenSource(
+ console: null,
+ logger: null,
+ gracePeriod: TimeSpan.FromMilliseconds(50),
+ abortTimeout: Timeout.InfiniteTimeSpan);
- public int WindowWidth => int.MaxValue;
-
- public bool IsOutputRedirected => false;
-
- public bool HasCancelKeyPressSubscribers => CancelKeyPress is not null;
-
- public void FireCancelKeyPress()
- {
- ConsoleCancelEventHandler? handler = CancelKeyPress;
- handler?.Invoke(this, CreateConsoleCancelEventArgs());
- }
-
- public void Clear() => throw new NotImplementedException();
-
- public ConsoleColor GetForegroundColor() => ConsoleColor.White;
-
- public void SetForegroundColor(ConsoleColor color)
- {
- // do nothing
- }
-
- public void Write(string? value)
- {
- // do nothing
- }
-
- public void Write(char value)
- {
- // do nothing
- }
-
- public void WriteLine()
- {
- // do nothing
- }
+ source.Cancel();
+ Assert.IsTrue(source.DrainingToken.IsCancellationRequested);
+ Assert.IsFalse(source.AbortingToken.IsCancellationRequested);
- public void WriteLine(string? value)
- {
- // do nothing
- }
+ // Event-driven wait: complete a TaskCompletionSource as soon as the token
+ // is canceled rather than polling, so the test finishes immediately after
+ // the grace period elapses (avoids Task.Delay flakiness under load).
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using CancellationTokenRegistration registration = source.AbortingToken.Register(() => tcs.TrySetResult(true));
- // ConsoleCancelEventArgs has no public constructor; use reflection to instantiate it
- // for the purposes of the test.
- private static ConsoleCancelEventArgs CreateConsoleCancelEventArgs()
- {
- ConstructorInfo? constructor = typeof(ConsoleCancelEventArgs).GetConstructor(
- BindingFlags.Instance | BindingFlags.NonPublic,
- binder: null,
- types: [typeof(ConsoleSpecialKey)],
- modifiers: null);
+ Task timeoutTask = Task.Delay(TimeSpan.FromSeconds(5), TestContext.CancellationToken);
+ Task completed = await Task.WhenAny(tcs.Task, timeoutTask).ConfigureAwait(false);
- Assert.IsNotNull(constructor, "Failed to locate internal ConsoleCancelEventArgs constructor.");
- return (ConsoleCancelEventArgs)constructor.Invoke([ConsoleSpecialKey.ControlC]);
- }
+ Assert.AreSame(tcs.Task, completed, "Aborting must trip before the timeout.");
+ Assert.IsTrue(source.AbortingToken.IsCancellationRequested, "Aborting must trip after the grace period.");
}
- private sealed class RecordingEnvironment : IEnvironment
- {
- public int? ExitCode { get; private set; }
-
- public int ExitCallCount { get; private set; }
-
- public string CommandLine => string.Empty;
-
- public string MachineName => string.Empty;
-
- public string NewLine => Environment.NewLine;
-
- public int ProcessId => 0;
-
- public string OsVersion => string.Empty;
+ public TestContext TestContext { get; set; } = null!;
-#if NETCOREAPP
- public string? ProcessPath => null;
-#endif
-
- public string[] GetCommandLineArgs() => [];
-
- public string? GetEnvironmentVariable(string name) => null;
-
- public IDictionary GetEnvironmentVariables() => new Dictionary();
-
- public string GetFolderPath(Environment.SpecialFolder folder, Environment.SpecialFolderOption option) => string.Empty;
-
- public void FailFast(string? message, Exception? exception)
- {
- // do nothing
- }
-
- public void FailFast(string? message)
- {
- // do nothing
- }
-
- public void SetEnvironmentVariable(string variable, string? value)
- {
- // do nothing
- }
-
- public void Exit(int exitCode)
- {
- ExitCode = exitCode;
- ExitCallCount++;
-
- // The real implementation never returns; ours does, so subsequent presses still
- // observe ExitCallCount accurately for the test.
- }
- }
-
- private sealed class RecordingLogger : ILogger
+ [TestMethod]
+ public void ZeroGracePeriod_ImmediatelyEscalatesToAborting()
{
- private int _warningCount;
+ using var source = new CTRLPlusCCancellationTokenSource(
+ console: null,
+ logger: null,
+ gracePeriod: TimeSpan.Zero,
+ abortTimeout: Timeout.InfiniteTimeSpan);
- public int WarningCount => _warningCount;
-
- public string? LastWarning { get; private set; }
-
- public bool IsEnabled(LogLevel logLevel) => true;
-
- public void Log(LogLevel logLevel, TState state, Exception? exception, Func formatter)
- {
- if (logLevel == LogLevel.Warning)
- {
- Interlocked.Increment(ref _warningCount);
- LastWarning = formatter(state, exception);
- }
- }
+ source.Cancel();
- public Task LogAsync(LogLevel logLevel, TState state, Exception? exception, Func formatter)
- {
- Log(logLevel, state, exception, formatter);
- return Task.CompletedTask;
- }
+ Assert.IsTrue(source.DrainingToken.IsCancellationRequested);
+ Assert.IsTrue(source.AbortingToken.IsCancellationRequested);
}
}