Skip to content

Commit 381d51f

Browse files
fix: resolve implicit-sender leak under xUnit v3 parallel execution (#735)
* fix: resolve implicit-sender leak under xUnit v3 parallel execution (#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. * fix: guard After() cleanup when Before() returned early Prevent After() from wiping the thread's SynchronizationContext when Before() skipped because the test class is not a TestKitBase subclass. * fix: remove DisableTestParallelization so parallel tests actually run The assembly-level CollectionBehavior attribute was overriding xunit.runner.json and forcing all tests to run sequentially. * 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: use upstream AkkaCleanAmbientContextAttribute and bump to Akka.NET 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 * fix: update API approval baseline to include AkkaCleanAmbientContext 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.
1 parent 3520cf6 commit 381d51f

7 files changed

Lines changed: 91 additions & 16 deletions

File tree

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
<CoverletVersion>6.0.3</CoverletVersion>
3232
<XunitRunneVisualstudio>3.1.5</XunitRunneVisualstudio>
3333
<Xunit3RunneVisualstudio>3.1.5</Xunit3RunneVisualstudio>
34-
<AkkaVersion>1.5.67</AkkaVersion>
34+
<AkkaVersion>1.5.68</AkkaVersion>
3535
<MicrosoftExtensionsVersion>[9.0.0,)</MicrosoftExtensionsVersion>
3636
<SystemTextJsonVersion>[9.0.0,)</SystemTextJsonVersion>
3737
</PropertyGroup>

src/Akka.Hosting.API.Tests/verify/CoreApiSpec.ApproveTestKit.verified.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ namespace Akka.Hosting.TestKit
4343
public System.Threading.Tasks.Task WithSnapshotSave(System.Func<Akka.Persistence.TestKit.SnapshotStoreSaveBehavior, System.Threading.Tasks.Task> behaviorSelector, System.Action execution) { }
4444
public System.Threading.Tasks.Task WithSnapshotSave(System.Func<Akka.Persistence.TestKit.SnapshotStoreSaveBehavior, System.Threading.Tasks.Task> behaviorSelector, System.Func<System.Threading.Tasks.Task> execution) { }
4545
}
46+
[Akka.TestKit.Xunit.Attributes.AkkaCleanAmbientContext]
4647
public abstract class TestKit : Akka.TestKit.TestKitBase, System.IAsyncDisposable, Xunit.IAsyncLifetime
4748
{
4849
protected TestKit(string? actorSystemName = null, Xunit.ITestOutputHelper? output = null, System.TimeSpan? startupTimeout = default, Microsoft.Extensions.Logging.LogLevel logLevel = 2) { }
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// -----------------------------------------------------------------------
2+
// <copyright file="ParallelAmbientContextSpec.cs" company="Akka.NET Project">
3+
// Copyright (C) 2013-2022 .NET Foundation <https://github.com/akkadotnet/akka.net>
4+
// </copyright>
5+
// -----------------------------------------------------------------------
6+
7+
using System;
8+
using System.Threading.Tasks;
9+
using Akka.Actor;
10+
using Akka.Actor.Internal;
11+
using Akka.TestKit;
12+
using Xunit;
13+
14+
namespace Akka.Hosting.TestKit.Tests;
15+
16+
public abstract class ParallelAmbientContextSpecBase : TestKit
17+
{
18+
protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
19+
{
20+
}
21+
22+
[Fact]
23+
public async Task Implicit_sender_should_resolve_to_own_TestActor()
24+
{
25+
TestActor.Tell("ping");
26+
await ExpectMsgAsync<string>(
27+
"ping",
28+
TimeSpan.FromSeconds(5),
29+
cancellationToken: TestContext.Current.CancellationToken);
30+
Assert.Equal(TestActor, LastSender);
31+
32+
await Task.Yield();
33+
TestActor.Tell("ping-after-yield");
34+
await ExpectMsgAsync<string>(
35+
"ping-after-yield",
36+
TimeSpan.FromSeconds(5),
37+
cancellationToken: TestContext.Current.CancellationToken);
38+
Assert.Equal(TestActor, LastSender);
39+
}
40+
}
41+
42+
public class ParallelAmbientContextSpec01 : ParallelAmbientContextSpecBase { }
43+
public class ParallelAmbientContextSpec02 : ParallelAmbientContextSpecBase { }
44+
public class ParallelAmbientContextSpec03 : ParallelAmbientContextSpecBase { }
45+
public class ParallelAmbientContextSpec04 : ParallelAmbientContextSpecBase { }
46+
public class ParallelAmbientContextSpec05 : ParallelAmbientContextSpecBase { }
47+
public class ParallelAmbientContextSpec06 : ParallelAmbientContextSpecBase { }
48+
public class ParallelAmbientContextSpec07 : ParallelAmbientContextSpecBase { }
49+
public class ParallelAmbientContextSpec08 : ParallelAmbientContextSpecBase { }
50+
public class ParallelAmbientContextSpec09 : ParallelAmbientContextSpecBase { }
51+
public class ParallelAmbientContextSpec10 : ParallelAmbientContextSpecBase { }
52+
public class ParallelAmbientContextSpec11 : ParallelAmbientContextSpecBase { }
53+
public class ParallelAmbientContextSpec12 : ParallelAmbientContextSpecBase { }
54+
public class ParallelAmbientContextSpec13 : ParallelAmbientContextSpecBase { }
55+
public class ParallelAmbientContextSpec14 : ParallelAmbientContextSpecBase { }
56+
public class ParallelAmbientContextSpec15 : ParallelAmbientContextSpecBase { }
57+
public class ParallelAmbientContextSpec16 : ParallelAmbientContextSpecBase { }
58+
59+
public abstract class ParallelNoImplicitSenderSpecBase : TestKit, INoImplicitSender
60+
{
61+
protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider)
62+
{
63+
}
64+
65+
[Fact]
66+
public async Task Current_should_be_null_both_pre_and_post_await()
67+
{
68+
Assert.Null(InternalCurrentActorCellKeeper.Current);
69+
await Task.Yield();
70+
Assert.Null(InternalCurrentActorCellKeeper.Current);
71+
await Task.Yield();
72+
Assert.Null(InternalCurrentActorCellKeeper.Current);
73+
}
74+
}
75+
76+
public class ParallelNoImplicitSenderSpec01 : ParallelNoImplicitSenderSpecBase { }
77+
public class ParallelNoImplicitSenderSpec02 : ParallelNoImplicitSenderSpecBase { }
78+
public class ParallelNoImplicitSenderSpec03 : ParallelNoImplicitSenderSpecBase { }
79+
public class ParallelNoImplicitSenderSpec04 : ParallelNoImplicitSenderSpecBase { }
80+
public class ParallelNoImplicitSenderSpec05 : ParallelNoImplicitSenderSpecBase { }
81+
public class ParallelNoImplicitSenderSpec06 : ParallelNoImplicitSenderSpecBase { }
82+
public class ParallelNoImplicitSenderSpec07 : ParallelNoImplicitSenderSpecBase { }
83+
public class ParallelNoImplicitSenderSpec08 : ParallelNoImplicitSenderSpecBase { }

src/Akka.Hosting.TestKit.Tests/Properties/AssemblyInfo.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,4 @@
3232
// by using the '*' as shown below:
3333
// [assembly: AssemblyVersion("1.0.*")]
3434

35-
[assembly: CollectionBehavior(CollectionBehavior.CollectionPerAssembly, DisableTestParallelization = true)]
35+
[assembly: CollectionBehavior(CollectionBehavior.CollectionPerClass)]
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"$schema": "https://xunit.github.io/schema/current/xunit.runner.schema.json",
33
"longRunningTestSeconds": 60,
4-
"parallelizeAssembly": false,
5-
"parallelizeTestCollections": false
4+
"parallelizeAssembly": true,
5+
"parallelizeTestCollections": true
66
}

src/Akka.Hosting.TestKit/TestKit.Shared.cs

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public IHost Host
5454
/// </summary>
5555
private void EnsureImplicitSender()
5656
{
57-
if (this is not INoImplicitSender && TestActor != null)
57+
if (this is not INoImplicitSender && InternalCurrentActorCellKeeper.Current == null && TestActor != null)
5858
InternalCurrentActorCellKeeper.Current = (ActorCell)((ActorRefWithCell)TestActor).Underlying;
5959
}
6060

@@ -192,12 +192,6 @@ private async Task InitializeAsyncCore()
192192
// TestActor initialization and registration now happens in AddStartup
193193
// before user actors are created, preventing race conditions
194194

195-
// ALWAYS set the implicit sender context on the current thread after initialization
196-
// This ensures it's available on the thread where tests will run
197-
// This is critical for tests using DI-created actors
198-
if (this is not INoImplicitSender && TestActor != null)
199-
InternalCurrentActorCellKeeper.Current = (ActorCell)((ActorRefWithCell)TestActor).Underlying;
200-
201195
await BeforeTestStart();
202196
}
203197

@@ -221,11 +215,6 @@ protected sealed override void InitializeTest(ActorSystem system, ActorSystemSet
221215

222216
protected virtual Task BeforeTestStart()
223217
{
224-
// Ensure the implicit sender is set on the current thread before each test
225-
// This is critical because tests may run on different threads than initialization
226-
if (this is not INoImplicitSender)
227-
InternalCurrentActorCellKeeper.Current = (ActorCell)((ActorRefWithCell)TestActor).Underlying;
228-
229218
return Task.CompletedTask;
230219
}
231220

src/Akka.Hosting.TestKit/TestKit.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
using System;
88
using System.Threading.Tasks;
99
using Akka.Annotations;
10+
using Akka.TestKit.Xunit.Attributes;
1011
using Xunit;
1112

1213
namespace Akka.Hosting.TestKit
1314
{
15+
[AkkaCleanAmbientContext]
1416
public abstract partial class TestKit : IAsyncLifetime, IAsyncDisposable
1517
{
1618
[InternalApi]

0 commit comments

Comments
 (0)