Skip to content

Commit 7596cef

Browse files
committed
@
feat(aspire): tear down Aspire on test-run abort via session token AspireFixture startup (CreateAsync/BuildAsync/StartAsync and the resource-wait) previously only observed its own ResourceTimeout, so a run abort (Ctrl+C / IDE stop / ProcessExit) could neither interrupt a stuck startup nor stop the application before the process was forcefully killed, leaking containers. Expose the engine-wide abort token on TestSessionContext (populated once in TUnitTestFramework.ExecuteRequestAsync, covering both reflection and source-generated modes) so session-scoped fixtures can observe run cancellation. AspireFixture links it into all startup/wait operations via a virtual RunCancellationToken and registers a single-shot teardown that stops and disposes the application as soon as the run is aborted; DisposeAsync awaits the same task so teardown runs exactly once. The token lives on TestSessionContext (not a per-test context) because a session- or class-scoped fixture outlives any single test; a per-test token would tear the shared application down when its first consuming test ends. RunCancellationToken is virtual for the rare per-test fixture. Abort is distinguished from a genuine resource timeout so it surfaces as cancellation rather than a misleading TimeoutException. @
1 parent 0471ec6 commit 7596cef

7 files changed

Lines changed: 125 additions & 18 deletions

TUnit.Aspire.Core/AspireFixture.cs

Lines changed: 103 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,29 @@ public class AspireFixture<TAppHost> : IAsyncInitializer, IAsyncDisposable
4040
private readonly object _timelineGate = new();
4141
private Stopwatch? _timelineClock;
4242

43+
// Single-shot teardown so the run-abort callback and the normal DisposeAsync path
44+
// converge on the same StopAsync/DisposeAsync work instead of racing each other.
45+
private readonly object _teardownGate = new();
46+
private Task? _teardownTask;
47+
private CancellationTokenRegistration _abortRegistration;
48+
49+
/// <summary>
50+
/// The run-abort token whose lifetime governs this fixture. Defaults to the test session's
51+
/// cancellation token (Ctrl+C, IDE stop, process exit), which matches the lifetime of a
52+
/// session- or class-scoped fixture. It is linked into the startup and resource-wait
53+
/// operations so an abort interrupts them promptly instead of blocking for the full
54+
/// <see cref="ResourceTimeout"/>, and triggers teardown of the Aspire application.
55+
/// Override-provided <see cref="WaitForResourcesAsync"/> implementations should honor it too.
56+
/// </summary>
57+
/// <remarks>
58+
/// Override this only if the fixture is genuinely per-test (<c>Shared = SharedType.None</c>)
59+
/// and you want a per-test token folded in. Do NOT return a per-test token from a shared
60+
/// fixture: it is cancelled when the first consuming test ends, which would tear the shared
61+
/// application down underneath every later test.
62+
/// </remarks>
63+
protected virtual CancellationToken RunCancellationToken =>
64+
TestSessionContext.Current?.CancellationToken ?? CancellationToken.None;
65+
4366
/// <summary>
4467
/// The running Aspire distributed application.
4568
/// </summary>
@@ -198,7 +221,8 @@ protected virtual void ConfigureBuilder(IDistributedApplicationTestingBuilder bu
198221
/// Override for full control over the resource waiting logic.
199222
/// </summary>
200223
/// <param name="app">The running distributed application.</param>
201-
/// <param name="cancellationToken">A cancellation token that will be cancelled after <see cref="ResourceTimeout"/>.</param>
224+
/// <param name="cancellationToken">A cancellation token that is cancelled after <see cref="ResourceTimeout"/>
225+
/// or when the test run is aborted (see <see cref="RunCancellationToken"/>).</param>
202226
protected virtual async Task WaitForResourcesAsync(DistributedApplication app, CancellationToken cancellationToken)
203227
{
204228
var notificationService = app.Services.GetRequiredService<ResourceNotificationService>();
@@ -259,7 +283,7 @@ public virtual async Task InitializeAsync()
259283
}
260284

261285
LogProgress($"Creating distributed application builder for {typeof(TAppHost).Name}...");
262-
var builder = await DistributedApplicationTestingBuilder.CreateAsync<TAppHost>(Args, ConfigureAppHost);
286+
var builder = await DistributedApplicationTestingBuilder.CreateAsync<TAppHost>(Args, ConfigureAppHost, RunCancellationToken);
263287
ConfigureBuilder(builder);
264288
RemoveResources(builder);
265289

@@ -272,9 +296,14 @@ public virtual async Task InitializeAsync()
272296
LogProgress($"Builder created in {sw.Elapsed.TotalSeconds:0.0}s");
273297

274298
LogProgress("Building application...");
275-
_app = await builder.BuildAsync();
299+
_app = await builder.BuildAsync(RunCancellationToken);
276300
LogProgress($"Application built in {sw.Elapsed.TotalSeconds:0.0}s");
277301

302+
// Now that the app exists, hook the run-abort token so a Ctrl+C / IDE stop tears the
303+
// application down immediately, rather than leaking containers while the rest of the
304+
// session unwinds (or the process is forcefully killed on the abort timeout).
305+
RegisterAbortTeardown();
306+
278307
var model = _app.Services.GetRequiredService<DistributedApplicationModel>();
279308
var resourceList = string.Join(", ", model.Resources.Select(r => r.Name));
280309
LogProgress($"Starting application with resources: [{resourceList}]");
@@ -288,32 +317,45 @@ public virtual async Task InitializeAsync()
288317
var monitorTask = MonitorResourceEventsAsync(_app, monitorCts.Token);
289318
var notificationService = _app.Services.GetRequiredService<ResourceNotificationService>();
290319

320+
// Linked to the run-abort token so an abort interrupts startup promptly; the timeout
321+
// is layered on top via CancelAfter.
322+
using var startCts = CancellationTokenSource.CreateLinkedTokenSource(RunCancellationToken);
323+
startCts.CancelAfter(ResourceTimeout);
291324
try
292325
{
293-
using (var startCts = new CancellationTokenSource(ResourceTimeout))
326+
try
294327
{
295-
try
296-
{
297-
await _app.StartAsync(startCts.Token);
298-
}
299-
catch (OperationCanceledException) when (startCts.IsCancellationRequested)
300-
{
301-
var headline = $"Timed out after {ResourceTimeout.TotalSeconds:0}s waiting for the Aspire application to start.";
302-
throw new TimeoutException(
303-
await BuildDiagnosticsAndAttachAsync(_app, notificationService, headline,
304-
model.Resources.Select(r => r.Name).ToList()));
305-
}
328+
await _app.StartAsync(startCts.Token);
329+
}
330+
catch (OperationCanceledException) when (RunCancellationToken.IsCancellationRequested)
331+
{
332+
// Run aborted — propagate cancellation; DisposeAsync (and the abort callback) tear down.
333+
throw;
334+
}
335+
catch (OperationCanceledException) when (startCts.IsCancellationRequested)
336+
{
337+
var headline = $"Timed out after {ResourceTimeout.TotalSeconds:0}s waiting for the Aspire application to start.";
338+
throw new TimeoutException(
339+
await BuildDiagnosticsAndAttachAsync(_app, notificationService, headline,
340+
model.Resources.Select(r => r.Name).ToList()));
306341
}
307342

308343
LogProgress($"Application started in {sw.Elapsed.TotalSeconds:0.0}s. Waiting for resources (timeout: {ResourceTimeout.TotalSeconds:0}s, behavior: {WaitBehavior})...");
309344

310-
using (var cts = new CancellationTokenSource(ResourceTimeout))
345+
using (var cts = CancellationTokenSource.CreateLinkedTokenSource(RunCancellationToken))
311346
{
347+
cts.CancelAfter(ResourceTimeout);
348+
312349
try
313350
{
314351
await WaitForResourcesAsync(_app, cts.Token);
315352
LogProgress("All resources ready.");
316353
}
354+
catch (OperationCanceledException) when (RunCancellationToken.IsCancellationRequested)
355+
{
356+
// Run aborted — propagate cancellation; teardown is handled by DisposeAsync / abort callback.
357+
throw;
358+
}
317359
catch (OperationCanceledException) when (cts.IsCancellationRequested)
318360
{
319361
// Fallback for custom WaitForResourcesAsync overrides that don't use the default
@@ -361,6 +403,44 @@ private void RemoveResources(IDistributedApplicationTestingBuilder builder)
361403
/// </code>
362404
/// </summary>
363405
public virtual async ValueTask DisposeAsync()
406+
{
407+
_abortRegistration.Dispose();
408+
await StopAndDisposeAsync();
409+
GC.SuppressFinalize(this);
410+
}
411+
412+
/// <summary>
413+
/// Registers the run-abort token so an aborted test run begins tearing down the Aspire
414+
/// application immediately, in parallel with the rest of session shutdown. The callback and
415+
/// the normal <see cref="DisposeAsync"/> path share a single teardown task, so the work runs
416+
/// exactly once regardless of which fires first.
417+
/// </summary>
418+
private void RegisterAbortTeardown()
419+
{
420+
if (!RunCancellationToken.CanBeCanceled)
421+
{
422+
return;
423+
}
424+
425+
_abortRegistration = RunCancellationToken.Register(static state =>
426+
{
427+
var fixture = (AspireFixture<TAppHost>) state!;
428+
fixture.LogProgress("Test run aborted — tearing down Aspire application...");
429+
// Fire-and-forget: don't block the thread raising cancellation. DisposeAsync awaits
430+
// the same task, so completion is still observed during session shutdown.
431+
_ = fixture.StopAndDisposeAsync();
432+
}, this);
433+
}
434+
435+
private Task StopAndDisposeAsync()
436+
{
437+
lock (_teardownGate)
438+
{
439+
return _teardownTask ??= StopAndDisposeCoreAsync();
440+
}
441+
}
442+
443+
private async Task StopAndDisposeCoreAsync()
364444
{
365445
if (_otlpReceiver is not null && _app is not null)
366446
{
@@ -387,8 +467,6 @@ public virtual async ValueTask DisposeAsync()
387467
await _otlpReceiver.DisposeAsync();
388468
_otlpReceiver = null;
389469
}
390-
391-
GC.SuppressFinalize(this);
392470
}
393471

394472
// --- OTLP Telemetry ---
@@ -719,6 +797,13 @@ await BuildDiagnosticsAndAttachAsync(
719797
}
720798
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
721799
{
800+
// A run-abort cancels the linked token too — surface it as cancellation, not a
801+
// misleading resource timeout, so the engine sees the abort and teardown proceeds.
802+
if (RunCancellationToken.IsCancellationRequested)
803+
{
804+
throw;
805+
}
806+
722807
// Timeout - diagnose each pending resource (state, exit code, health, dependencies)
723808
// and attach the full timeline + untruncated logs as an artifact.
724809
failureCts.Cancel();

TUnit.Core/Models/TestSessionContext.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,20 @@ internal TestSessionContext(TestDiscoveryContext beforeTestDiscoveryContext) : b
5959

6060
public required string? TestFilter { get; init; }
6161

62+
/// <summary>
63+
/// The engine-wide cancellation token for this test session. Signalled when the run is
64+
/// aborted (e.g. Ctrl+C, IDE stop, or process exit). Session-scoped fixtures
65+
/// (<see cref="Interfaces.IAsyncInitializer"/> implementations such as long-running
66+
/// containers or distributed applications) can observe this to abort startup and tear
67+
/// themselves down promptly on an abort request.
68+
/// </summary>
69+
/// <remarks>
70+
/// Defaults to <see cref="System.Threading.CancellationToken.None"/> until the engine
71+
/// populates it at the start of execution. Per-test cancellation flows through
72+
/// <see cref="TestContext.CancellationToken"/> instead; this token spans the whole session.
73+
/// </remarks>
74+
public CancellationToken CancellationToken { get; internal set; }
75+
6276
private readonly Lock _lock = new();
6377
private readonly List<AssemblyHookContext> _assemblies = [];
6478
private ClassHookContext[]? _cachedTestClasses;

TUnit.Engine/Framework/TUnitTestFramework.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ public async Task ExecuteRequestAsync(ExecuteRequestContext context)
5959
GlobalContext.Current.GlobalLogger = serviceProvider.Logger;
6060
BeforeTestDiscoveryContext.Current = serviceProvider.ContextProvider.BeforeTestDiscoveryContext;
6161
TestDiscoveryContext.Current = serviceProvider.ContextProvider.TestDiscoveryContext;
62+
// Expose the engine-wide abort token on the session context so session-scoped
63+
// fixtures (containers, Aspire apps, etc.) can observe run cancellation and tear
64+
// themselves down.
65+
serviceProvider.ContextProvider.TestSessionContext.CancellationToken = serviceProvider.CancellationToken.Token;
6266
TestSessionContext.Current = serviceProvider.ContextProvider.TestSessionContext;
6367

6468
serviceProvider.Initializer.Initialize();

TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1721,6 +1721,7 @@ namespace
17211721
{
17221722
public .<.TestContext> AllTests { get; }
17231723
public .<.AssemblyHookContext> Assemblies { get; }
1724+
public .CancellationToken CancellationToken { get; }
17241725
public required string Id { get; init; }
17251726
public .<.ClassHookContext> TestClasses { get; }
17261727
public .TestDiscoveryContext TestDiscoveryContext { get; }

TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1721,6 +1721,7 @@ namespace
17211721
{
17221722
public .<.TestContext> AllTests { get; }
17231723
public .<.AssemblyHookContext> Assemblies { get; }
1724+
public .CancellationToken CancellationToken { get; }
17241725
public required string Id { get; init; }
17251726
public .<.ClassHookContext> TestClasses { get; }
17261727
public .TestDiscoveryContext TestDiscoveryContext { get; }

TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1721,6 +1721,7 @@ namespace
17211721
{
17221722
public .<.TestContext> AllTests { get; }
17231723
public .<.AssemblyHookContext> Assemblies { get; }
1724+
public .CancellationToken CancellationToken { get; }
17241725
public required string Id { get; init; }
17251726
public .<.ClassHookContext> TestClasses { get; }
17261727
public .TestDiscoveryContext TestDiscoveryContext { get; }

TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1660,6 +1660,7 @@ namespace
16601660
{
16611661
public .<.TestContext> AllTests { get; }
16621662
public .<.AssemblyHookContext> Assemblies { get; }
1663+
public .CancellationToken CancellationToken { get; }
16631664
public required string Id { get; init; }
16641665
public .<.ClassHookContext> TestClasses { get; }
16651666
public .TestDiscoveryContext TestDiscoveryContext { get; }

0 commit comments

Comments
 (0)