using Akka.Actor; using Akka.TestKit.Xunit2; namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests; /// /// 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 without /// actor context throws when reading either. /// /// /// What made it dangerous is that it does not 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 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, string? StaleSelfPath, string? StaleContextPath); private sealed class ProbeActor : ReceiveActor { public ProbeActor() { ReceiveAsync(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(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); } }