Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand All @@ -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; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// </summary>
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));
Expand Down
73 changes: 73 additions & 0 deletions src/contrib/testkits/Akka.TestKit.Xunit.Tests/Bugfix8191Spec.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// -----------------------------------------------------------------------
// <copyright file="Bugfix8191Spec.cs" company="Akka.NET Project">
// Copyright (C) 2009-2022 Lightbend Inc. <http://www.lightbend.com>
// Copyright (C) 2013-2026 .NET Foundation <https://github.com/akkadotnet/akka.net>
// </copyright>
// -----------------------------------------------------------------------

using System;
using System.Threading.Tasks;
using Xunit;

namespace Akka.TestKit.Xunit.Tests;

/// <summary>
/// Regression tests for https://github.com/akkadotnet/akka.net/issues/8191
///
/// xUnit v3 tears a test class instance down via <see cref="IAsyncDisposable.DisposeAsync"/>
/// in preference to <see cref="IDisposable.Dispose"/> whenever the type implements both.
/// <see cref="TestKit"/> must therefore drive its dispose chain — and shut the
/// <c>ActorSystem</c> down — from <c>DisposeAsync</c>, and a derived <c>DisposeAsync</c>
/// override must be able to chain to <c>base.DisposeAsync()</c>. Before the fix the
/// <c>ActorSystem</c> was silently leaked once a derived spec implemented
/// <c>IAsyncLifetime</c>.
/// </summary>
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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
50 changes: 48 additions & 2 deletions src/contrib/testkits/Akka.TestKit.Xunit/TestKit.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//-----------------------------------------------------------------------

using System;
using System.Threading.Tasks;
using Akka.Actor;
using Akka.Actor.Internal;
using Akka.Actor.Setup;
Expand All @@ -22,7 +23,7 @@ namespace Akka.TestKit.Xunit;
/// as its testing framework.
/// </summary>
[AkkaCleanAmbientContext]
public class TestKit : TestKitBase, IDisposable
public class TestKit : TestKitBase, IDisposable, IAsyncLifetime

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the primary change here - ensure that the TestKit comports with xUnit3's IAsyncLifetime.

{
private class PrefixedOutput : ITestOutputHelper
{
Expand Down Expand Up @@ -84,6 +85,7 @@ public void WriteLine(string format, params object[] args)

private bool _disposed;
private bool _disposing;
private bool _disposingAsync;

/// <summary>
/// <para>
Expand Down Expand Up @@ -249,6 +251,14 @@ protected void InitializeLogger(ActorSystem system, string prefix)
logger.Tell(new InitializeLogger(system.EventStream), ActorRefs.NoSender);
}

/// <summary>
/// xUnit lifecycle hook, invoked once before the test method runs. The default
/// implementation does nothing. Override to perform asynchronous test setup, and
/// call <c>await base.InitializeAsync()</c> from your override.
/// </summary>
public virtual ValueTask InitializeAsync()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so we have to do some sync-over-async stuff in the Initialize / Dispose methods in the TestKit today (such as terminating the ActorSystem) - if we could make that natively async that would be great, but I suspect that might be too destructive of a change to make backwards compatible. Might just be a matter of re-arranging the base class calls though. Let me look into that.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c142966. Added TestKitBase.ShutdownAsync — an async counterpart to Shutdown() that awaits ActorSystem termination via Task.WhenAny(Terminate(), Task.Delay(timeout)) instead of Terminate().Wait(timeout). It's purely additive; the synchronous Shutdown() is untouched.

DisposeAsync() now runs the synchronous dispose chain — Dispose(bool), and therefore AfterAll() plus any derived Dispose(bool) override — with the blocking Shutdown() call suppressed via a flag, then awaits ShutdownAsync(). So the dispose-pattern extensibility point still fires (no silent bypass of a Dispose(bool) override) and AfterAll() still runs while the system is alive, but the actual ActorSystem teardown no longer blocks the xUnit teardown thread.

The constructor side is still synchronous — the ActorSystem is created in the ctor, which can't be made async — so there's no InitializeAsync sync-over-async to remove there; the base InitializeAsync() is just an empty hook.

Heads-up: this touches TestKitBase, so the ApproveTestKit API baselines (.DotNet + .Net) are regenerated in the same commit for the two new ShutdownAsync overloads.

=> default;

/// <summary>
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
/// </summary>
Expand All @@ -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;
}
}
Expand All @@ -279,4 +292,37 @@ public void Dispose()
{
Dispose(true);
}

/// <summary>
/// xUnit lifecycle hook, invoked once after the test method completes. The default
/// implementation runs the synchronous dispose chain (<see cref="Dispose(bool)"/> and
/// therefore <see cref="AfterAll"/>) and then asynchronously terminates the
/// <see cref="ActorSystem"/>, without blocking the calling thread.
/// <para>
/// Override this for asynchronous teardown, and always call <c>await base.DisposeAsync()</c>
/// from your override. xUnit v3 invokes <see cref="System.IAsyncDisposable.DisposeAsync"/> in
/// preference to <see cref="IDisposable.Dispose"/> for any type that implements both
/// interfaces, so an override that does not chain to the base will skip shutdown and leak
/// the <see cref="ActorSystem"/>.
/// </para>
/// </summary>
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();
}
}
}
Loading
Loading