7a7d345b89
Six sleeps across four test files stood in for an observable edge. Each preceded an
ABSENCE assertion (or "settled" before one), so they failed toward false-pass rather than
flake: the assertion could not distinguish "the mechanism ran and correctly stayed quiet"
from "the mechanism had not run yet".
ScriptDeadlineAtEnqueueTests (two sleeps, the worst offender)
- :105 Thread.Sleep(2s) — now two explicit edges: the run must be observably QUEUED
(scheduler.QueueDepth >= 1, never checked before), then the wait polls until the 1 s
budget has elapsed, bailing early if the run leaves the queue, and asserts it is STILL
queued afterwards. The test now proves the design claim it was named for — queue wait
burns the run's budget — instead of assuming it.
- :128 Thread.Sleep(2s) + Assert.Equal(0, DetachedThreadCount) — replaced by a positive
control: a second script that genuinely wedges a worker, on the same scheduler, same
1 s timeout + 1 s grace, armed strictly later. Its stuck report is the edge; once it
lands, every watchdog armed at or before it has provably finished its grace delay. The
absence is then asserted per script name, and the detach counter is pinned at 1 (the
wedged run) rather than 0 — which a counter that never moves also satisfied.
DeploymentWarmThenGateTests (:117 plus two sibling Task.Delay(500) startup waits)
- The manager calls SetInstanceCounts unconditionally when its startup config load
completes, so a report counter on the test collector is the startup edge. The
empty-site zero is now the manager's own observation instead of an untouched field.
SiteAlarmLiveCacheServiceTests (:195, :216)
- Both sampled IsLive once after a fixed sleep. Now they poll continuously across three
linger windows on a new teardown observable (the mock client counts subscription
cancellations), so a stop at any instant inside the window is caught, and each closes
with a positive control proving that counter moves.
DataConnectionActorTests (:435)
- A Task.Delay(200) "settle" after an AwaitCondition on a connect counter. The counter
only proves ConnectAsync was CALLED; the actor is still Connecting until it processes
the piped result. Now waits for the Connected health publication that BecomeConnected
emits, so the subscribe under test provably runs against Connected rather than being
silently stashed. (The stash path keeps its own coverage in
WP6_ConnectingState_StashesSubscribeRequests.)
Negative controls (product broken in the working tree, old vs new test compared):
- Stuck-script watchdog disabled entirely: OLD test passed 2/2, NEW test failed. The
whole watchdog could be deleted and the old assertions were satisfied.
- Startup reporting a phantom instance count and taking longer than 500 ms: OLD passed
2/2, NEW failed (expected 0, actual 1).
- Linger cancellation disabled + the remaining-viewer guard dropped, so the stream is
torn down under a live viewer while IsLive keeps reporting true:
Resubscribe_Within_Linger OLD passed 2/2, NEW failed. (Last_Viewer_Leaving caught this
particular break in the old form too, via a later assertion — its strengthening is the
teardown observable and the positive control, not a false-pass fix.)
- DataConnectionActorTests :435 has no false-pass demo: the sleep guarded no assertion
there, and the change is determinism plus proving the claimed state.
Also fixes a latent race these timing changes exposed in
InstanceActorChildAttributeRaceTests: it resolved child actors single-shot immediately
after construction, but ResolveOne throws until the child's cell is published on the
parent AND the cell's Actor property stays null until the child's constructor has run on
its own mailbox thread. Neither is ordered against the test thread, so it failed both ways
under full-suite load (~1 run in 6 once the sleeps above stopped padding the schedule;
14/14 green on the unmodified tree, so it was latent, not new). Resolution now retries to
a bounded deadline; the assertion is unchanged.
Verification: SiteRuntime.Tests 8/8 green (605 tests), Communication.Tests 3/3 (717),
DataConnectionLayer.Tests 3/3 (319). Test-only change — no src/ modifications.
227 lines
10 KiB
C#
227 lines
10 KiB
C#
using Akka.Actor;
|
|
using Akka.TestKit;
|
|
using Akka.TestKit.Xunit2;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
|
using ZB.MOM.WW.ScadaBridge.TestSupport;
|
|
using System.Reflection;
|
|
using System.Text.Json;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
|
|
|
/// <summary>
|
|
/// Regression coverage for SiteRuntime-017 — the Instance Actor must not hand its
|
|
/// own live mutable <c>_attributes</c> dictionary by reference into the
|
|
/// <see cref="ScriptActor"/> / <see cref="AlarmActor"/> 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 <c>_attributes</c> in <c>HandleAttributeValueChanged</c> /
|
|
/// <c>HandleTagValueUpdate</c>. <see cref="Dictionary{TKey,TValue}"/> 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
|
|
/// <see cref="InvalidOperationException"/> ("collection was modified") — surfacing
|
|
/// as <c>ActorInitializationException</c> and stopping the child.
|
|
///
|
|
/// The fix: <c>CreateChildActors</c> snapshots <c>_attributes</c> once on the
|
|
/// Instance Actor thread (<c>new Dictionary<,>(_attributes)</c>) 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 <c>_attributes</c>, while still
|
|
/// carrying the same point-in-time contents.
|
|
/// </summary>
|
|
public class InstanceActorChildAttributeRaceTests : TestKit, IDisposable
|
|
{
|
|
private readonly SiteStorageService _storage;
|
|
private readonly ScriptCompilationService _compilationService;
|
|
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
|
private readonly SiteRuntimeOptions _options;
|
|
private readonly TestLocalDb _localDb;
|
|
|
|
public InstanceActorChildAttributeRaceTests()
|
|
{
|
|
_localDb = TestLocalDb.CreateTemp("instance-race-test");
|
|
_storage = new SiteStorageService(
|
|
_localDb.Db,
|
|
NullLogger<SiteStorageService>.Instance);
|
|
_storage.InitializeAsync().GetAwaiter().GetResult();
|
|
_compilationService = new ScriptCompilationService(
|
|
NullLogger<ScriptCompilationService>.Instance);
|
|
_sharedScriptLibrary = new SharedScriptLibrary(
|
|
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
|
_options = new SiteRuntimeOptions
|
|
{
|
|
MaxScriptCallDepth = 10,
|
|
ScriptExecutionTimeoutSeconds = 30
|
|
};
|
|
}
|
|
|
|
void IDisposable.Dispose()
|
|
{
|
|
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
|
|
// then dispose the database before deleting — the master connection anchors the WAL.
|
|
Shutdown();
|
|
var path = _localDb.Path;
|
|
_localDb.Dispose();
|
|
TestLocalDb.DeleteFiles(path);
|
|
}
|
|
|
|
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
|
|
}
|
|
]
|
|
};
|
|
|
|
/// <summary>
|
|
/// Resolves a named child and the live actor instance behind it, retrying until BOTH
|
|
/// exist (register row 40 — absence/settle timing pins).
|
|
///
|
|
/// <para>Two distinct publication races sit between "the parent reported N children" and
|
|
/// "the child's actor object is reachable by reflection", and neither is ordered against
|
|
/// the test thread: <see cref="ActorSelection.ResolveOne"/> throws
|
|
/// <see cref="ActorNotFoundException"/> until the child's cell is published on the parent,
|
|
/// and the cell's internal <c>Actor</c> property stays null until the child's constructor
|
|
/// has run on the child's own mailbox thread. A single-shot resolve therefore fails
|
|
/// intermittently under load — observed both ways in full-suite runs ("subject was null"
|
|
/// from ResolveOne, and a null instance from the reflection read). Retrying to a bounded
|
|
/// deadline removes the race without weakening the assertion: what the test proves is
|
|
/// still the seed-dictionary isolation contract, checked once both objects exist.</para>
|
|
/// </summary>
|
|
private async Task<T> ResolveChildInstanceAsync<T>(IActorRef parent, string childName)
|
|
where T : class
|
|
{
|
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15);
|
|
while (true)
|
|
{
|
|
try
|
|
{
|
|
var child = await Sys.ActorSelection(parent.Path / childName)
|
|
.ResolveOne(TimeSpan.FromSeconds(1));
|
|
var cell = ((ActorRefWithCell)child).Underlying;
|
|
// ActorCell exposes the actor instance via its internal Actor property.
|
|
var actorProp = cell.GetType().GetProperty(
|
|
"Actor", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
|
|
if (actorProp!.GetValue(cell) is T typed)
|
|
{
|
|
return typed;
|
|
}
|
|
}
|
|
catch (ActorNotFoundException)
|
|
{
|
|
// The child's cell is not published on the parent yet — retry below.
|
|
}
|
|
|
|
if (DateTime.UtcNow > deadline)
|
|
{
|
|
throw new TimeoutException(
|
|
$"Child actor '{childName}' (or its {typeof(T).Name} instance) never " +
|
|
"materialised within the resolve deadline.");
|
|
}
|
|
|
|
await Task.Delay(25);
|
|
}
|
|
}
|
|
|
|
private static Dictionary<string, object?> GetPrivateAttributes(InstanceActor instance)
|
|
{
|
|
var field = typeof(InstanceActor).GetField(
|
|
"_attributes", BindingFlags.Instance | BindingFlags.NonPublic);
|
|
return (Dictionary<string, object?>)field!.GetValue(instance)!;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ChildActors_AreSeededFromAnIsolatedCopy_NotTheLiveAttributesDictionary()
|
|
{
|
|
const string instanceName = "RacePump";
|
|
var config = BuildConfig(instanceName);
|
|
|
|
var testRef = ActorOfAsTestActorRef<InstanceActor>(
|
|
Props.Create(() => new InstanceActor(
|
|
instanceName,
|
|
JsonSerializer.Serialize(config),
|
|
_storage,
|
|
_compilationService,
|
|
_sharedScriptLibrary,
|
|
null,
|
|
_options,
|
|
NullLogger<InstanceActor>.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 scriptActor = await ResolveChildInstanceAsync<ScriptActor>(testRef, $"script-{name}");
|
|
|
|
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 alarmActor = await ResolveChildInstanceAsync<AlarmActor>(testRef, "alarm-HighTemp");
|
|
|
|
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);
|
|
}
|
|
}
|