diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/ActorContextAfterAwaitTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/ActorContextAfterAwaitTests.cs
index 0327e7a..8ce1a02 100644
--- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/ActorContextAfterAwaitTests.cs
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/ActorContextAfterAwaitTests.cs
@@ -4,14 +4,14 @@ using Akka.TestKit.Xunit2;
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests;
///
-/// Pins the Akka behaviour that depends on: after a genuine
-/// thread hop, actor context is gone, so Self cannot be read.
+/// Pins the Akka behaviour that depends on: on a thread with
+/// no actor context, Self and Context cannot be read.
///
///
///
/// This is here because assuming otherwise produced a real bug. Self resolves through
-/// Context, which is [ThreadStatic]; a continuation that resumes on a thread-pool
-/// thread has none, and reading it throws .
+/// Context, which is [ThreadStatic]; a continuation that resumes on a thread without
+/// actor context throws when reading either.
///
///
/// What made it dangerous is that it does not reliably throw. When the awaited operation
@@ -21,12 +21,24 @@ namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests;
/// therefore unconditional: capture Self (and Sender) into locals while still on the
/// actor thread, and never touch actor context after an await.
///
+///
+/// The illegal reads run on a dedicated new thread, not directly in the pool continuation.
+/// An earlier revision read from the ConfigureAwait(false) continuation itself and flaked
+/// (observed 2026-07-18 under parallel suite load): shared thread-pool threads are exactly where
+/// Akka and the TestKit legitimately install the ThreadStatic cell during mailbox runs and async
+/// continuations, so "no context here" is a timing accident, not a guarantee — worse, a cell whose
+/// actor was already cleared makes Context return null and Self return the
+/// cleared ref without throwing. The very unreliability this test documents made its old
+/// formulation unsound. A fresh thread is the one place the no-context state is guaranteed.
+///
///
public sealed class ActorContextAfterAwaitTests : TestKit
{
private sealed record Probe;
- private sealed record Outcome(string? SelfError, string? ContextError, string? CapturedSelfPath);
+ private sealed record Outcome(
+ string? SelfError, string? ContextError, string? CapturedSelfPath,
+ string? StaleSelfPath, string? StaleContextPath);
private sealed class ProbeActor : ReceiveActor
{
@@ -39,40 +51,52 @@ public sealed class ActorContextAfterAwaitTests : TestKit
// The correct pattern: capture while the context is still current.
IActorRef capturedSelf = Self;
- // Force a real hop off the mailbox thread.
+ // The realistic escape: after this, the handler is off the mailbox thread.
await Task.Delay(50).ConfigureAwait(false);
- await Task.Yield();
string? selfError = null;
string? contextError = null;
+ string? staleSelfPath = null;
+ string? staleContextPath = null;
- try
- {
- ActorPath path = Self.Path;
- GC.KeepAlive(path);
- }
- catch (Exception ex)
- {
- selfError = ex.GetType().Name;
- }
+ // LongRunning = a brand-new thread, where no actor cell can ever have been
+ // installed. Reading Self/Context THERE is the deterministic form of the hazard.
+ await Task.Factory.StartNew(
+ () =>
+ {
+ try
+ {
+ // If this ever succeeds the failure message needs to say whose
+ // context the thread was carrying — that is the whole diagnosis.
+ staleSelfPath = Self.Path.ToString();
+ }
+ catch (Exception ex)
+ {
+ selfError = ex.GetType().Name;
+ }
- try
- {
- IActorRef fromContext = Context.Self;
- GC.KeepAlive(fromContext);
- }
- catch (Exception ex)
- {
- contextError = ex.GetType().Name;
- }
+ try
+ {
+ staleContextPath = Context.Self.Path.ToString();
+ }
+ catch (Exception ex)
+ {
+ contextError = ex.GetType().Name;
+ }
+ },
+ CancellationToken.None,
+ TaskCreationOptions.LongRunning,
+ TaskScheduler.Default).ConfigureAwait(false);
- replyTo.Tell(new Outcome(selfError, contextError, capturedSelf.Path.ToString()));
+ replyTo.Tell(new Outcome(
+ selfError, contextError, capturedSelf.Path.ToString(),
+ staleSelfPath, staleContextPath));
});
}
}
[Fact]
- public void Self_and_Context_are_both_unreadable_after_a_real_thread_hop()
+ public void Self_and_Context_are_both_unreadable_on_a_thread_without_actor_context()
{
IActorRef probe = Sys.ActorOf(Props.Create(() => new ProbeActor()), "context-probe");
probe.Tell(new Probe());
@@ -80,8 +104,14 @@ public sealed class ActorContextAfterAwaitTests : TestKit
Outcome outcome = ExpectMsg(TimeSpan.FromSeconds(10));
// Both throw — Self is not a cached field, it goes through the ThreadStatic context.
- Assert.Equal("NotSupportedException", outcome.SelfError);
- Assert.Equal("NotSupportedException", outcome.ContextError);
+ Assert.True(
+ outcome.SelfError == "NotSupportedException",
+ $"Self read did not throw NotSupportedException (got: {outcome.SelfError ?? "no exception"}; " +
+ $"stale Self path: '{outcome.StaleSelfPath}').");
+ Assert.True(
+ outcome.ContextError == "NotSupportedException",
+ $"Context read did not throw NotSupportedException (got: {outcome.ContextError ?? "no exception"}; " +
+ $"stale Context path: '{outcome.StaleContextPath}').");
// ...but a reference captured beforehand stays valid, which is what the actor relies on.
Assert.EndsWith("/user/context-probe", outcome.CapturedSelfPath, StringComparison.Ordinal);