@@ -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 ( ) ;
0 commit comments