diff --git a/TUnit.Aspire.Core/AspireFixture.cs b/TUnit.Aspire.Core/AspireFixture.cs index 739dfe8e2e1..bfa128043f4 100644 --- a/TUnit.Aspire.Core/AspireFixture.cs +++ b/TUnit.Aspire.Core/AspireFixture.cs @@ -40,6 +40,29 @@ public class AspireFixture : IAsyncInitializer, IAsyncDisposable private readonly object _timelineGate = new(); private Stopwatch? _timelineClock; + // Single-shot teardown so the run-abort callback and the normal DisposeAsync path + // converge on the same StopAsync/DisposeAsync work instead of racing each other. + private readonly object _teardownGate = new(); + private Task? _teardownTask; + private CancellationTokenRegistration _abortRegistration; + + /// + /// The run-abort token whose lifetime governs this fixture. Defaults to the test session's + /// cancellation token (Ctrl+C, IDE stop, process exit), which matches the lifetime of a + /// session- or class-scoped fixture. It is linked into the startup and resource-wait + /// operations so an abort interrupts them promptly instead of blocking for the full + /// , and triggers teardown of the Aspire application. + /// Override-provided implementations should honor it too. + /// + /// + /// Override this only if the fixture is genuinely per-test (Shared = SharedType.None) + /// and you want a per-test token folded in. Do NOT return a per-test token from a shared + /// fixture: it is cancelled when the first consuming test ends, which would tear the shared + /// application down underneath every later test. + /// + protected virtual CancellationToken RunCancellationToken => + TestSessionContext.Current?.SessionCancellationToken ?? CancellationToken.None; + /// /// The running Aspire distributed application. /// @@ -198,7 +221,8 @@ protected virtual void ConfigureBuilder(IDistributedApplicationTestingBuilder bu /// Override for full control over the resource waiting logic. /// /// The running distributed application. - /// A cancellation token that will be cancelled after . + /// A cancellation token that is cancelled after + /// or when the test run is aborted (see ). protected virtual async Task WaitForResourcesAsync(DistributedApplication app, CancellationToken cancellationToken) { var notificationService = app.Services.GetRequiredService(); @@ -258,8 +282,12 @@ public virtual async Task InitializeAsync() LogProgress($"OTLP receiver listening on port {_otlpReceiver!.Port}"); } + // Register before CreateAsync/BuildAsync so an abort during early AppHost creation still + // begins fixture teardown instead of waiting for normal failed-initializer disposal. + RegisterAbortTeardown(); + LogProgress($"Creating distributed application builder for {typeof(TAppHost).Name}..."); - var builder = await DistributedApplicationTestingBuilder.CreateAsync(Args, ConfigureAppHost); + var builder = await DistributedApplicationTestingBuilder.CreateAsync(Args, ConfigureAppHost, RunCancellationToken); ConfigureBuilder(builder); RemoveResources(builder); @@ -272,7 +300,7 @@ public virtual async Task InitializeAsync() LogProgress($"Builder created in {sw.Elapsed.TotalSeconds:0.0}s"); LogProgress("Building application..."); - _app = await builder.BuildAsync(); + _app = await builder.BuildAsync(RunCancellationToken); LogProgress($"Application built in {sw.Elapsed.TotalSeconds:0.0}s"); var model = _app.Services.GetRequiredService(); @@ -288,32 +316,45 @@ public virtual async Task InitializeAsync() var monitorTask = MonitorResourceEventsAsync(_app, monitorCts.Token); var notificationService = _app.Services.GetRequiredService(); + // Linked to the run-abort token so an abort interrupts startup promptly; the timeout + // is layered on top via CancelAfter. + using var startCts = CancellationTokenSource.CreateLinkedTokenSource(RunCancellationToken); + startCts.CancelAfter(ResourceTimeout); try { - using (var startCts = new CancellationTokenSource(ResourceTimeout)) + try { - try - { - await _app.StartAsync(startCts.Token); - } - catch (OperationCanceledException) when (startCts.IsCancellationRequested) - { - var headline = $"Timed out after {ResourceTimeout.TotalSeconds:0}s waiting for the Aspire application to start."; - throw new TimeoutException( - await BuildDiagnosticsAndAttachAsync(_app, notificationService, headline, - model.Resources.Select(r => r.Name).ToList())); - } + await _app.StartAsync(startCts.Token); + } + catch (OperationCanceledException) when (RunCancellationToken.IsCancellationRequested) + { + // Run aborted — propagate cancellation; DisposeAsync (and the abort callback) tear down. + throw; + } + catch (OperationCanceledException) when (startCts.IsCancellationRequested) + { + var headline = $"Timed out after {ResourceTimeout.TotalSeconds:0}s waiting for the Aspire application to start."; + throw new TimeoutException( + await BuildDiagnosticsAndAttachAsync(_app, notificationService, headline, + model.Resources.Select(r => r.Name).ToList())); } LogProgress($"Application started in {sw.Elapsed.TotalSeconds:0.0}s. Waiting for resources (timeout: {ResourceTimeout.TotalSeconds:0}s, behavior: {WaitBehavior})..."); - using (var cts = new CancellationTokenSource(ResourceTimeout)) + using (var cts = CancellationTokenSource.CreateLinkedTokenSource(RunCancellationToken)) { + cts.CancelAfter(ResourceTimeout); + try { await WaitForResourcesAsync(_app, cts.Token); LogProgress("All resources ready."); } + catch (OperationCanceledException) when (RunCancellationToken.IsCancellationRequested) + { + // Run aborted — propagate cancellation; teardown is handled by DisposeAsync / abort callback. + throw; + } catch (OperationCanceledException) when (cts.IsCancellationRequested) { // Fallback for custom WaitForResourcesAsync overrides that don't use the default @@ -361,6 +402,54 @@ private void RemoveResources(IDistributedApplicationTestingBuilder builder) /// /// public virtual async ValueTask DisposeAsync() + { + _abortRegistration.Dispose(); + await StopAndDisposeAsync(); + GC.SuppressFinalize(this); + } + + /// + /// Registers the run-abort token so an aborted test run begins tearing down the Aspire + /// application immediately, in parallel with the rest of session shutdown. The callback and + /// the normal path share a single teardown task, so the work runs + /// exactly once regardless of which fires first. + /// + private void RegisterAbortTeardown() + { + if (!RunCancellationToken.CanBeCanceled) + { + return; + } + + _abortRegistration = RunCancellationToken.Register(static state => + { + var fixture = (AspireFixture) state!; + fixture.LogProgress("Test run aborted — tearing down Aspire application..."); + // Fire-and-forget: don't block the thread raising cancellation. Log errors here too + // in case the host kills the process before DisposeAsync observes the shared task. + _ = Task.Run(async () => + { + try + { + await fixture.StopAndDisposeAsync(); + } + catch (Exception ex) + { + fixture.LogProgress($"Teardown error on abort: {ex.Message}"); + } + }); + }, this); + } + + private Task StopAndDisposeAsync() + { + lock (_teardownGate) + { + return _teardownTask ??= StopAndDisposeCoreAsync(); + } + } + + private async Task StopAndDisposeCoreAsync() { if (_otlpReceiver is not null && _app is not null) { @@ -387,8 +476,6 @@ public virtual async ValueTask DisposeAsync() await _otlpReceiver.DisposeAsync(); _otlpReceiver = null; } - - GC.SuppressFinalize(this); } // --- OTLP Telemetry --- @@ -719,6 +806,13 @@ await BuildDiagnosticsAndAttachAsync( } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { + // A run-abort cancels the linked token too — surface it as cancellation, not a + // misleading resource timeout, so the engine sees the abort and teardown proceeds. + if (RunCancellationToken.IsCancellationRequested) + { + throw; + } + // Timeout - diagnose each pending resource (state, exit code, health, dependencies) // and attach the full timeline + untruncated logs as an artifact. failureCts.Cancel(); diff --git a/TUnit.Core/EngineCancellationToken.cs b/TUnit.Core/EngineCancellationToken.cs index 9170263aece..3b72aba7c9d 100644 --- a/TUnit.Core/EngineCancellationToken.cs +++ b/TUnit.Core/EngineCancellationToken.cs @@ -19,6 +19,7 @@ public class EngineCancellationToken : IDisposable private int _initialised; private volatile bool _forcefulExitStarted; + private CancellationTokenRegistration _platformRegistration; public EngineCancellationToken() { @@ -32,13 +33,28 @@ public EngineCancellationToken() /// Per-call cancellation flows through the explicit CancellationToken threaded into /// discovery/execution, not through this session-scoped token. /// - internal void Initialise() + /// + /// Microsoft.Testing.Platform's run-abort token. The host (IDE stop button, CI runner cancel, + /// --abort) cancels this independently of the OS signal handlers, so we link it in: when + /// it fires, the engine token fires too, and session-scoped fixtures observing it tear down. + /// Unlike the Ctrl+C path, this does NOT arm the forceful-exit timer — the platform owns + /// process shutdown when it is the one cancelling, and killing the process underneath it would + /// truncate result reporting. + /// + internal void Initialise(CancellationToken platformCancellationToken = default) { if (Interlocked.CompareExchange(ref _initialised, 1, 0) != 0) { return; } + if (platformCancellationToken.CanBeCanceled) + { + _platformRegistration = platformCancellationToken.Register( + static state => ((EngineCancellationToken) state!).Cancel(armForcefulExit: false), + this); + } + // Console.CancelKeyPress is not supported on browser platforms #if NET5_0_OR_GREATER if (!OperatingSystem.IsBrowser()) @@ -59,7 +75,7 @@ private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) e.Cancel = true; } - private void Cancel() + private void Cancel(bool armForcefulExit = true) { // Cancel the test execution if (!CancellationTokenSource.IsCancellationRequested) @@ -67,6 +83,14 @@ private void Cancel() CancellationTokenSource.Cancel(); } + // The forceful-exit timer is only for signal-driven cancellation (Ctrl+C), where we + // suppressed the runtime's default termination and must guarantee the process still dies. + // Platform-driven cancellation manages its own shutdown, so it opts out. + if (!armForcefulExit) + { + return; + } + // Only start the forceful exit timer once if (!_forcefulExitStarted) { @@ -106,6 +130,8 @@ private void OnProcessExit(object? sender, EventArgs e) /// public void Dispose() { + _platformRegistration.Dispose(); + // Console.CancelKeyPress is not supported on browser platforms #if NET5_0_OR_GREATER if (!OperatingSystem.IsBrowser()) diff --git a/TUnit.Core/Models/TestSessionContext.cs b/TUnit.Core/Models/TestSessionContext.cs index 098d9a9eb52..2711bd96103 100644 --- a/TUnit.Core/Models/TestSessionContext.cs +++ b/TUnit.Core/Models/TestSessionContext.cs @@ -59,6 +59,20 @@ internal TestSessionContext(TestDiscoveryContext beforeTestDiscoveryContext) : b public required string? TestFilter { get; init; } + /// + /// The engine-wide cancellation token for this test session. Signalled when the run is + /// aborted (e.g. Ctrl+C, IDE stop, or process exit). Session-scoped fixtures + /// ( implementations such as long-running + /// containers or distributed applications) can observe this to abort startup and tear + /// themselves down promptly on an abort request. + /// + /// + /// Defaults to until the engine + /// populates it at the start of execution. Per-test cancellation flows through + /// instead; this token spans the whole session. + /// + public CancellationToken SessionCancellationToken { get; internal set; } + private readonly Lock _lock = new(); private readonly List _assemblies = []; private ClassHookContext[]? _cachedTestClasses; diff --git a/TUnit.Engine/Framework/TUnitTestFramework.cs b/TUnit.Engine/Framework/TUnitTestFramework.cs index 948ed0b3f0e..3ef4de9fa7a 100644 --- a/TUnit.Engine/Framework/TUnitTestFramework.cs +++ b/TUnit.Engine/Framework/TUnitTestFramework.cs @@ -59,13 +59,20 @@ public async Task ExecuteRequestAsync(ExecuteRequestContext context) GlobalContext.Current.GlobalLogger = serviceProvider.Logger; BeforeTestDiscoveryContext.Current = serviceProvider.ContextProvider.BeforeTestDiscoveryContext; TestDiscoveryContext.Current = serviceProvider.ContextProvider.TestDiscoveryContext; + // Expose the engine-wide abort token on the session context so session-scoped + // fixtures (containers, Aspire apps, etc.) can observe run cancellation and tear + // themselves down. + serviceProvider.ContextProvider.TestSessionContext.SessionCancellationToken = serviceProvider.CancellationToken.Token; TestSessionContext.Current = serviceProvider.ContextProvider.TestSessionContext; serviceProvider.Initializer.Initialize(); await serviceProvider.HookDelegateBuilder.InitializeAsync(); - serviceProvider.CancellationToken.Initialise(); + // Link the platform's run-abort token (IDE stop, CI runner cancel, --abort) so it + // flows through the engine token to session-scoped fixtures, alongside the OS signal + // handlers (Ctrl+C / process exit) wired up inside Initialise. + serviceProvider.CancellationToken.Initialise(context.CancellationToken); await _requestHandler.HandleRequestAsync((TestExecutionRequest) context.Request, serviceProvider, context, GetFilter(context)); } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index d68c10b1dca..2a4fbf0bc74 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1722,6 +1722,7 @@ namespace public .<.TestContext> AllTests { get; } public .<.AssemblyHookContext> Assemblies { get; } public required string Id { get; init; } + public .CancellationToken SessionCancellationToken { get; } public .<.ClassHookContext> TestClasses { get; } public .TestDiscoveryContext TestDiscoveryContext { get; } public required string? TestFilter { get; init; } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 3f02fb3161f..f6c2a7836aa 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1722,6 +1722,7 @@ namespace public .<.TestContext> AllTests { get; } public .<.AssemblyHookContext> Assemblies { get; } public required string Id { get; init; } + public .CancellationToken SessionCancellationToken { get; } public .<.ClassHookContext> TestClasses { get; } public .TestDiscoveryContext TestDiscoveryContext { get; } public required string? TestFilter { get; init; } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index c5e3c25db70..b52b811d34d 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1722,6 +1722,7 @@ namespace public .<.TestContext> AllTests { get; } public .<.AssemblyHookContext> Assemblies { get; } public required string Id { get; init; } + public .CancellationToken SessionCancellationToken { get; } public .<.ClassHookContext> TestClasses { get; } public .TestDiscoveryContext TestDiscoveryContext { get; } public required string? TestFilter { get; init; } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index 537423da35b..c6a3114e729 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1661,6 +1661,7 @@ namespace public .<.TestContext> AllTests { get; } public .<.AssemblyHookContext> Assemblies { get; } public required string Id { get; init; } + public .CancellationToken SessionCancellationToken { get; } public .<.ClassHookContext> TestClasses { get; } public .TestDiscoveryContext TestDiscoveryContext { get; } public required string? TestFilter { get; init; }