using Akka.Actor; 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. /// /// /// /// 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 . /// /// /// 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. /// /// public sealed class ActorContextAfterAwaitTests : TestKit { private sealed record Probe; private sealed record Outcome(string? SelfError, string? ContextError, string? CapturedSelfPath); 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; // Force a real hop off the mailbox thread. await Task.Delay(50).ConfigureAwait(false); await Task.Yield(); string? selfError = null; string? contextError = null; try { ActorPath path = Self.Path; GC.KeepAlive(path); } catch (Exception ex) { selfError = ex.GetType().Name; } try { IActorRef fromContext = Context.Self; GC.KeepAlive(fromContext); } catch (Exception ex) { contextError = ex.GetType().Name; } replyTo.Tell(new Outcome(selfError, contextError, capturedSelf.Path.ToString())); }); } } [Fact] public void Self_and_Context_are_both_unreadable_after_a_real_thread_hop() { 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.Equal("NotSupportedException", outcome.SelfError); Assert.Equal("NotSupportedException", outcome.ContextError); // ...but a reference captured beforehand stays valid, which is what the actor relies on. Assert.EndsWith("/user/context-probe", outcome.CapturedSelfPath, StringComparison.Ordinal); } }