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
@@ -1,6 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../../../xunitSettings.props" />
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Going to use a unique, project-specific xunit settings file for this so we can enable test parallelism and see about reproducing issues like akkadotnet/Akka.Hosting#733 in CI/CD.


<PropertyGroup>
<TargetFrameworks>$(NetFrameworkTestVersion);$(NetTestVersion)</TargetFrameworks>
<OutputType>Exe</OutputType>
Expand All @@ -27,4 +25,10 @@
<ProjectReference Include="..\Akka.TestKit.Xunit\Akka.TestKit.Xunit.csproj" />
</ItemGroup>

<ItemGroup>
<None Update="xunit.runner.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//-----------------------------------------------------------------------
// <copyright file="ParallelAmbientContextSpec.cs" company="Akka.NET Project">
// Copyright (C) 2009-2022 Lightbend Inc. <http://www.lightbend.com>
// Copyright (C) 2013-2025 .NET Foundation <https://github.com/akkadotnet/akka.net>
// </copyright>
//-----------------------------------------------------------------------

using System;
using System.Threading.Tasks;
using Akka.Actor;
using Akka.Actor.Internal;
using Akka.TestKit.Xunit.Attributes;
using Xunit;

namespace Akka.TestKit.Xunit.Tests;

// Regression guard for the xUnit v3 parallel-class implicit-sender leak
// (see ActorCellKeepingSynchronizationContext for the mechanism).
//
// 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
{
// 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();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM


public ValueTask DisposeAsync() => default;

[Fact]
public async Task Implicit_sender_should_resolve_to_own_TestActor()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

{
// Pre-await prefix — before the fix, this window read whatever cell a
// sibling ctor pinned on the body thread.
TestActor.Tell("ping");
await ExpectMsgAsync<string>("ping", TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(TestActor, LastSender);

await Task.Yield();
TestActor.Tell("ping-after-yield");
await ExpectMsgAsync<string>("ping-after-yield", TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(TestActor, LastSender);
}
}

public class ParallelAmbientContextSpec01 : ParallelAmbientContextSpecBase { }
public class ParallelAmbientContextSpec02 : ParallelAmbientContextSpecBase { }
public class ParallelAmbientContextSpec03 : ParallelAmbientContextSpecBase { }
public class ParallelAmbientContextSpec04 : ParallelAmbientContextSpecBase { }
public class ParallelAmbientContextSpec05 : ParallelAmbientContextSpecBase { }
public class ParallelAmbientContextSpec06 : ParallelAmbientContextSpecBase { }
public class ParallelAmbientContextSpec07 : ParallelAmbientContextSpecBase { }
public class ParallelAmbientContextSpec08 : ParallelAmbientContextSpecBase { }
public class ParallelAmbientContextSpec09 : ParallelAmbientContextSpecBase { }
public class ParallelAmbientContextSpec10 : ParallelAmbientContextSpecBase { }
public class ParallelAmbientContextSpec11 : ParallelAmbientContextSpecBase { }
public class ParallelAmbientContextSpec12 : ParallelAmbientContextSpecBase { }
public class ParallelAmbientContextSpec13 : ParallelAmbientContextSpecBase { }
public class ParallelAmbientContextSpec14 : ParallelAmbientContextSpecBase { }
public class ParallelAmbientContextSpec15 : ParallelAmbientContextSpecBase { }
public class ParallelAmbientContextSpec16 : ParallelAmbientContextSpecBase { }

// Regression test for INoImplicitSender under the same xUnit v3 parallel
// scheduling. INoImplicitSender tests contractually have no implicit sender —
// 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 async ValueTask InitializeAsync() => await Task.Yield();

public ValueTask DisposeAsync() => default;

[Fact]
public async Task Current_should_be_null_both_pre_and_post_await()
{
// Invariant: body must enter with Current == null.
Assert.Null(InternalCurrentActorCellKeeper.Current);

// Force continuation onto a potentially polluted worker.
await Task.Yield();
Assert.Null(InternalCurrentActorCellKeeper.Current);

await Task.Yield();
Assert.Null(InternalCurrentActorCellKeeper.Current);
}
}

public class ParallelNoImplicitSenderSpec01 : ParallelNoImplicitSenderSpecBase { }
public class ParallelNoImplicitSenderSpec02 : ParallelNoImplicitSenderSpecBase { }
public class ParallelNoImplicitSenderSpec03 : ParallelNoImplicitSenderSpecBase { }
public class ParallelNoImplicitSenderSpec04 : ParallelNoImplicitSenderSpecBase { }
public class ParallelNoImplicitSenderSpec05 : ParallelNoImplicitSenderSpecBase { }
public class ParallelNoImplicitSenderSpec06 : ParallelNoImplicitSenderSpecBase { }
public class ParallelNoImplicitSenderSpec07 : ParallelNoImplicitSenderSpecBase { }
public class ParallelNoImplicitSenderSpec08 : ParallelNoImplicitSenderSpecBase { }
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"$schema": "https://xunit.github.io/schema/current/xunit.runner.schema.json",
"longRunningTestSeconds": 60,
"parallelizeAssembly": true,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ensures we run the Akka.TestKit.Xunit.Tests assembly in parallel to try to get some CI/CD coverage over the test isolation guarantees.

"parallelizeTestCollections": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//-----------------------------------------------------------------------
// <copyright file="AkkaCleanAmbientContextAttribute.cs" company="Akka.NET Project">
// Copyright (C) 2009-2022 Lightbend Inc. <http://www.lightbend.com>
// Copyright (C) 2013-2025 .NET Foundation <https://github.com/akkadotnet/akka.net>
// </copyright>
//-----------------------------------------------------------------------

using System;
using System.Reflection;
using System.Threading;
using Akka.Actor;
using Akka.Actor.Internal;
using Xunit;
using Xunit.v3;

namespace Akka.TestKit.Xunit.Attributes;

/// <summary>
/// Makes a test class parallel-safe under xUnit v3's parallel-collection
/// scheduling by pinning <see cref="InternalCurrentActorCellKeeper.Current"/>
/// to the running test's TestActor cell on the body thread, and installing
/// an <see cref="ActorCellKeepingSynchronizationContext"/> that re-pins the
/// cell across <c>await</c> continuations.
/// <para/>
/// Applied to <see cref="TestKit"/> (and inherited by derived test
/// classes) so users get parallel-safe behavior automatically. See
/// <see cref="ActorCellKeepingSynchronizationContext"/> for the underlying
/// mechanism and the ThreadStatic-vs-ExecutionContext rationale.
/// </summary>
[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public sealed class AkkaCleanAmbientContextAttribute : BeforeAfterTestAttribute
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The fix. Since xUnit 3 can switch thread executor/context between the constructor, InitializeAsync() invocation, and the actual test method invocation, we pin the test actor into the ActorCellKeepingSynchronizationContext right before the test starts and clear it after when it is being teared down.

BeforeAfterTestAttribute is a new attribute in xUnit 3 for test setup and tear down.

{
/// <inheritdoc/>
public override void Before(MethodInfo methodUnderTest, IXunitTest test)
{
var instance = TestContext.Current.TestClassInstance;
if (instance is not TestKitBase testKit)
return;

// Null cell for INoImplicitSender mirrors TestKitBase.InitializeTest:
// the Post wrapper will pin Current = null so no sibling cell leaks in.
var cell = testKit is INoImplicitSender ? null : TryGetCell(testKit);

InternalCurrentActorCellKeeper.Current = cell;
SynchronizationContext.SetSynchronizationContext(new ActorCellKeepingSynchronizationContext(cell));
}

/// <inheritdoc/>
public override void After(MethodInfo methodUnderTest, IXunitTest test)
{
InternalCurrentActorCellKeeper.Current = null;
}

private static ActorCell? TryGetCell(TestKitBase testKit)
{
return testKit.TestActor is ActorRefWithCell withCell
? withCell.Underlying as ActorCell
: null;
}
}
2 changes: 2 additions & 0 deletions src/contrib/testkits/Akka.TestKit.Xunit/TestKit.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using Akka.Actor.Setup;
using Akka.Configuration;
using Akka.Event;
using Akka.TestKit.Xunit.Attributes;
using Akka.TestKit.Xunit.Internals;
using Xunit;

Expand All @@ -20,6 +21,7 @@ namespace Akka.TestKit.Xunit;
/// This class represents an Akka.NET TestKit that uses <a href="https://xunit.github.io/">xUnit</a>
/// as its testing framework.
/// </summary>
[AkkaCleanAmbientContext]
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Mark TestKit with the new AkkaCleanAmbientContext. This attribute is marked as Inherited so all classes that inherits TestKit will automatically have this attribute.

public class TestKit : TestKitBase, IDisposable
{
private class PrefixedOutput : ITestOutputHelper
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/akkadotnet/akka.net")]
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Akka.TestKit.Xunit")]
[assembly: System.Runtime.InteropServices.ComVisible(false)]
[assembly: System.Runtime.InteropServices.Guid("ae01b790-1478-4917-9299-b4855ba997cb")]
[assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v10.0", FrameworkDisplayName=".NET 10.0")]
Expand Down Expand Up @@ -839,4 +840,4 @@ namespace Akka.TestKit.TestEvent
public Unmute(System.Collections.Generic.IReadOnlyCollection<Akka.TestKit.Internal.EventFilterBase> filters) { }
public System.Collections.Generic.IReadOnlyCollection<Akka.TestKit.Internal.EventFilterBase> Filters { get; }
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
[assembly: System.Reflection.AssemblyMetadata("RepositoryUrl", "https://github.com/akkadotnet/akka.net")]
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Akka.TestKit.Xunit")]
[assembly: System.Runtime.InteropServices.ComVisible(false)]
[assembly: System.Runtime.InteropServices.Guid("ae01b790-1478-4917-9299-b4855ba997cb")]
[assembly: System.Runtime.Versioning.TargetFramework(".NETStandard,Version=v2.0", FrameworkDisplayName=".NET Standard 2.0")]
Expand Down Expand Up @@ -839,4 +840,4 @@ namespace Akka.TestKit.TestEvent
public Unmute(System.Collections.Generic.IReadOnlyCollection<Akka.TestKit.Internal.EventFilterBase> filters) { }
public System.Collections.Generic.IReadOnlyCollection<Akka.TestKit.Internal.EventFilterBase> Filters { get; }
}
}
}
59 changes: 45 additions & 14 deletions src/core/Akka.TestKit/ActorCellKeepingSynchronizationContext.cs
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These are just TBD cleanup, which is appreciated.

Original file line number Diff line number Diff line change
@@ -1,42 +1,71 @@
//-----------------------------------------------------------------------
//-----------------------------------------------------------------------
// <copyright file="ActorCellKeepingSynchronizationContext.cs" company="Akka.NET Project">
// Copyright (C) 2009-2022 Lightbend Inc. <http://www.lightbend.com>
// Copyright (C) 2013-2025 .NET Foundation <https://github.com/akkadotnet/akka.net>
// </copyright>
//-----------------------------------------------------------------------

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Akka.Actor;
using Akka.Actor.Internal;
using Akka.Annotations;

namespace Akka.TestKit
{
/// <summary>
/// TBD
/// INTERNAL API.
/// <para/>
/// A <see cref="SynchronizationContext"/> used by the test kits to pin
/// the ambient <see cref="InternalCurrentActorCellKeeper.Current"/>
/// <see cref="ActorCell"/> across <c>await</c> continuations that
/// originate from a test body.
/// <para/>
/// <see cref="InternalCurrentActorCellKeeper.Current"/> is a
/// <see cref="ThreadStaticAttribute"/> slot — it does not flow through
/// <see cref="System.Threading.ExecutionContext"/>. When a test awaits,
/// the continuation can resume on an arbitrary <see cref="ThreadPool"/>
/// thread whose <see cref="ThreadStaticAttribute"/> slot is either empty
/// or polluted by unrelated work. Installing this SC on the test-body
/// thread causes every posted continuation to run inside a
/// save/pin/restore window, so the test's cell is visible to
/// <c>IActorRef.Tell(message)</c> implicit-sender resolution and
/// to anything else reading <see cref="InternalCurrentActorCellKeeper.Current"/>
/// from the continuation.
/// <para/>
/// Not intended for use outside the test kits.
/// </summary>
class ActorCellKeepingSynchronizationContext : SynchronizationContext
[InternalApi]
internal class ActorCellKeepingSynchronizationContext : SynchronizationContext
{
private readonly ActorCell _cell;

/// <summary>
/// TBD
/// Creates a new <see cref="ActorCellKeepingSynchronizationContext"/>
/// that pins the given <paramref name="cell"/> as
/// <see cref="InternalCurrentActorCellKeeper.Current"/> for the
/// duration of every callback posted through it.
/// </summary>
/// <param name="cell">TBD</param>
/// <param name="cell">
/// The <see cref="ActorCell"/> to pin as the ambient current cell,
/// or <see langword="null"/> to pin "no implicit sender" (mirrors
/// the <see cref="INoImplicitSender"/> branch of
/// <see cref="TestKitBase.InitializeTest(ActorSystem, Akka.Actor.Setup.ActorSystemSetup, string, string)"/>).
/// </param>
public ActorCellKeepingSynchronizationContext(ActorCell cell)
{
_cell = cell;
}

/// <summary>
/// TBD
/// Queues the given callback to run on the <see cref="ThreadPool"/>
/// with <see cref="InternalCurrentActorCellKeeper.Current"/> pinned
/// to the cell this SC was constructed with, then restores the
/// previously pinned value when the callback returns.
/// </summary>
/// <param name="d">TBD</param>
/// <param name="state">TBD</param>
/// <param name="d">The delegate to invoke.</param>
/// <param name="state">The state object to pass to <paramref name="d"/>.</param>
public override void Post(SendOrPostCallback d, object state)
{
ThreadPool.QueueUserWorkItem(_ =>
Expand All @@ -60,10 +89,12 @@ public override void Post(SendOrPostCallback d, object state)
}

/// <summary>
/// TBD
/// Synchronously dispatches the given callback through
/// <see cref="Post(SendOrPostCallback, object)"/>, blocking the
/// calling thread until the callback completes or throws.
/// </summary>
/// <param name="d">TBD</param>
/// <param name="state">TBD</param>
/// <param name="d">The delegate to invoke.</param>
/// <param name="state">The state object to pass to <paramref name="d"/>.</param>
public override void Send(SendOrPostCallback d, object state)
{
var tcs = new TaskCompletionSource<int>();
Expand Down
2 changes: 2 additions & 0 deletions src/core/Akka.TestKit/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//-----------------------------------------------------------------------

using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

// General Information about an assembly is controlled through the following
Expand All @@ -19,3 +20,4 @@

// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("ae01b790-1478-4917-9299-b4855ba997cb")]
[assembly: InternalsVisibleTo("Akka.TestKit.Xunit")]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Make Akka.TestKit.Xunit a friend assembly - that's preferable to exposing more things on the public API.

Loading