Skip to content

[xUnit 3] Fix parallel-class implicit-sender leak in Akka.TestKit.Xunit#8174

Closed
Arkatufus wants to merge 10 commits into
akkadotnet:devfrom
Arkatufus:xunit-3-implicit-sender-leak-D2
Closed

[xUnit 3] Fix parallel-class implicit-sender leak in Akka.TestKit.Xunit#8174
Arkatufus wants to merge 10 commits into
akkadotnet:devfrom
Arkatufus:xunit-3-implicit-sender-leak-D2

Conversation

@Arkatufus
Copy link
Copy Markdown
Contributor

Problem

Under xUnit v3 parallel-class scheduling, MaxConcurrencySyncContext dispatches test ctors and bodies onto a dedicated pool of reused threads. TestKitBase's constructor pins InternalCurrentActorCellKeeper.Current — a [ThreadStatic] — on its ctor thread. When a sibling test's body lands on that same reused thread, the pre-await synchronous prefix of the [Fact] reads the sibling's cell as its implicit sender, causing Tell() to use the wrong Sender. Replies then cross ActorSystem boundaries and land in the wrong TestActor's mailbox.

Originally reported in https://github.com/akkadotnet/Akka.Hosting by a user running tests with parallelizeTestCollections: true.

Fix

Add a [BeforeAfterTestAttribute] that runs synchronously on the test-body thread and:

  • Before: pins InternalCurrentActorCellKeeper.Current to the running test's TestActor cell (or null for INoImplicitSender), and installs ActorCellKeepingSynchronizationContext so await continuations re-pin the cell across worker-thread switches.
  • After: clears Current so the reused worker doesn't carry the cell into the next test.

Applied to Akka.TestKit.Xunit.TestKit with Inherited = true, so derived test classes (including Akka.Hosting.TestKit downstream) get parallel-safe behavior automatically via attribute inheritance.

Changes

  • Akka.TestKit/ActorCellKeepingSynchronizationContext.cs — promoted from internal to [InternalApi] public so the new attribute can install an equivalent wrapper without duplicating the save/pin/restore logic. All TBD XML docs replaced with real documentation.
  • Akka.TestKit.Xunit/Attributes/AkkaCleanAmbientContextAttribute.cs — new BeforeAfterTestAttribute. Uses TestContext.Current.TestClassInstance for direct instance access (xUnit v3 only).
  • Akka.TestKit.Xunit/TestKit.cs — decorated with [AkkaCleanAmbientContext].

Regression tests

  • ParallelAmbientContextSpec — 16 + 8 sibling test classes (distinct top-level classes so xUnit schedules them in separate collections). Verified: fails 15/16 without the attribute, passes all with it. Marked [LocalFact] because the shared src/xunit.runner.json disables parallelization — exercising the bug requires running with xUnit.ParallelizeTestCollections=true:
    dotnet test --filter "FullyQualifiedName~ParallelAmbientContext" \
        -- xUnit.ParallelizeAssembly=true \
           xUnit.ParallelizeTestCollections=true
    
  • AkkaCleanAmbientContextAttributeSpec — reflection guards (always run) that the attribute is declared on TestKit, is inherited by derived classes, and has AttributeUsage.Inherited == true.

Scope

  • xUnit v3 only (Akka.TestKit.Xunit). Akka.TestKit.Xunit2 is untouched — the bug is specific to xUnit v3's MaxConcurrencySyncContext thread-reuse semantics.
  • No behavioral changes to Akka.TestKit.ActorCellKeepingSynchronizationContext — only visibility + docs.
  • No changes to Akka core (Akka.dll).

Validation

  • Akka.TestKit.Xunit.Tests: 31/31 pass (all three frameworks).
  • End-to-end via Akka.Hosting parallel repro (montrose issue): 15/15 pass 5× consecutively; fails without the attribute.

Under xUnit v3 parallel-class scheduling, MaxConcurrencySyncContext
dispatches test ctors and bodies onto a dedicated pool of reused threads.
TestKitBase's constructor pins InternalCurrentActorCellKeeper.Current
(a ThreadStatic) on its ctor thread; when a sibling test's body lands on
that same reused thread, the pre-await synchronous prefix of the [Fact]
reads the sibling's cell as its implicit sender, causing Tell() to use
the wrong Sender. Replies then cross ActorSystem boundaries.

Fix: add a BeforeAfterTestAttribute that runs synchronously on the test-
body thread and (a) pins Current to the running test's TestActor cell,
(b) installs ActorCellKeepingSynchronizationContext so await continuations
also re-pin the cell. After the test, Current is cleared so the reused
worker doesn't carry the cell into the next test.

Applied to Akka.TestKit.Xunit.TestKit so derived test classes (including
Akka.Hosting.TestKit downstream) get parallel-safe behavior automatically
via attribute inheritance.

ActorCellKeepingSynchronizationContext promoted to [InternalApi] public
so the attribute can install an equivalent wrapper without duplicating
the save/pin/restore logic. TBD XML docs replaced with real documentation.

Regression tests:
- ParallelAmbientContextSpec: 16 + 8 sibling test classes asserting
  implicit-sender correctness and INoImplicitSender == null across awaits.
  Marked [LocalFact] — requires xUnit.ParallelizeTestCollections=true to
  exercise the bug (disabled in the shared src/xunit.runner.json).
- AkkaCleanAmbientContextAttributeSpec: reflection guards that the
  attribute is declared on the base TestKit, is inherited by subclasses,
  and has Inherited=true in its AttributeUsage.
Copy link
Copy Markdown
Contributor Author

@Arkatufus Arkatufus left a comment

Choose a reason for hiding this comment

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

self-review

/// 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.

/// 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.

Comment thread src/core/Akka.TestKit/ActorCellKeepingSynchronizationContext.cs Outdated
Arkatufus and others added 6 commits April 23, 2026 04:12
@Aaronontheweb Aaronontheweb added akka-testkit Akka.NET Testkit issues confirmed bug labels Apr 23, 2026
Copy link
Copy Markdown
Member

@Aaronontheweb Aaronontheweb left a comment

Choose a reason for hiding this comment

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

Generally looks good but have some nitpicks

@@ -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.

// 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


// 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.

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.

{
"$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.

Stop swallowing NullReferenceException when reading TestActor and drop the reflection-only attribute spec in favor of the parallel behavior tests that now run in CI.
Copy link
Copy Markdown
Member

@Aaronontheweb Aaronontheweb left a comment

Choose a reason for hiding this comment

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

LGTM - addressed my requested changes from the previous review.

Copy link
Copy Markdown
Member

@Aaronontheweb Aaronontheweb left a comment

Choose a reason for hiding this comment

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

Looks like this approach may not work at all for the Hosting.TestKit, so I'm investigating that separately.

@Aaronontheweb
Copy link
Copy Markdown
Member

Related: Wrapping SynchronizationContext for Hosting Compatibility

PR #8182 builds on this PR's ActorCellKeepingSynchronizationContext + AkkaCleanAmbientContextAttribute to support wrapping the outer SynchronizationContext rather than replacing it. This is needed for consumers like Akka.Hosting.TestKit whose IHost async startup depends on xUnit v3's MaxConcurrencySyncContext scheduling — raw ThreadPool.QueueUserWorkItem dispatch causes hangs.

The wrapping approach:

  • Captures SynchronizationContext.Current in Before() and passes it as an _inner SC to ActorCellKeepingSynchronizationContext
  • Post()/Send() delegate scheduling to the inner SC (preserving xUnit's worker-thread management) while wrapping callbacks with the cell-pinning save/restore window
  • Falls back to ThreadPool dispatch when no outer SC exists (identical to the current behavior)

Proven in Akka.Hosting PR akkadotnet/Akka.Hosting#735 — 303/303 tests pass under real xUnit v3 parallel class execution.

See issue akkadotnet/Akka.Hosting#733 for the original bug report.

@Aaronontheweb
Copy link
Copy Markdown
Member

superseded via #8182

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

akka-testkit Akka.NET Testkit issues confirmed bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants