using Akka.Actor; using Akka.TestKit; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; using ScadaLink.Commons.Messages.Streaming; using ScadaLink.Commons.Types.Flattening; using ScadaLink.SiteRuntime.Actors; using ScadaLink.SiteRuntime.Persistence; using ScadaLink.SiteRuntime.Scripts; using System.Reflection; using System.Text.Json; namespace ScadaLink.SiteRuntime.Tests.Actors; /// /// Regression coverage for SiteRuntime-017 — the Instance Actor must not hand its /// own live mutable _attributes dictionary by reference into the /// / constructors. /// /// Each child constructor runs on the child's own mailbox thread and seeds itself /// by enumerating the dictionary it was given. The Instance Actor concurrently /// mutates _attributes in HandleAttributeValueChanged / /// HandleTagValueUpdate. is not safe /// for concurrent read/write: if a child enumerates the shared live dictionary /// while the Instance Actor inserts into it, the child constructor throws /// ("collection was modified") — surfacing /// as ActorInitializationException and stopping the child. /// /// The fix: CreateChildActors snapshots _attributes once on the /// Instance Actor thread (new Dictionary<,>(_attributes)) and hands /// each child that private copy. This test asserts the isolation contract /// directly and deterministically: every child's seed dictionary must be a /// distinct object from the Instance Actor's live _attributes, while still /// carrying the same point-in-time contents. /// public class InstanceActorChildAttributeRaceTests : TestKit, IDisposable { private readonly SiteStorageService _storage; private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly SiteRuntimeOptions _options; private readonly string _dbFile; public InstanceActorChildAttributeRaceTests() { _dbFile = Path.Combine(Path.GetTempPath(), $"instance-race-test-{Guid.NewGuid():N}.db"); _storage = new SiteStorageService( $"Data Source={_dbFile}", NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); _compilationService = new ScriptCompilationService( NullLogger.Instance); _sharedScriptLibrary = new SharedScriptLibrary( _compilationService, NullLogger.Instance); _options = new SiteRuntimeOptions { MaxScriptCallDepth = 10, ScriptExecutionTimeoutSeconds = 30 }; } void IDisposable.Dispose() { Shutdown(); try { File.Delete(_dbFile); } catch { /* cleanup */ } } private static FlattenedConfiguration BuildConfig(string instanceName) => new() { InstanceUniqueName = instanceName, Attributes = [ new ResolvedAttribute { CanonicalName = "Temperature", Value = "98.6", DataType = "Double" }, new ResolvedAttribute { CanonicalName = "Pressure", Value = "12", DataType = "Int32" }, new ResolvedAttribute { CanonicalName = "Label", Value = "Main Pump", DataType = "String" } ], Scripts = [ new ResolvedScript { CanonicalName = "WorkerA", Code = "return 1;", TriggerType = "ValueChange", TriggerConfiguration = "{\"AttributeName\":\"Temperature\"}" }, new ResolvedScript { CanonicalName = "WorkerB", Code = "return 2;", TriggerType = "ValueChange", TriggerConfiguration = "{\"AttributeName\":\"Pressure\"}" } ], Alarms = [ new ResolvedAlarm { CanonicalName = "HighTemp", TriggerType = "ValueMatch", TriggerConfiguration = "{}", PriorityLevel = 1 } ] }; /// Resolves the live actor instance behind a local . private static object GetActorInstance(IActorRef actorRef) { var cell = ((ActorRefWithCell)actorRef).Underlying; // ActorCell exposes the actor instance via its internal Actor property. var actorProp = cell.GetType().GetProperty( "Actor", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); var instance = actorProp!.GetValue(cell); Assert.NotNull(instance); return instance!; } private static Dictionary GetPrivateAttributes(InstanceActor instance) { var field = typeof(InstanceActor).GetField( "_attributes", BindingFlags.Instance | BindingFlags.NonPublic); return (Dictionary)field!.GetValue(instance)!; } [Fact] public async Task ChildActors_AreSeededFromAnIsolatedCopy_NotTheLiveAttributesDictionary() { const string instanceName = "RacePump"; var config = BuildConfig(instanceName); var testRef = ActorOfAsTestActorRef( Props.Create(() => new InstanceActor( instanceName, JsonSerializer.Serialize(config), _storage, _compilationService, _sharedScriptLibrary, null, _options, NullLogger.Instance)), "instance"); var instanceActor = testRef.UnderlyingActor; var liveAttributes = GetPrivateAttributes(instanceActor); // Sanity: the children were created. Assert.Equal(2, instanceActor.ScriptActorCount); Assert.Equal(1, instanceActor.AlarmActorCount); // Every child Script Actor must have been seeded from a dictionary that // is NOT the Instance Actor's live _attributes field — otherwise the // child constructor would enumerate a dictionary the Instance Actor // mutates on another thread (SiteRuntime-017). foreach (var name in new[] { "WorkerA", "WorkerB" }) { var child = await Sys.ActorSelection(testRef.Path / $"script-{name}") .ResolveOne(TimeSpan.FromSeconds(5)); var scriptActor = (ScriptActor)GetActorInstance(child); Assert.NotNull(scriptActor.SeedAttributesReference); Assert.False( ReferenceEquals(scriptActor.SeedAttributesReference, liveAttributes), $"Script Actor '{name}' was seeded from the Instance Actor's live " + "_attributes dictionary by reference (SiteRuntime-017). It must be " + "given a private snapshot copy."); // The snapshot must still carry the same point-in-time contents. Assert.Equal(liveAttributes.Count, scriptActor.SeedAttributesReference!.Count); foreach (var kvp in liveAttributes) { Assert.True(scriptActor.SeedAttributesReference.ContainsKey(kvp.Key)); } } // The Alarm Actor must likewise be seeded from an isolated copy. var alarmChild = await Sys.ActorSelection(testRef.Path / "alarm-HighTemp") .ResolveOne(TimeSpan.FromSeconds(5)); var alarmActor = (AlarmActor)GetActorInstance(alarmChild); Assert.NotNull(alarmActor.SeedAttributesReference); Assert.False( ReferenceEquals(alarmActor.SeedAttributesReference, liveAttributes), "Alarm Actor 'HighTemp' was seeded from the Instance Actor's live " + "_attributes dictionary by reference (SiteRuntime-017). It must be " + "given a private snapshot copy."); Assert.Equal(liveAttributes.Count, alarmActor.SeedAttributesReference!.Count); } }