Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs
T
Joseph Doherty 39c5af3d7f fix(runtime): in-place ApplyDelta releases and re-subscribes data + alarms (#663)
A Connected ApplyDelta called ReinitializeAsync and kept the actor's data and
native-alarm handles, though every driver's reinit drops the driver-side
subscriptions behind them (#630). The reconcile only re-subscribes a missing
handle, so the driver went silent until a reconnect. Release both at the
driver before the reinit and re-subscribe both in a finally. Unreachable from
deploys since #516; pinned by a two-case test, each case failing against a
different wrong fix.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 07:49:56 -04:00

632 lines
35 KiB
C#

using Akka.Actor;
using Akka.Event;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Covers WS-4b: <see cref="DriverInstanceActor"/> subscribes a driver's native
/// <see cref="IAlarmSource.OnAlarmEvent"/> (when the driver is an <see cref="IAlarmSource"/>)
/// and forwards every transition to its parent as
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/> — mirroring the OnDataChange →
/// <see cref="DriverInstanceActor.AttributeValueBatchPublished"/> forward. The driver fires
/// <c>OnAlarmEvent</c> on its OWN thread; the actor marshals it onto the actor thread via Self.
/// </summary>
public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
{
/// <summary>
/// Driving an <see cref="IAlarmSource"/> driver to Connected then raising a native alarm forwards
/// it to the parent as <see cref="DriverInstanceActor.AttributeAlarmPublished"/> carrying the
/// driver-instance id + the original <see cref="AlarmEventArgs"/> (SourceNodeId preserved).
/// </summary>
[Fact]
public async Task Native_alarm_is_forwarded_to_parent_as_AttributeAlarmPublished()
{
var driver = new AlarmSourceStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
// The alarm handler is attached after Become(Connected); wait for it to be wired before raising.
AwaitCondition(() => driver.AlarmSubscriberCount == 1, TimeSpan.FromSeconds(2));
// Also establish a data-change subscription so the OnDataChange path is wired (the alarm attach is
// independent of Subscribe — this proves both event paths coexist on one driver).
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
new DriverInstanceActor.Subscribe(new[] { "tag-z" }, TimeSpan.FromMilliseconds(100)),
TimeSpan.FromSeconds(3));
driver.RaiseAlarm(new AlarmEventArgs(
new StubAlarmHandle(),
SourceNodeId: "src-node-7",
ConditionId: "cond-1",
AlarmType: "AnalogLimitAlarm.HiHi",
Message: "level too high",
Severity: AlarmSeverity.High,
SourceTimestampUtc: DateTime.UtcNow,
Kind: AlarmTransitionKind.Raise));
var published = parent.ExpectMsg<DriverInstanceActor.AttributeAlarmPublished>(TimeSpan.FromSeconds(2));
published.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
published.Args.SourceNodeId.ShouldBe("src-node-7");
published.Args.ConditionId.ShouldBe("cond-1");
published.Args.Kind.ShouldBe(AlarmTransitionKind.Raise);
// The same driver's OnDataChange still flows independently — alarm + value events coexist.
driver.FireDataChange("tag-z", value: 9.0, statusCode: 0u);
parent.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(TimeSpan.FromSeconds(2))
.Items[0].FullReference.ShouldBe("tag-z");
}
/// <summary>
/// The native-alarm path is gated at the driver: an <see cref="IAlarmSource"/> suppresses
/// <c>OnAlarmEvent</c> until at least one alarm subscription exists (e.g. GalaxyDriver gates its
/// central feed). When the host pushes a <see cref="DriverInstanceActor.SetDesiredSubscriptions"/>
/// carrying native-alarm refs to a <c>Connected</c> driver — the live deploy path — the actor must
/// call <see cref="IAlarmSource.SubscribeAlarmsAsync"/> with those refs to un-gate the feed, and must
/// NOT re-subscribe when the same set is redeployed (the feed is already un-gated).
/// </summary>
[Fact]
public async Task Alarm_subscription_is_established_when_alarm_refs_are_pushed_while_connected()
{
var driver = new AlarmSourceStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.AlarmSubscriberCount == 1, TimeSpan.FromSeconds(2)); // reached Connected
driver.SubscribeAlarmsCallCount.ShouldBe(0, "no alarm subscription before any alarm refs are pushed");
// A deploy delivers the desired set with a native-alarm ref while the driver is already Connected.
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
Array.Empty<string>(), TimeSpan.FromMilliseconds(100), new[] { "Temp.HiHi" }));
AwaitCondition(() => driver.SubscribeAlarmsCallCount == 1, TimeSpan.FromSeconds(2));
driver.LastAlarmRefs.ShouldBe(new[] { "Temp.HiHi" });
// Redeploying the same alarm set must NOT re-subscribe (idempotent — already un-gated). Round-trip a
// data Subscribe to flush the mailbox so the second SetDesiredSubscriptions is fully processed first.
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
Array.Empty<string>(), TimeSpan.FromMilliseconds(100), new[] { "Temp.HiHi" }));
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
new DriverInstanceActor.Subscribe(new[] { "tag-z" }, TimeSpan.FromMilliseconds(100)),
TimeSpan.FromSeconds(3));
driver.SubscribeAlarmsCallCount.ShouldBe(1, "an already-established alarm subscription is not re-issued");
}
/// <summary>
/// A driver that is NOT an <see cref="IAlarmSource"/> (only <see cref="ISubscribable"/>) connects
/// and serves data changes normally — <c>AttachAlarmSource</c> is a safe no-op (no crash) and no
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/> is ever produced.
/// </summary>
[Fact]
public async Task Non_alarm_source_driver_serves_data_changes_and_never_publishes_alarms()
{
var driver = new SubscribableOnlyStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
new DriverInstanceActor.Subscribe(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)),
TimeSpan.FromSeconds(3));
driver.FireDataChange("tag-a", value: 1.5, statusCode: 0u);
// Data-change forwarding still works (no crash on AttachAlarmSource for a non-IAlarmSource driver).
var dc = parent.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(TimeSpan.FromSeconds(2));
dc.Items[0].FullReference.ShouldBe("tag-a");
// An AttributeAlarmPublished can never be produced for a non-IAlarmSource driver.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>
/// A native alarm transition that races in while the actor is in <c>Reconnecting</c> is silently
/// dropped (debug log only) — it NEVER reaches the parent as
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/> AND is NOT dead-lettered (the
/// <c>Reconnecting</c> state's explicit <c>Receive&lt;NativeAlarmRaised&gt;</c> handler consumes and
/// discards it, so it never becomes unhandled). The feed re-delivers active alarms once the actor
/// re-enters <c>Connected</c>, so dropping here is safe.
/// (<see cref="DriverInstanceActor"/> line ~345: the <c>Reconnecting</c> state's
/// <c>Receive&lt;NativeAlarmRaised&gt;</c> logs a debug message and discards.)
///
/// <para>
/// To reproduce the race deterministically: a <c>ForceReconnect</c> is enqueued first, then
/// the driver fires an alarm while its <c>OnAlarmEvent</c> handler is still attached (the actor
/// hasn't yet processed <c>ForceReconnect</c>). The handler's <c>self.Tell(NativeAlarmRaised)</c>
/// lands second in the mailbox. The actor then processes <c>ForceReconnect</c> → Reconnecting
/// (detaches handler), then processes the queued <c>NativeAlarmRaised</c> in Reconnecting
/// → drops it. The default 10 s reconnect interval ensures no retry fires during the check.
/// </para>
///
/// <para>
/// Both properties are actively asserted: (a) the parent probe receives no
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/>; (b) a dead-letter probe subscribed
/// to <see cref="AllDeadLetters"/> on the <see cref="ActorSystem.EventStream"/> also receives
/// nothing — proving the drop handler is present (removing it would cause a dead-letter and fail
/// this assertion). <c>NativeAlarmRaised</c> is private to the actor, so the dead-letter probe
/// subscribes to the unfiltered <see cref="AllDeadLetters"/> channel; this is safe because
/// exactly one message is injected and the reconnect timer (10 s) cannot fire in the window.
/// </para>
/// </summary>
[Fact]
public void Native_alarm_during_reconnect_is_dropped_not_forwarded()
{
// Subscribe a dead-letter probe BEFORE injecting the alarm so we don't miss any early publish.
// NativeAlarmRaised is private, so we subscribe to the unfiltered AllDeadLetters channel.
// Only one message is injected and the 10 s reconnect timer can't fire in this window, so
// a plain "no dead letters at all" assertion is safe and non-flaky.
var deadLetters = CreateTestProbe();
Sys.EventStream.Subscribe(deadLetters.Ref, typeof(AllDeadLetters));
// Long reconnect interval (default 10 s) so the retry doesn't fire during the assertion window.
var driver = new AlarmSourceStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
// Drive to Connected; confirm the alarm handler is attached.
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.AlarmSubscriberCount == 1, TimeSpan.FromSeconds(2));
// Enqueue ForceReconnect FIRST — the actor hasn't processed it yet, so the handler is
// still wired. The test thread then immediately fires the alarm on the driver; the handler's
// self.Tell(NativeAlarmRaised) lands SECOND in the mailbox.
actor.Tell(new DriverInstanceActor.ForceReconnect());
driver.RaiseAlarm(new AlarmEventArgs(
new StubAlarmHandle(),
SourceNodeId: "src-node-reconnect",
ConditionId: "cond-reconnect",
AlarmType: "T",
Message: "during-reconnect raise",
Severity: AlarmSeverity.High,
SourceTimestampUtc: DateTime.UtcNow,
Kind: AlarmTransitionKind.Raise));
// (a) The parent must NOT receive AttributeAlarmPublished from that alarm.
// The actor processes: (1) ForceReconnect → Reconnecting (handler detached);
// (2) NativeAlarmRaised → dropped (debug log, no forward).
// Wait generously — the default reconnect interval of 10 s means no retry fires here.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
// (b) The alarm must also NOT have dead-lettered — the Reconnecting state's explicit
// Receive<NativeAlarmRaised> handler must have consumed it. If that handler were removed the
// message would become unhandled → AllDeadLetters, and this assertion would catch the regression.
deadLetters.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
// The actor must still be alive — Watch + no Terminated.
Watch(actor);
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(100));
}
/// <summary>
/// Gitea #662 case 1 — re-subscribing a Connected driver's DATA (what every deploy's
/// <c>SetDesiredSubscriptions</c> push does) must leave the native-alarm feed alone. The data
/// re-subscribe drops the prior data subscription first, and that teardown used to run the full
/// detach — unhooking <c>OnAlarmEvent</c> and forgetting the alarm handle — so the alarm subscription
/// stayed "established" while every event it carried reached nobody until a reconnect.
/// </summary>
[Fact]
public async Task A_data_re_subscribe_keeps_the_native_alarm_feed_attached()
{
var driver = new AlarmSourceStubDriver();
var parent = CreateTestProbe();
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.AlarmSubscriberCount == 1, TimeSpan.FromSeconds(2));
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
new[] { "tag-a" }, TimeSpan.FromMilliseconds(100), new[] { "Temp.HiHi" }));
AwaitCondition(() => driver.SubscribeAlarmsCallCount == 1, TimeSpan.FromSeconds(2));
// A second data subscribe while the first handle is live — the drop-then-resubscribe path.
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
new DriverInstanceActor.Subscribe(new[] { "tag-a", "tag-b" }, TimeSpan.FromMilliseconds(100)),
TimeSpan.FromSeconds(3));
driver.AlarmSubscriberCount.ShouldBe(1, "the data re-subscribe must not unhook OnAlarmEvent");
driver.SubscribeAlarmsCallCount.ShouldBe(1,
"the alarm handle is still live, so nothing should re-issue (and duplicate) the driver-side subscription");
driver.RaiseAlarm(new AlarmEventArgs(
new StubAlarmHandle(), "Temp.HiHi", "Temp.HiHi", "T", "after re-subscribe", AlarmSeverity.High,
DateTime.UtcNow, Kind: AlarmTransitionKind.Raise));
parent.FishForMessage<DriverInstanceActor.AttributeAlarmPublished>(_ => true, TimeSpan.FromSeconds(2))
.Args.ConditionId.ShouldBe("Temp.HiHi");
}
/// <summary>
/// Gitea #662 case 2 — an alarm subscribe that fails on (re)connect (a session not yet ready after an
/// outage) is retried by the periodic reconcile while the driver stays Connected. Before #662 the
/// reconcile looked at data only, so the feed stayed gated for the life of the connection. Nothing
/// else here can produce the second call: no reconnect, no deploy.
/// </summary>
/// <param name="failures">Leading failures; 3 crosses the backoff (skips 0, 1, then 3 ticks), proving the
/// retries keep coming rather than giving up.</param>
[Theory]
[InlineData(1)]
[InlineData(3)]
public void A_failed_alarm_subscribe_is_retried_by_the_periodic_reconcile(int failures)
{
var driver = new AlarmSourceStubDriver { AlarmSubscribeFailuresBeforeSuccess = failures };
var publisher = new CountingHealthPublisher();
var parent = CreateTestProbe();
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, healthPublisher: publisher, healthPollInterval: TimeSpan.FromMilliseconds(100)));
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
Array.Empty<string>(), TimeSpan.FromMilliseconds(100), new[] { "Temp.HiHi" }));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.SubscribeAlarmsCallCount >= 1, PresenceBudget);
AwaitCondition(() => driver.SubscribeAlarmsCallCount >= failures + 1, PresenceBudget);
driver.LastAlarmRefs.ShouldBe(new[] { "Temp.HiHi" });
driver.RaiseAlarm(new AlarmEventArgs(
new StubAlarmHandle(), "Temp.HiHi", "Temp.HiHi", "T", "after retry", AlarmSeverity.High,
DateTime.UtcNow, Kind: AlarmTransitionKind.Raise));
parent.FishForMessage<DriverInstanceActor.AttributeAlarmPublished>(_ => true, TimeSpan.FromSeconds(2))
.Args.ConditionId.ShouldBe("Temp.HiHi");
// Converged: a live, matching handle is left alone on later ticks. POSITIVE CONTROL: each tick
// publishes one health snapshot, so waiting for four more makes the unchanged count mean "the
// reconcile ran and declined", not "no tick had fired yet". It relies on the base StubDriver's
// GetHealth() stamping UtcNow, which defeats the publish dedup so EVERY tick publishes — a stub with
// stable health would publish once and this wait would time out (loudly, not vacuously).
var settled = driver.SubscribeAlarmsCallCount;
var before = publisher.Count;
AwaitCondition(() => publisher.Count >= before + 4, PresenceBudget);
driver.SubscribeAlarmsCallCount.ShouldBe(settled, "a live, matching alarm handle must not be re-subscribed every tick");
}
/// <summary>
/// After a full reconnect cycle (Connected → Reconnecting → Connected), a single raised alarm still
/// yields EXACTLY ONE <see cref="DriverInstanceActor.AttributeAlarmPublished"/> — the
/// <c>_alarmEventHandler is not null</c> guard in <c>AttachAlarmSource</c> plus the detach on the
/// Connected → Reconnecting transition prevent a double-attach (which would publish twice).
/// </summary>
[Fact]
public void Reconnect_does_not_double_attach_alarm_handler()
{
var driver = new AlarmSourceStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.AlarmSubscriberCount == 1, TimeSpan.FromSeconds(2));
// Force Connected → Reconnecting (detaches) → Connected (re-attaches). InitializeCount climbs on
// the retry; the alarm handler count must settle back to exactly one (no leaked extra handler).
var initBefore = driver.InitializeCount;
actor.Tell(new DriverInstanceActor.DisconnectObserved("backend blip"));
AwaitCondition(() => driver.InitializeCount > initBefore, TimeSpan.FromSeconds(3));
AwaitCondition(() => driver.AlarmSubscriberCount == 1, TimeSpan.FromSeconds(3));
driver.RaiseAlarm(new AlarmEventArgs(
new StubAlarmHandle(),
SourceNodeId: "src-node-1",
ConditionId: "cond-x",
AlarmType: "T",
Message: "m",
Severity: AlarmSeverity.Low,
SourceTimestampUtc: DateTime.UtcNow,
Kind: AlarmTransitionKind.Raise));
// Exactly one forward — a double-attach would surface as a second AttributeAlarmPublished.
parent.ExpectMsg<DriverInstanceActor.AttributeAlarmPublished>(TimeSpan.FromSeconds(2))
.Args.SourceNodeId.ShouldBe("src-node-1");
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>#477 Layer 2 — the actor announces connectivity to its parent (the host): Connected on
/// reaching the Connected state, then Connected=false on a <see cref="DriverInstanceActor.DisconnectObserved"/>,
/// then Connected=true again on the reconnect. This is the signal the host uses to annotate the driver's
/// native alarm conditions' Quality (comms lost → Bad, restored → Good).</summary>
[Fact]
public void ConnectivityChanged_is_announced_to_parent_on_connect_disconnect_and_reconnect()
{
var driver = new AlarmSourceStubDriver();
var parent = CreateTestProbe(); // deliberately does NOT ignore ConnectivityChanged — it's the subject.
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// Initial connect → Connected=true.
parent.FishForMessage<DriverInstanceActor.ConnectivityChanged>(m => true, TimeSpan.FromSeconds(3))
.Connected.ShouldBeTrue();
// Disconnect → Connected=false (this is what drives the conditions Bad).
actor.Tell(new DriverInstanceActor.DisconnectObserved("backend blip"));
parent.FishForMessage<DriverInstanceActor.ConnectivityChanged>(m => true, TimeSpan.FromSeconds(3))
.Connected.ShouldBeFalse();
// Reconnect → Connected=true again (drives them back Good).
parent.FishForMessage<DriverInstanceActor.ConnectivityChanged>(m => true, TimeSpan.FromSeconds(3))
.Connected.ShouldBeTrue();
}
/// <summary>
/// Gitea #663 — an in-place config change on a Connected driver (<see cref="DriverInstanceActor.ApplyDelta"/>
/// → <see cref="IDriver.ReinitializeAsync"/>) must end with exactly one live data subscription and one live
/// native-alarm subscription at the driver, and the alarm feed still forwarding. The handler used to keep
/// the actor's handles across the reinit while the driver had dropped the subscriptions behind them, so
/// the reconcile (which only re-subscribes a MISSING handle) never restored them and the driver went silent.
/// </summary>
/// <param name="reinitKeepsSubscriptions">false = the production shape (reinit drops everything, #630) —
/// the regression; true = a driver that keeps them, which pins that the fix releases before re-subscribing
/// instead of piling a second subscription on top.</param>
[Theory]
[InlineData(false)]
[InlineData(true)]
public void An_in_place_ApplyDelta_leaves_one_live_data_and_one_live_alarm_subscription(bool reinitKeepsSubscriptions)
{
var driver = new AlarmSourceStubDriver { ReinitKeepsSubscriptions = reinitKeepsSubscriptions };
var publisher = new CountingHealthPublisher();
var parent = CreateTestProbe();
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver, healthPublisher: publisher, healthPollInterval: TimeSpan.FromMilliseconds(100)));
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
new[] { "tag-a" }, TimeSpan.FromMilliseconds(100), new[] { "Temp.HiHi" }));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.LiveDataSubscriptions == 1 && driver.LiveAlarmSubscriptions == 1, PresenceBudget);
var correlation = CorrelationId.NewId();
actor.Tell(new DriverInstanceActor.ApplyDelta("{\"changed\":true}", correlation), parent.Ref);
parent.FishForMessage<DriverInstanceActor.ApplyResult>(r => r.Correlation == correlation, PresenceBudget)
.Success.ShouldBeTrue();
driver.ReinitializeCount.ShouldBe(1);
driver.InitializeCount.ShouldBe(1, "an in-place delta must not go through the reconnect path");
AwaitCondition(() => driver.LiveDataSubscriptions == 1 && driver.LiveAlarmSubscriptions == 1, PresenceBudget);
// The "not two" half is an absence, so wait for three more health-poll ticks — each runs the reconcile
// that would issue a duplicate — before re-checking.
var ticks = publisher.Count;
AwaitCondition(() => publisher.Count >= ticks + 3, PresenceBudget);
driver.LiveDataSubscriptions.ShouldBe(1);
driver.LiveAlarmSubscriptions.ShouldBe(1);
driver.AlarmSubscriberCount.ShouldBe(1);
driver.RaiseAlarm(new AlarmEventArgs(
new StubAlarmHandle(), "Temp.HiHi", "Temp.HiHi", "T", "after reinit", AlarmSeverity.High,
DateTime.UtcNow, Kind: AlarmTransitionKind.Raise));
parent.FishForMessage<DriverInstanceActor.AttributeAlarmPublished>(_ => true, PresenceBudget)
.Args.ConditionId.ShouldBe("Temp.HiHi");
}
// --- stub drivers ----------------------------------------------------------------------------
private class StubDriver : IDriver
{
/// <summary>Gets the number of times initialization was called.</summary>
public int InitializeCount;
/// <summary>Gets the driver instance ID.</summary>
public string DriverInstanceId => "alarm-stub-driver-1";
/// <summary>Gets the driver type.</summary>
public string DriverType => "Stub";
/// <summary>Initializes the driver.</summary>
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
Interlocked.Increment(ref InitializeCount);
return Task.CompletedTask;
}
/// <summary>Reinitializes the driver.</summary>
public virtual Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) =>
Task.CompletedTask;
/// <summary>Shuts down the driver.</summary>
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>Gets the health status of the driver.</summary>
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
}
/// <summary>A driver that implements <see cref="ISubscribable"/> + <see cref="IAlarmSource"/> and lets the
/// test raise <see cref="IAlarmSource.OnAlarmEvent"/> on demand (the driver fires it on its own thread in
/// production; here the test thread stands in for that).</summary>
private sealed class AlarmSourceStubDriver : StubDriver, ISubscribable, IAlarmSource
{
private readonly StubHandle _subHandle = new();
/// <summary>Occurs when data changes.</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>Server-pushed alarm transition.</summary>
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
/// <summary>Number of live subscribers on <see cref="OnAlarmEvent"/> — proves attach/detach.</summary>
public int AlarmSubscriberCount => OnAlarmEvent?.GetInvocationList().Length ?? 0;
private readonly object _liveLock = new();
private readonly HashSet<object> _liveData = new(ReferenceEqualityComparer.Instance);
private readonly HashSet<object> _liveAlarms = new(ReferenceEqualityComparer.Instance);
private int _reinitializeCount;
/// <summary>Driver-side data subscriptions currently open (subscribed and not yet released).</summary>
public int LiveDataSubscriptions { get { lock (_liveLock) return _liveData.Count; } }
/// <summary>Driver-side native-alarm subscriptions currently open.</summary>
public int LiveAlarmSubscriptions { get { lock (_liveLock) return _liveAlarms.Count; } }
/// <summary>Number of <see cref="ReinitializeAsync"/> calls.</summary>
public int ReinitializeCount => Volatile.Read(ref _reinitializeCount);
/// <summary>When false (the default, and every production driver since #630), a reinit releases the
/// device/session state and with it every driver-side subscription; when true it keeps them — the
/// shape that would double-subscribe if the actor re-subscribed without releasing first.</summary>
public bool ReinitKeepsSubscriptions { get; init; }
/// <inheritdoc />
public override Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _reinitializeCount);
if (!ReinitKeepsSubscriptions)
{
lock (_liveLock)
{
_liveData.Clear();
_liveAlarms.Clear();
}
}
return Task.CompletedTask;
}
/// <summary>Subscribes to the specified full references.</summary>
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
var handle = new StubHandle();
lock (_liveLock) _liveData.Add(handle);
return Task.FromResult<ISubscriptionHandle>(handle);
}
/// <summary>Unsubscribes from the specified subscription handle.</summary>
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
lock (_liveLock) _liveData.Remove(handle);
return Task.CompletedTask;
}
private int _subscribeAlarmsCallCount;
/// <summary>Number of <see cref="SubscribeAlarmsAsync"/> calls — proves the actor established the
/// native-alarm subscription that un-gates the feed (incremented on the actor dispatcher thread).</summary>
public int SubscribeAlarmsCallCount => Volatile.Read(ref _subscribeAlarmsCallCount);
/// <summary>The node set handed to the most recent <see cref="SubscribeAlarmsAsync"/> call.</summary>
public IReadOnlyList<string>? LastAlarmRefs { get; private set; }
/// <summary>Subscribes to alarm events for the specified node set.</summary>
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
{
LastAlarmRefs = sourceNodeIds;
var call = Interlocked.Increment(ref _subscribeAlarmsCallCount);
if (call <= AlarmSubscribeFailuresBeforeSuccess)
throw new InvalidOperationException($"alarm subscribe failure {call} (test-injected)");
var handle = new StubAlarmHandle();
lock (_liveLock) _liveAlarms.Add(handle);
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
}
/// <summary>How many leading <see cref="SubscribeAlarmsAsync"/> calls throw — the "session not ready
/// after an outage" shape from #662.</summary>
public int AlarmSubscribeFailuresBeforeSuccess { get; init; }
/// <summary>Cancels an alarm subscription.</summary>
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
{
lock (_liveLock) _liveAlarms.Remove(handle);
return Task.CompletedTask;
}
/// <summary>Acknowledges a batch of alarms.</summary>
public Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
Task.CompletedTask;
/// <summary>Fires <see cref="OnAlarmEvent"/> — stands in for the driver's native feed.</summary>
public void RaiseAlarm(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
/// <summary>Fires a data change event (keeps OnDataChange exercised; the actor wires both events).</summary>
public void FireDataChange(string fullRef, object? value, uint statusCode)
{
var snapshot = new DataValueSnapshot(value, statusCode, DateTime.UtcNow, DateTime.UtcNow);
OnDataChange?.Invoke(this, new DataChangeEventArgs(_subHandle, fullRef, snapshot));
}
private sealed class StubHandle : ISubscriptionHandle
{
/// <summary>Gets the diagnostic ID of the subscription.</summary>
public string DiagnosticId => "stub-sub";
}
}
/// <summary>A driver that is <see cref="ISubscribable"/> but NOT <see cref="IAlarmSource"/> — proves
/// <c>AttachAlarmSource</c> is a no-op (no crash) and no alarm forward ever happens.</summary>
private sealed class SubscribableOnlyStubDriver : StubDriver, ISubscribable
{
private readonly StubHandle _subHandle = new();
/// <summary>Occurs when data changes.</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>Subscribes to the specified full references.</summary>
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
Task.FromResult<ISubscriptionHandle>(_subHandle);
/// <summary>Unsubscribes from the specified subscription handle.</summary>
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) =>
Task.CompletedTask;
/// <summary>Fires a data change event with the specified parameters.</summary>
public void FireDataChange(string fullRef, object? value, uint statusCode)
{
var snapshot = new DataValueSnapshot(value, statusCode, DateTime.UtcNow, DateTime.UtcNow);
OnDataChange?.Invoke(this, new DataChangeEventArgs(_subHandle, fullRef, snapshot));
}
private sealed class StubHandle : ISubscriptionHandle
{
/// <summary>Gets the diagnostic ID of the subscription.</summary>
public string DiagnosticId => "stub-sub";
}
}
/// <summary>Counts health-snapshot publishes — one per processed <c>health-poll</c> tick.</summary>
private sealed class CountingHealthPublisher : IDriverHealthPublisher
{
private int _count;
/// <summary>Number of snapshots published so far.</summary>
public int Count => Volatile.Read(ref _count);
/// <inheritdoc />
public void Publish(
string clusterId,
string driverInstanceId,
DriverHealth health,
int errorCount5Min,
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null,
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null,
SkippedTagReport? skippedTags = null,
IReadOnlyDictionary<string, string>? diagnostics = null)
=> Interlocked.Increment(ref _count);
}
/// <summary>Minimal <see cref="IAlarmSubscriptionHandle"/> for building <see cref="AlarmEventArgs"/>.</summary>
private sealed class StubAlarmHandle : IAlarmSubscriptionHandle
{
/// <summary>Gets the diagnostic ID of the alarm subscription.</summary>
public string DiagnosticId => "stub-alarm-sub";
}
}