diff --git a/src/core/Akka.TestKit.Tests/TestActorRefTests/ParallelTestActorDeadlockSpec.cs b/src/core/Akka.TestKit.Tests/TestActorRefTests/ParallelTestActorDeadlockSpec.cs
index a0df2d2d77c..4f69eda882b 100644
--- a/src/core/Akka.TestKit.Tests/TestActorRefTests/ParallelTestActorDeadlockSpec.cs
+++ b/src/core/Akka.TestKit.Tests/TestActorRefTests/ParallelTestActorDeadlockSpec.cs
@@ -1,13 +1,37 @@
+//-----------------------------------------------------------------------
+//
+// Copyright (C) 2009-2022 Lightbend Inc.
+// Copyright (C) 2013-2025 .NET Foundation
+//
+//-----------------------------------------------------------------------
+
+#nullable enable
+
using System;
+using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Akka.Actor;
+using Akka.Configuration;
using Xunit;
namespace Akka.TestKit.Tests.TestActorRefTests
{
- public class ParallelTestActorDeadlockSpec
+ ///
+ /// Reproduces the deadlock that used to hit Akka.Hosting.TestKit when many TestKits start at
+ /// once and an actor talks to the TestActor from its own PreStart.
+ ///
+ /// Related issues:
+ ///
+ /// - https://github.com/akkadotnet/akka.net/issues/7770
+ /// - https://github.com/akkadotnet/Akka.Hosting/pull/643
+ ///
+ ///
+ ///
+ public sealed class ParallelTestActorDeadlockSpec
{
+ private const int ConcurrentTestKits = 16;
+
private readonly ITestOutputHelper _output;
public ParallelTestActorDeadlockSpec(ITestOutputHelper output)
@@ -15,22 +39,24 @@ public ParallelTestActorDeadlockSpec(ITestOutputHelper output)
_output = output;
}
- // This test reproduces the deadlock that occurs in Akka.Hosting.TestKit
- // when multiple TestKits start up in parallel and actors try to interact
- // with TestActor during initialization.
- //
- // Related issues:
- // - https://github.com/akkadotnet/akka.net/issues/7770
- // - https://github.com/akkadotnet/Akka.Hosting/pull/643
[Fact(Timeout = 20000)]
public async Task Parallel_TestKit_startup_should_not_deadlock()
{
- // Reduced from 40 to 16 - still tests parallel startup behavior while staying
- // within CI agent resource constraints. 40 concurrent TestKits causes shutdown
- // cascade where blocking 5-second waits saturate the thread pool.
- var concurrentTests = 16;
-
- var tasks = Enumerable.Range(0, concurrentTests)
+ // Task.Run is deliberate: the bodies must run on ThreadPool threads. That is what
+ // Akka.Hosting.TestKit does - it builds the TestKit inside a StartActors callback on a
+ // host-startup continuation - and it is the condition this spec exists to guard.
+ // Building a TestKit blocks its caller (LoggingBus.StartDefaultLoggers waits for the
+ // loggers, TestKitBase waits for the TestActor's PreStart), and every one of those waits
+ // is for work that runs on the same pool. If construction ever waits on something that
+ // can only make progress on the blocked thread, this is where it shows up.
+ //
+ // The same shape also makes the spec sensitive to plain pool starvation on small CI
+ // agents. Measured with sixteen blocked pool threads on two cores: construction takes up
+ // to 5.3s (the loggers hit akka.logger-startup-timeout on the starved pool), and the
+ // PingerActor can take up to 3.1s to exist after ActorOf returns. The per-step budgets
+ // below are sized for that and dilated so akka.test.timefactor applies; the [Fact]
+ // timeout is the actual deadlock detector, and construction plus one wait fits under it.
+ var tasks = Enumerable.Range(0, ConcurrentTestKits)
.Select(_ => Task.Run(RunOneTestKit))
.ToArray();
@@ -38,43 +64,50 @@ public async Task Parallel_TestKit_startup_should_not_deadlock()
async Task RunOneTestKit()
{
- // Removed inner Task.Run - it was causing unnecessary thread pool queueing
- // and increasing the likelihood of scheduling delays
var id = Guid.NewGuid().ToString("N").Substring(0, 8);
+ var sw = Stopwatch.StartNew();
try
{
- _output.WriteLine($"[{id}] Creating TestKit...");
- // Create TestKit synchronously like a normal test would
- using var testKit = new Akka.TestKit.Xunit.TestKit($"test-{id}", output: _output);
- _output.WriteLine($"[{id}] TestKit created");
-
- // Simulate what happens in Akka.Hosting - actor creation during startup
- // that tries to interact with TestActor
- _output.WriteLine($"[{id}] Creating PingerActor...");
- var actor = testKit.Sys.ActorOf(Props.Create(() => new PingerActor(testKit.TestActor)));
- _output.WriteLine($"[{id}] PingerActor created");
-
- // Increased timeout from 2s to 5s to account for thread pool delays under high parallelism
- // Under heavy load (40 concurrent tests), PreStart() execution can be delayed due to
- // thread pool starvation, and timer drift can cause the timeout to fire late
- await testKit.ExpectMsgAsync("ping", TimeSpan.FromSeconds(5));
- _output.WriteLine($"[{id}] Received ping from PingerActor");
-
- // Now verify the TestKit is working normally
- _output.WriteLine($"[{id}] Sending test message...");
+ // Config.Empty + actorSystemName. The single-string overload parses its argument
+ // as HOCON, so passing the id there named every system "test" and left the log
+ // output unattributable.
+ await using var testKit =
+ new Akka.TestKit.Xunit.TestKit(Config.Empty, $"test-{id}", _output);
+ var created = sw.Elapsed;
+
+ // Simulates what Akka.Hosting does: an actor created during startup that talks
+ // to the TestActor from PreStart.
+ var pinger = testKit.Sys.ActorOf(Props.Create(() => new PingerActor(testKit.TestActor)));
+
+ // ActorOf hands back a RepointableActorRef - the actor does not exist yet and
+ // PreStart has not run. Wait for it to become real instead of assuming the ping
+ // is already on its way.
+ await testKit.Sys.ActorSelection(pinger.Path)
+ .ResolveOne(testKit.Dilated(TimeSpan.FromSeconds(10)));
+ var started = sw.Elapsed;
+
+ await testKit.ExpectMsgAsync("ping", testKit.Dilated(TimeSpan.FromSeconds(10)));
+ var pinged = sw.Elapsed;
+
+ // ...and the TestKit still works normally afterwards.
testKit.TestActor.Tell("test-message");
- await testKit.ExpectMsgAsync("test-message", TimeSpan.FromSeconds(5));
- _output.WriteLine($"[{id}] Test completed successfully");
+ await testKit.ExpectMsgAsync("test-message", testKit.Dilated(TimeSpan.FromSeconds(10)));
+
+ _output.WriteLine(
+ $"[{id}] created={created.TotalMilliseconds:F0}ms " +
+ $"pinger-started=+{(started - created).TotalMilliseconds:F0}ms " +
+ $"ping=+{(pinged - started).TotalMilliseconds:F0}ms " +
+ $"total={sw.Elapsed.TotalMilliseconds:F0}ms");
}
catch (Exception ex)
{
- _output.WriteLine($"[{id}] Failed: {ex.Message}");
+ _output.WriteLine($"[{id}] failed at {sw.Elapsed.TotalMilliseconds:F0}ms: {ex.Message}");
throw;
}
}
}
- private class PingerActor : ActorBase
+ private sealed class PingerActor : ActorBase
{
private readonly IActorRef _testActor;
@@ -85,12 +118,7 @@ public PingerActor(IActorRef testActor)
protected override bool Receive(object message) => false;
- protected override void PreStart()
- {
- // This simulates what StartupPinger does in Akka.Hosting
- // Sending a message to TestActor during actor initialization
- _testActor.Tell("ping");
- }
+ protected override void PreStart() => _testActor.Tell("ping");
}
}
-}
\ No newline at end of file
+}