diff --git a/src/contrib/dependencyinjection/Akka.DependencyInjection.Tests/BugFixSpec.cs b/src/contrib/dependencyinjection/Akka.DependencyInjection.Tests/BugFixSpec.cs
index 6672e4700fc..353ec020177 100644
--- a/src/contrib/dependencyinjection/Akka.DependencyInjection.Tests/BugFixSpec.cs
+++ b/src/contrib/dependencyinjection/Akka.DependencyInjection.Tests/BugFixSpec.cs
@@ -19,7 +19,7 @@
namespace Akka.DependencyInjection.Tests
{
- public class BugFixSpec: AkkaSpec, IAsyncLifetime
+ public class BugFixSpec: AkkaSpec
{
private readonly IServiceProvider _serviceProvider;
private readonly AkkaService _akkaService;
@@ -59,8 +59,9 @@ internal class NotInServices
}
- public async ValueTask InitializeAsync()
+ public override async ValueTask InitializeAsync()
{
+ await base.InitializeAsync();
await _akkaService.StartAsync(default);
InitializeLogger(_akkaService.ActorSystem);
}
@@ -72,11 +73,6 @@ protected override void AfterAll()
base.AfterAll();
}
- public ValueTask DisposeAsync()
- {
- return new ValueTask(Task.CompletedTask);
- }
-
internal class AkkaService : IHostedService
{
public ActorSystem ActorSystem { get; private set; }
diff --git a/src/contrib/testkits/Akka.TestKit.Xunit.Tests/Bugfix8144Spec.cs b/src/contrib/testkits/Akka.TestKit.Xunit.Tests/Bugfix8144Spec.cs
index 06b5f2d4799..86eee5bf4c2 100644
--- a/src/contrib/testkits/Akka.TestKit.Xunit.Tests/Bugfix8144Spec.cs
+++ b/src/contrib/testkits/Akka.TestKit.Xunit.Tests/Bugfix8144Spec.cs
@@ -17,15 +17,12 @@ namespace Akka.TestKit.Tests;
/// Verifies that implicit sender (TestActor) is preserved across async boundaries.
/// Regression test for https://github.com/akkadotnet/akka.net/issues/8144
///
-public class Bugfix8144Spec: Xunit.TestKit, IAsyncLifetime
+public class Bugfix8144Spec: Xunit.TestKit
{
- public ValueTask DisposeAsync()
+ public override async ValueTask InitializeAsync()
{
- return new ValueTask(Task.CompletedTask);
- }
+ await base.InitializeAsync();
- public async ValueTask InitializeAsync()
- {
// Any await here can cause a thread switch, which previously
// lost the [ThreadStatic] InternalCurrentActorCellKeeper.Current
await Task.Delay(TimeSpan.FromMilliseconds(100));
diff --git a/src/contrib/testkits/Akka.TestKit.Xunit.Tests/Bugfix8191Spec.cs b/src/contrib/testkits/Akka.TestKit.Xunit.Tests/Bugfix8191Spec.cs
new file mode 100644
index 00000000000..bc9c56d076e
--- /dev/null
+++ b/src/contrib/testkits/Akka.TestKit.Xunit.Tests/Bugfix8191Spec.cs
@@ -0,0 +1,73 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2009-2022 Lightbend Inc.
+// Copyright (C) 2013-2026 .NET Foundation
+//
+// -----------------------------------------------------------------------
+
+using System;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Akka.TestKit.Xunit.Tests;
+
+///
+/// Regression tests for https://github.com/akkadotnet/akka.net/issues/8191
+///
+/// xUnit v3 tears a test class instance down via
+/// in preference to whenever the type implements both.
+/// must therefore drive its dispose chain — and shut the
+/// ActorSystem down — from DisposeAsync, and a derived DisposeAsync
+/// override must be able to chain to base.DisposeAsync(). Before the fix the
+/// ActorSystem was silently leaked once a derived spec implemented
+/// IAsyncLifetime.
+///
+public class Bugfix8191Spec
+{
+ private sealed class TrackingTestKit : TestKit
+ {
+ public bool AfterAllRan { get; private set; }
+
+ protected override void AfterAll()
+ {
+ AfterAllRan = true;
+ base.AfterAll();
+ }
+ }
+
+ private sealed class AsyncTeardownTestKit : TestKit
+ {
+ public bool DisposeAsyncOverrideRan { get; private set; }
+
+ public override async ValueTask DisposeAsync()
+ {
+ DisposeAsyncOverrideRan = true;
+ await base.DisposeAsync();
+ }
+ }
+
+ [Fact(DisplayName = "TestKit.DisposeAsync should run the dispose chain and shut down the ActorSystem")]
+ public async Task Should_run_dispose_chain_and_shut_down_system_When_disposed_via_DisposeAsync()
+ {
+ var testKit = new TrackingTestKit();
+ var system = testKit.Sys;
+
+ // Exercise the exact path xUnit v3 uses to tear down a test class instance.
+ await ((IAsyncDisposable)testKit).DisposeAsync();
+
+ Assert.True(testKit.AfterAllRan, "AfterAll() should run as part of the DisposeAsync chain");
+ Assert.True(system.WhenTerminated.IsCompleted, "the ActorSystem should be shut down");
+ }
+
+ [Fact(DisplayName = "A derived DisposeAsync override chaining to base should shut down the ActorSystem")]
+ public async Task Should_shut_down_system_When_derived_DisposeAsync_chains_to_base()
+ {
+ var testKit = new AsyncTeardownTestKit();
+ var system = testKit.Sys;
+
+ await ((IAsyncDisposable)testKit).DisposeAsync();
+
+ Assert.True(testKit.DisposeAsyncOverrideRan, "the derived DisposeAsync override should run");
+ Assert.True(system.WhenTerminated.IsCompleted, "base.DisposeAsync() should shut the ActorSystem down");
+ }
+}
diff --git a/src/contrib/testkits/Akka.TestKit.Xunit.Tests/ParallelAmbientContextSpec.cs b/src/contrib/testkits/Akka.TestKit.Xunit.Tests/ParallelAmbientContextSpec.cs
index bcc60b7298f..83a6e3fe82b 100644
--- a/src/contrib/testkits/Akka.TestKit.Xunit.Tests/ParallelAmbientContextSpec.cs
+++ b/src/contrib/testkits/Akka.TestKit.Xunit.Tests/ParallelAmbientContextSpec.cs
@@ -20,14 +20,12 @@ namespace Akka.TestKit.Xunit.Tests;
// This project provides its own xunit.runner.json with parallel collections
// enabled so CI and local runs exercise the reported failure mode by default.
-public abstract class ParallelAmbientContextSpecBase : TestKit, IAsyncLifetime
+public abstract class ParallelAmbientContextSpecBase : TestKit
{
// Forces the post-ctor continuation onto a different SC worker — the
// thread pollution only manifests when the body thread differs from the
// ctor thread.
- public async ValueTask InitializeAsync() => await Task.Yield();
-
- public ValueTask DisposeAsync() => default;
+ public override async ValueTask InitializeAsync() => await Task.Yield();
[Fact]
public async Task Implicit_sender_should_resolve_to_own_TestActor()
@@ -67,11 +65,9 @@ public class ParallelAmbientContextSpec16 : ParallelAmbientContextSpecBase { }
// Current must be null both at body entry (pre-await prefix) and across any
// await continuations resumed on a reused worker thread that a sibling's
// Before hook may have pinned with a non-null cell.
-public abstract class ParallelNoImplicitSenderSpecBase : TestKit, IAsyncLifetime, INoImplicitSender
+public abstract class ParallelNoImplicitSenderSpecBase : TestKit, INoImplicitSender
{
- public async ValueTask InitializeAsync() => await Task.Yield();
-
- public ValueTask DisposeAsync() => default;
+ public override async ValueTask InitializeAsync() => await Task.Yield();
[Fact]
public async Task Current_should_be_null_both_pre_and_post_await()
diff --git a/src/contrib/testkits/Akka.TestKit.Xunit/TestKit.cs b/src/contrib/testkits/Akka.TestKit.Xunit/TestKit.cs
index 213442df4c0..7ea0a8954df 100644
--- a/src/contrib/testkits/Akka.TestKit.Xunit/TestKit.cs
+++ b/src/contrib/testkits/Akka.TestKit.Xunit/TestKit.cs
@@ -6,6 +6,7 @@
//-----------------------------------------------------------------------
using System;
+using System.Threading.Tasks;
using Akka.Actor;
using Akka.Actor.Internal;
using Akka.Actor.Setup;
@@ -22,7 +23,7 @@ namespace Akka.TestKit.Xunit;
/// as its testing framework.
///
[AkkaCleanAmbientContext]
-public class TestKit : TestKitBase, IDisposable
+public class TestKit : TestKitBase, IDisposable, IAsyncLifetime
{
private class PrefixedOutput : ITestOutputHelper
{
@@ -84,6 +85,7 @@ public void WriteLine(string format, params object[] args)
private bool _disposed;
private bool _disposing;
+ private bool _disposingAsync;
///
///
@@ -249,6 +251,14 @@ protected void InitializeLogger(ActorSystem system, string prefix)
logger.Tell(new InitializeLogger(system.EventStream), ActorRefs.NoSender);
}
+ ///
+ /// xUnit lifecycle hook, invoked once before the test method runs. The default
+ /// implementation does nothing. Override to perform asynchronous test setup, and
+ /// call await base.InitializeAsync() from your override.
+ ///
+ public virtual ValueTask InitializeAsync()
+ => default;
+
///
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
///
@@ -270,7 +280,10 @@ protected virtual void Dispose(bool disposing)
}
finally
{
- Shutdown();
+ // DisposeAsync() terminates the ActorSystem asynchronously and sets this flag,
+ // so we don't also block here with a synchronous Shutdown().
+ if (!_disposingAsync)
+ Shutdown();
_disposed = true;
}
}
@@ -279,4 +292,37 @@ public void Dispose()
{
Dispose(true);
}
+
+ ///
+ /// xUnit lifecycle hook, invoked once after the test method completes. The default
+ /// implementation runs the synchronous dispose chain ( and
+ /// therefore ) and then asynchronously terminates the
+ /// , without blocking the calling thread.
+ ///
+ /// Override this for asynchronous teardown, and always call await base.DisposeAsync()
+ /// from your override. xUnit v3 invokes in
+ /// preference to for any type that implements both
+ /// interfaces, so an override that does not chain to the base will skip shutdown and leak
+ /// the .
+ ///
+ ///
+ public virtual async ValueTask DisposeAsync()
+ {
+ if (_disposing || _disposed)
+ return;
+
+ // Run the synchronous dispose chain — AfterAll() plus any overridden Dispose(bool) —
+ // but suppress its blocking Shutdown() call; the ActorSystem is terminated
+ // asynchronously below instead. The finally guarantees shutdown still runs even if
+ // AfterAll() throws, matching the synchronous Dispose() behavior.
+ _disposingAsync = true;
+ try
+ {
+ Dispose(true);
+ }
+ finally
+ {
+ await ShutdownAsync();
+ }
+ }
}
\ No newline at end of file
diff --git a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveTestKit.DotNet.verified.txt b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveTestKit.DotNet.verified.txt
index 8dd5a2fe34d..e1ad3712459 100644
--- a/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveTestKit.DotNet.verified.txt
+++ b/src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveTestKit.DotNet.verified.txt
@@ -405,17 +405,17 @@ namespace Akka.TestKit
public System.Collections.Generic.IReadOnlyCollection ExpectMsgAllOf(System.Collections.Generic.IReadOnlyCollection messages, System.Threading.CancellationToken cancellationToken = null) { }
public System.Collections.Generic.IReadOnlyCollection ExpectMsgAllOf(System.TimeSpan max, params T[] messages) { }
public System.Collections.Generic.IReadOnlyCollection ExpectMsgAllOf(System.TimeSpan max, System.Collections.Generic.IReadOnlyCollection messages, System.Threading.CancellationToken cancellationToken = null) { }
- [System.Runtime.CompilerServices.AsyncIteratorStateMachineAttribute(typeof(Akka.TestKit.TestKitBase.d__158))]
+ [System.Runtime.CompilerServices.AsyncIteratorStateMachineAttribute(typeof(Akka.TestKit.TestKitBase.d__160))]
public System.Collections.Generic.IAsyncEnumerable ExpectMsgAllOfAsync(System.Collections.Generic.IReadOnlyCollection messages, [System.Runtime.CompilerServices.EnumeratorCancellationAttribute()] System.Threading.CancellationToken cancellationToken = null) { }
- [System.Runtime.CompilerServices.AsyncIteratorStateMachineAttribute(typeof(Akka.TestKit.TestKitBase.d__161))]
+ [System.Runtime.CompilerServices.AsyncIteratorStateMachineAttribute(typeof(Akka.TestKit.TestKitBase.d__163))]
public System.Collections.Generic.IAsyncEnumerable ExpectMsgAllOfAsync(System.TimeSpan max, System.Collections.Generic.IReadOnlyCollection messages, [System.Runtime.CompilerServices.EnumeratorCancellationAttribute()] System.Threading.CancellationToken cancellationToken = null) { }
public System.Collections.Generic.IReadOnlyCollection