Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 112 additions & 18 deletions TUnit.Aspire.Core/AspireFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,29 @@ public class AspireFixture<TAppHost> : 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;

/// <summary>
/// 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
/// <see cref="ResourceTimeout"/>, and triggers teardown of the Aspire application.
/// Override-provided <see cref="WaitForResourcesAsync"/> implementations should honor it too.
/// </summary>
/// <remarks>
/// Override this only if the fixture is genuinely per-test (<c>Shared = SharedType.None</c>)
/// 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.
/// </remarks>
protected virtual CancellationToken RunCancellationToken =>
TestSessionContext.Current?.SessionCancellationToken ?? CancellationToken.None;

/// <summary>
/// The running Aspire distributed application.
/// </summary>
Expand Down Expand Up @@ -198,7 +221,8 @@ protected virtual void ConfigureBuilder(IDistributedApplicationTestingBuilder bu
/// Override for full control over the resource waiting logic.
/// </summary>
/// <param name="app">The running distributed application.</param>
/// <param name="cancellationToken">A cancellation token that will be cancelled after <see cref="ResourceTimeout"/>.</param>
/// <param name="cancellationToken">A cancellation token that is cancelled after <see cref="ResourceTimeout"/>
/// or when the test run is aborted (see <see cref="RunCancellationToken"/>).</param>
protected virtual async Task WaitForResourcesAsync(DistributedApplication app, CancellationToken cancellationToken)
{
var notificationService = app.Services.GetRequiredService<ResourceNotificationService>();
Expand Down Expand Up @@ -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<TAppHost>(Args, ConfigureAppHost);
var builder = await DistributedApplicationTestingBuilder.CreateAsync<TAppHost>(Args, ConfigureAppHost, RunCancellationToken);
ConfigureBuilder(builder);
RemoveResources(builder);

Expand All @@ -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<DistributedApplicationModel>();
Expand All @@ -288,32 +316,45 @@ public virtual async Task InitializeAsync()
var monitorTask = MonitorResourceEventsAsync(_app, monitorCts.Token);
var notificationService = _app.Services.GetRequiredService<ResourceNotificationService>();

// 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
Expand Down Expand Up @@ -361,6 +402,54 @@ private void RemoveResources(IDistributedApplicationTestingBuilder builder)
/// </code>
/// </summary>
public virtual async ValueTask DisposeAsync()
{
_abortRegistration.Dispose();
await StopAndDisposeAsync();
GC.SuppressFinalize(this);
}

/// <summary>
/// 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 <see cref="DisposeAsync"/> path share a single teardown task, so the work runs
/// exactly once regardless of which fires first.
/// </summary>
private void RegisterAbortTeardown()
{
if (!RunCancellationToken.CanBeCanceled)
{
return;
}

_abortRegistration = RunCancellationToken.Register(static state =>
{
var fixture = (AspireFixture<TAppHost>) 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)
{
Expand All @@ -387,8 +476,6 @@ public virtual async ValueTask DisposeAsync()
await _otlpReceiver.DisposeAsync();
_otlpReceiver = null;
}

GC.SuppressFinalize(this);
}

// --- OTLP Telemetry ---
Expand Down Expand Up @@ -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();
Expand Down
30 changes: 28 additions & 2 deletions TUnit.Core/EngineCancellationToken.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public class EngineCancellationToken : IDisposable

private int _initialised;
private volatile bool _forcefulExitStarted;
private CancellationTokenRegistration _platformRegistration;

public EngineCancellationToken()
{
Expand All @@ -32,13 +33,28 @@ public EngineCancellationToken()
/// Per-call cancellation flows through the explicit <c>CancellationToken</c> threaded into
/// discovery/execution, not through this session-scoped token.
/// </summary>
internal void Initialise()
/// <param name="platformCancellationToken">
/// Microsoft.Testing.Platform's run-abort token. The host (IDE stop button, CI runner cancel,
/// <c>--abort</c>) 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.
/// </param>
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())
Expand All @@ -59,14 +75,22 @@ 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)
{
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)
{
Expand Down Expand Up @@ -106,6 +130,8 @@ private void OnProcessExit(object? sender, EventArgs e)
/// </summary>
public void Dispose()
{
_platformRegistration.Dispose();

// Console.CancelKeyPress is not supported on browser platforms
#if NET5_0_OR_GREATER
if (!OperatingSystem.IsBrowser())
Expand Down
14 changes: 14 additions & 0 deletions TUnit.Core/Models/TestSessionContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,20 @@ internal TestSessionContext(TestDiscoveryContext beforeTestDiscoveryContext) : b

public required string? TestFilter { get; init; }

/// <summary>
/// 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
/// (<see cref="Interfaces.IAsyncInitializer"/> implementations such as long-running
/// containers or distributed applications) can observe this to abort startup and tear
/// themselves down promptly on an abort request.
/// </summary>
/// <remarks>
/// Defaults to <see cref="System.Threading.CancellationToken.None"/> until the engine
/// populates it at the start of execution. Per-test cancellation flows through
/// <see cref="TestContext.CancellationToken"/> instead; this token spans the whole session.
/// </remarks>
public CancellationToken SessionCancellationToken { get; internal set; }

private readonly Lock _lock = new();
private readonly List<AssemblyHookContext> _assemblies = [];
private ClassHookContext[]? _cachedTestClasses;
Expand Down
9 changes: 8 additions & 1 deletion TUnit.Engine/Framework/TUnitTestFramework.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
Loading