fix: resolve implicit-sender leak under xUnit v3 parallel execution#735
Merged
Aaronontheweb merged 7 commits intoMay 17, 2026
Merged
Conversation
…kkadotnet#733) Add a wrapping SynchronizationContext that preserves xUnit's MaxConcurrencySyncContext scheduling while pinning InternalCurrentActorCellKeeper.Current across await continuations. A new BeforeAfterTestAttribute captures the active SC before each test, installs the decorator, and restores it afterward. This avoids the hang caused by replacing xUnit's SC with raw ThreadPool dispatch. Also guards EnsureImplicitSender with a Current == null check to prevent overwriting the cell during actor message processing, and removes two redundant cell-pinning writes that are now handled by the attribute. Parallel test collections enabled; 24 regression tests added.
Prevent After() from wiping the thread's SynchronizationContext when Before() skipped because the test class is not a TestKitBase subclass.
The assembly-level CollectionBehavior attribute was overriding xunit.runner.json and forcing all tests to run sequentially.
ffddf8d to
cfba21d
Compare
Arkatufus
suggested changes
Apr 24, 2026
xUnit v3's runner awaits the test body between Before() and After(), so After() can resume on a different OS thread. ThreadStatic fields set in Before() are invisible on the new thread, causing After() to silently skip cleanup. AsyncLocal flows via ExecutionContext across await boundaries, ensuring correct save/restore regardless of thread.
Aaronontheweb
added a commit
to akkadotnet/akka.net
that referenced
this pull request
May 7, 2026
…tionContext (#8182) * [xUnit 3] Fix parallel-class implicit-sender leak in Akka.TestKit.Xunit 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. * Update API approval list * Keep xUnit ambient context plumbing internal Keep ActorCellKeepingSynchronizationContext internal via friend assembly access and run Akka.TestKit.Xunit.Tests with parallel collections so CI exercises the implicit-sender leak regression by default. * Remove exception-driven ambient context checks 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. * fix: wrap outer SynchronizationContext instead of replacing it ActorCellKeepingSynchronizationContext now accepts an optional inner SynchronizationContext and delegates Post/Send scheduling to it while wrapping callbacks with the cell-pinning save/restore window. When no inner SC exists (the default), behavior is identical to before. AkkaCleanAmbientContextAttribute captures the active SC in Before() and passes it as the inner SC, then restores it in After(). This preserves xUnit v3's MaxConcurrencySyncContext scheduling, which downstream consumers like Akka.Hosting.TestKit depend on for async IHost lifecycle. Without this, applying the attribute to Hosting's TestKit causes test hangs. See akkadotnet/Akka.Hosting#735 and akkadotnet/Akka.Hosting#733. * fix: use AsyncLocal instead of ThreadStatic in BeforeAfterTestAttribute xUnit v3's runner awaits the test body between Before() and After(), so After() can resume on a different OS thread. ThreadStatic fields set in Before() are invisible on the new thread, causing After() to silently skip cleanup. AsyncLocal flows via ExecutionContext across await boundaries, ensuring correct save/restore regardless of thread. * fix: harden xUnit ambient context reuse * refactor: convert AmbientContextState to record Aligns with project style guidance (sealed classes and records as default for immutable data carriers). Behavior unchanged — instance is only stored/retrieved through AsyncLocal, never compared. Per review feedback on PR #8182. --------- Co-authored-by: Gregorius Soedharmo <arkatufus@yahoo.com>
3 tasks
Aaronontheweb
added a commit
to akkadotnet/akka.net
that referenced
this pull request
May 8, 2026
…tionContext (#8182) * [xUnit 3] Fix parallel-class implicit-sender leak in Akka.TestKit.Xunit 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. * Update API approval list * Keep xUnit ambient context plumbing internal Keep ActorCellKeepingSynchronizationContext internal via friend assembly access and run Akka.TestKit.Xunit.Tests with parallel collections so CI exercises the implicit-sender leak regression by default. * Remove exception-driven ambient context checks 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. * fix: wrap outer SynchronizationContext instead of replacing it ActorCellKeepingSynchronizationContext now accepts an optional inner SynchronizationContext and delegates Post/Send scheduling to it while wrapping callbacks with the cell-pinning save/restore window. When no inner SC exists (the default), behavior is identical to before. AkkaCleanAmbientContextAttribute captures the active SC in Before() and passes it as the inner SC, then restores it in After(). This preserves xUnit v3's MaxConcurrencySyncContext scheduling, which downstream consumers like Akka.Hosting.TestKit depend on for async IHost lifecycle. Without this, applying the attribute to Hosting's TestKit causes test hangs. See akkadotnet/Akka.Hosting#735 and akkadotnet/Akka.Hosting#733. * fix: use AsyncLocal instead of ThreadStatic in BeforeAfterTestAttribute xUnit v3's runner awaits the test body between Before() and After(), so After() can resume on a different OS thread. ThreadStatic fields set in Before() are invisible on the new thread, causing After() to silently skip cleanup. AsyncLocal flows via ExecutionContext across await boundaries, ensuring correct save/restore regardless of thread. * fix: harden xUnit ambient context reuse * refactor: convert AmbientContextState to record Aligns with project style guidance (sealed classes and records as default for immutable data carriers). Behavior unchanged — instance is only stored/retrieved through AsyncLocal, never compared. Per review feedback on PR #8182. --------- Co-authored-by: Gregorius Soedharmo <arkatufus@yahoo.com> (cherry picked from commit 515a266)
…ET 1.5.68 Akka.NET v1.5.68 (PR #8182) made ActorCellKeepingSynchronizationContext a proper decorator that wraps the outer SynchronizationContext instead of replacing it with raw ThreadPool dispatch, and added AkkaCleanAmbientContextAttribute to Akka.TestKit.Xunit as a public, inheritable BeforeAfterTestAttribute. Switch Akka.Hosting.TestKit to the upstream attribute: - Delete custom ActorCellKeepingSynchronizationContext (now covered upstream) - Delete custom HostingCleanAmbientContextAttribute - Apply [AkkaCleanAmbientContext] (Akka.TestKit.Xunit.Attributes) on TestKit base - Restore the TestEventListener registration guard in TestKit.Shared.cs to prevent the serialization rebuild race on CI (regression vs dev introduced on this branch) - Bump AkkaVersion to 1.5.68
…pat-cleanup # Conflicts: # Directory.Build.props
…attribute TestKit class is now decorated with [AkkaCleanAmbientContext] from Akka.TestKit.Xunit.Attributes, which changes the public API surface captured by the Verify snapshot test.
This was referenced May 18, 2026
Merged
This was referenced May 19, 2026
Open
Open
This was referenced May 21, 2026
Closed
Open
This was referenced May 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #733 —
Akka.Hosting.TestKitimplicit-sender leaks acrossActorSystems under xUnit v3 parallel class execution.Problem
InternalCurrentActorCellKeeper.Currentis[ThreadStatic]and does not flow throughExecutionContext. Under xUnit v3's default parallel-class scheduling (MaxConcurrencySyncContext),awaitcontinuations resume on ThreadPool threads where a sibling test pinned its cell, causingTell()to use the wrong implicit sender. Replies then crossActorSystemboundaries and land in the wrongTestActor's mailbox.Root Cause of Prior Spike Hang
The akka.net PR #8174 approach (
AkkaCleanAmbientContextAttribute+ActorCellKeepingSynchronizationContext) replaces xUnit'sSynchronizationContextwith one that dispatches via rawThreadPool.QueueUserWorkItem. This works forAkka.TestKit.Xunitbut hangsAkka.Hosting.TestKitbecause Hosting'sIHostasync startup lifecycle depends on xUnit'sMaxConcurrencySyncContextscheduling.Fix
A wrapping/decorating
SynchronizationContextthat preserves xUnit's scheduler while pinning the ambient actor cell acrossawaitcontinuations:ActorCellKeepingSynchronizationContext— delegatesPost()/Send()to the captured outer SC (preserving xUnit scheduling) while wrapping callbacks with a save/pin/restore window forInternalCurrentActorCellKeeper.Current. Falls back toThreadPooldispatch when no outer SC exists.HostingCleanAmbientContextAttribute—BeforeAfterTestAttributethat captures the active SC before each test, installs the decorator, pins the cell, and restores everything inAfter().EnsureImplicitSender()— guarded withCurrent == nullto prevent overwriting the cell during actor message processing (matches akka.net's pattern).INoImplicitSender).Upstream Path
This proves the wrapping SC concept in Hosting. Once validated in CI, the approach can be upstreamed to akka.net PR #8174 to replace the raw-ThreadPool SC, then Hosting can switch to the upstream attribute.
Changes
src/Akka.Hosting.TestKit/Internals/ActorCellKeepingSynchronizationContext.cs— New: wrapping SCsrc/Akka.Hosting.TestKit/Internals/HostingCleanAmbientContextAttribute.cs— New: BeforeAfterTestAttributesrc/Akka.Hosting.TestKit/TestKit.cs— Apply[HostingCleanAmbientContext]src/Akka.Hosting.TestKit/TestKit.Shared.cs— GuardEnsureImplicitSender, remove redundant writessrc/Akka.Hosting.TestKit.Tests/xunit.runner.json— Enable parallel executionsrc/Akka.Hosting.TestKit.Tests/ParallelAmbientContextSpec.cs— New: 24 regression testsTest plan
ParallelAmbientContextSpectests pass with parallel collections enabled (16 implicit-sender + 8 no-implicit-sender)--list-tests) completes without hangingAkka.Hosting.TestKit.Xunit2) builds clean, unaffected