Skip to content
Merged
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
@@ -0,0 +1,100 @@
//-----------------------------------------------------------------------
// <copyright file="ActorCellKeepingSynchronizationContextSpec.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.TestKit;
using Xunit;

namespace Akka.TestKit.Xunit.Tests;

public class ActorCellKeepingSynchronizationContextSpec
{
[Fact]
public void Post_should_delegate_to_outer_synchronization_context()
{
var inner = new RecordingSynchronizationContext();
var wrapper = CreateWrapper(inner);
var state = new object();
object? callbackState = null;
SynchronizationContext? callbackContext = null;

wrapper.Post(s =>
{
callbackState = s;
callbackContext = SynchronizationContext.Current;
}, state);

Assert.Equal(1, inner.PostCalls);
Assert.Same(state, callbackState);
Assert.Same(wrapper, callbackContext);
}

[Fact]
public void Send_should_delegate_to_outer_synchronization_context()
{
var inner = new RecordingSynchronizationContext();
var wrapper = CreateWrapper(inner);
var state = new object();
object? callbackState = null;
SynchronizationContext? callbackContext = null;

wrapper.Send(s =>
{
callbackState = s;
callbackContext = SynchronizationContext.Current;
}, state);

Assert.Equal(1, inner.SendCalls);
Assert.Same(state, callbackState);
Assert.Same(wrapper, callbackContext);
}

[Fact]
public void CreateCopy_should_preserve_outer_synchronization_context()
{
var inner = new RecordingSynchronizationContext();
var wrapper = CreateWrapper(inner);
var copy = wrapper.CreateCopy();

copy.Post(_ => { }, null);
copy.Send(_ => { }, null);

Assert.Equal(1, inner.PostCalls);
Assert.Equal(1, inner.SendCalls);
}

private static SynchronizationContext CreateWrapper(SynchronizationContext? inner)
{
var wrapperType = typeof(TestKitBase).Assembly.GetType("Akka.TestKit.ActorCellKeepingSynchronizationContext", throwOnError: true)!;
return (SynchronizationContext)Activator.CreateInstance(
wrapperType,
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
binder: null,
args: [null, inner],
culture: null)!;
}

private sealed class RecordingSynchronizationContext : SynchronizationContext
{
public int PostCalls { get; private set; }
public int SendCalls { get; private set; }

public override void Post(SendOrPostCallback d, object? state)
{
PostCalls++;
d(state);
}

public override void Send(SendOrPostCallback d, object? state)
{
SendCalls++;
d(state);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../../../xunitSettings.props" />

<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();

public ValueTask DisposeAsync() => default;

[Fact]
public async Task Implicit_sender_should_resolve_to_own_TestActor()
{
// 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,
"parallelizeTestCollections": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//-----------------------------------------------------------------------
// <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>
//-----------------------------------------------------------------------

#nullable enable

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/>
/// Intended for xUnit v3 test kits built on <see cref="TestKitBase"/>.
/// Applied to <see cref="TestKit"/> (and inherited by derived test
/// classes) so users get parallel-safe behavior automatically, and can also
/// be applied by downstream test kits that derive directly from
/// <see cref="TestKitBase"/>. See
/// <see cref="ActorCellKeepingSynchronizationContext"/> for the underlying
/// mechanism and the ThreadStatic-vs-ExecutionContext rationale.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public sealed class AkkaCleanAmbientContextAttribute : BeforeAfterTestAttribute
{
private sealed record AmbientContextState
{
public required bool Applied { get; init; }
public SynchronizationContext? PreviousContext { get; init; }
public ActorCell? PreviousCell { get; init; }
}

// AsyncLocal flows across await boundaries via ExecutionContext, unlike [ThreadStatic].
// This is critical because xUnit v3's runner awaits the test body between Before() and After(),
// so After() can resume on a different thread than Before() ran on.
private static readonly AsyncLocal<AmbientContextState?> _state = new();

/// <inheritdoc/>
public override void Before(MethodInfo methodUnderTest, IXunitTest test)
{
var instance = TestContext.Current.TestClassInstance;
if (instance is not TestKitBase testKit)
{
_state.Value = new AmbientContextState
{
Applied = false,
PreviousContext = SynchronizationContext.Current,
PreviousCell = InternalCurrentActorCellKeeper.Current
};
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);

_state.Value = new AmbientContextState
{
Applied = true,
PreviousContext = SynchronizationContext.Current,
PreviousCell = InternalCurrentActorCellKeeper.Current
};

InternalCurrentActorCellKeeper.Current = cell;
SynchronizationContext.SetSynchronizationContext(
new ActorCellKeepingSynchronizationContext(cell, _state.Value.PreviousContext));
}

/// <inheritdoc/>
public override void After(MethodInfo methodUnderTest, IXunitTest test)
{
var state = _state.Value;
if (state is null || !state.Applied)
return;

InternalCurrentActorCellKeeper.Current = state.PreviousCell;
SynchronizationContext.SetSynchronizationContext(state.PreviousContext);
_state.Value = 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]
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.AssemblyMetadataAttribute("RepositoryUrl", "https://github.com/akkadotnet/akka.net")]
[assembly: System.Runtime.CompilerServices.InternalsVisibleToAttribute("Akka.TestKit.Xunit")]
[assembly: System.Runtime.InteropServices.ComVisibleAttribute(false)]
[assembly: System.Runtime.InteropServices.GuidAttribute("ae01b790-1478-4917-9299-b4855ba997cb")]
[assembly: System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v10.0", FrameworkDisplayName=".NET 10.0")]
Expand Down Expand Up @@ -849,4 +850,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.AssemblyMetadataAttribute("RepositoryUrl", "https://github.com/akkadotnet/akka.net")]
[assembly: System.Runtime.CompilerServices.InternalsVisibleToAttribute("Akka.TestKit.Xunit")]
[assembly: System.Runtime.InteropServices.ComVisibleAttribute(false)]
[assembly: System.Runtime.InteropServices.GuidAttribute("ae01b790-1478-4917-9299-b4855ba997cb")]
[assembly: System.Runtime.Versioning.TargetFrameworkAttribute(".NETStandard,Version=v2.0", FrameworkDisplayName=".NET Standard 2.0")]
Expand Down Expand Up @@ -849,4 +850,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; }
}
}
}
Loading
Loading