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