Files
scadaproj/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests/ActorContextAfterAwaitTests.cs
T
Joseph Doherty 51bef634d0 test(secrets): make the context-after-await pin deterministic (fixes flake)
ActorContextAfterAwaitTests asserted that a ConfigureAwait(false)
continuation on the shared thread pool always throws NotSupportedException
when reading Self/Context. That property is a timing accident, not an Akka
guarantee: pool threads are exactly where Akka and the TestKit legitimately
install the [ThreadStatic] actor cell during mailbox runs and async
continuations, and Akka 1.5.62 has two non-throwing states besides — a
cleared cell makes ActorBase.Context return null (NullReferenceException on
.Self, not NotSupportedException) and ActorBase.Self return _clearedSelf
without any throw. Under parallel suite load the assertion failed once at
exactly that seam (2026-07-18); ironically the test's own doc comment said
the behaviour "does not reliably throw" and then asserted reliability.

The illegal reads now run on a dedicated new thread (LongRunning), the one
place the no-context state is guaranteed, while the realistic
ConfigureAwait(false) escape from the mailbox is kept. If the reads ever
unexpectedly succeed again, the failure message reports whose context the
thread was carrying. Verified 3 consecutive full-project runs green (38/38)
after 8 instrumented runs hunting the original repro.

Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
2026-07-18 14:54:30 -04:00

120 lines
5.4 KiB
C#

using Akka.Actor;
using Akka.TestKit.Xunit2;
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests;
/// <summary>
/// Pins the Akka behaviour that <see cref="SecretReplicationActor"/> depends on: on a thread with
/// no actor context, <c>Self</c> and <c>Context</c> cannot be read.
/// </summary>
/// <remarks>
/// <para>
/// This is here because assuming otherwise produced a real bug. <c>Self</c> resolves through
/// <c>Context</c>, which is <c>[ThreadStatic]</c>; a continuation that resumes on a thread without
/// actor context throws <see cref="NotSupportedException"/> when reading either.
/// </para>
/// <para>
/// What made it dangerous is that it does <em>not</em> reliably throw. When the awaited operation
/// completes synchronously — which a local SQLite store usually does — the continuation stays on the
/// actor's mailbox thread and the context is still intact, so the illegal read succeeds. Correctness
/// then depends on store latency and thread-pool timing. The rule the production code follows is
/// therefore unconditional: capture <c>Self</c> (and <c>Sender</c>) into locals while still on the
/// actor thread, and never touch actor context after an <c>await</c>.
/// </para>
/// <para>
/// The illegal reads run on a <b>dedicated new thread</b>, not directly in the pool continuation.
/// An earlier revision read from the <c>ConfigureAwait(false)</c> 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 <c>Context</c> return <c>null</c> and <c>Self</c> return the
/// cleared ref <em>without throwing</em>. The very unreliability this test documents made its old
/// formulation unsound. A fresh thread is the one place the no-context state is guaranteed.
/// </para>
/// </remarks>
public sealed class ActorContextAfterAwaitTests : TestKit
{
private sealed record Probe;
private sealed record Outcome(
string? SelfError, string? ContextError, string? CapturedSelfPath,
string? StaleSelfPath, string? StaleContextPath);
private sealed class ProbeActor : ReceiveActor
{
public ProbeActor()
{
ReceiveAsync<Probe>(async probe =>
{
IActorRef replyTo = Sender;
// The correct pattern: capture while the context is still current.
IActorRef capturedSelf = Self;
// The realistic escape: after this, the handler is off the mailbox thread.
await Task.Delay(50).ConfigureAwait(false);
string? selfError = null;
string? contextError = null;
string? staleSelfPath = null;
string? staleContextPath = null;
// 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
{
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(),
staleSelfPath, staleContextPath));
});
}
}
[Fact]
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());
Outcome outcome = ExpectMsg<Outcome>(TimeSpan.FromSeconds(10));
// Both throw — Self is not a cached field, it goes through the ThreadStatic context.
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);
}
}