Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerNativeAlarmRebuildCarryOverTests.cs
T
Joseph Doherty f02067d295 test: retry OPC UA host start on a port bind race (TOCTOU in AllocateFreePort)
The tests probe a free port (bind 0, release) and start the SDK server later; under a
highly parallel run another test can take the port in between and the SDK fails with
'Failed to establish tcp listener sockets for Ipv4 and IPv6.'. OpcUaHostStart.StartAsync
builds a fresh host + server on a fresh port per attempt (up to 5) when the failure is a
bind failure; all 45 start sites in OpcUaServer.Tests, OpcUaServer.IntegrationTests and
Runtime.Tests use it. Per-attempt GUID ApplicationUris are hoisted out of the host factory
because a changed URI against a reused PKI store fails with 'Cannot access private key'.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 22:30:55 -04:00

278 lines
12 KiB
C#

using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Gitea #641 — a full <see cref="OtOpcUaNodeManager.RebuildAddressSpace"/> recreates every native condition
/// inactive, and when the alarm-tag set is unchanged nothing tells the new node its state again (the driver
/// does not re-subscribe, and Galaxy / OpcUaClient have nothing to replay anyway). The node manager therefore
/// carries each NATIVE condition's live state across the rebuild: captured before the clear, restored when
/// the same id is re-materialised native in the same apply, and reported as ONE condition event when the
/// apply completes (<see cref="OtOpcUaNodeManager.CompleteAddressSpaceApply"/>).
/// </summary>
/// <remarks>
/// "One event" is observed here through the condition's <c>EventId</c>, which every report stamps fresh.
/// The exact count over the wire is pinned by <c>NativeAlarmRebuildCarryOverWireTests</c>.
/// </remarks>
public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable
{
private const string Device = "Plant/MTConnect/dev1";
private const string Alarm = "Plant/MTConnect/dev1/Xtravel";
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-641-{Guid.NewGuid():N}");
private static AlarmConditionSnapshot Fault(bool acked) => new(
Active: true, Acknowledged: acked, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: 800, Message: "Xtravel FAULT 1001",
StatusCode: OpcUaStatus.Good);
[Trait("Category", "Unit")]
[Fact]
public async Task An_active_unacked_native_condition_survives_a_rebuild_and_is_reported_once()
{
var (host, nm) = await BootAsync();
var raisedAt = new DateTime(2026, 10, 8, 9, 0, 0, DateTimeKind.Utc);
Materialise(nm, native: true);
nm.WriteAlarmCondition(Alarm, Fault(acked: false), raisedAt, AddressSpaceRealm.Raw);
nm.RebuildAddressSpace();
Materialise(nm, native: true);
var restored = Condition(nm);
var idBeforeComplete = (byte[]?)restored.EventId.Value?.Clone();
// State is back as soon as the node exists — a Read or a ConditionRefresh in between already sees it.
restored.ActiveState.Id.Value.ShouldBeTrue();
restored.AckedState.Id.Value.ShouldBeFalse();
restored.Retain.Value.ShouldBeTrue();
restored.Severity.Value.ShouldBe((ushort)800);
restored.Message.Value.Text.ShouldBe("Xtravel FAULT 1001");
restored.Quality.Value.Code.ShouldBe(StatusCodes.Good);
nm.CompleteAddressSpaceApply();
// The report happens at the END of the apply (after the notifier wiring), stamped with the instant the
// state came about, not the deploy time.
restored.EventId.Value.ShouldNotBe(idBeforeComplete);
restored.Time.Value.ShouldBe(raisedAt);
// ...and only once: a second completion with nothing pending reports nothing.
var idAfterComplete = (byte[]?)restored.EventId.Value?.Clone();
nm.CompleteAddressSpaceApply();
restored.EventId.Value.ShouldBe(idAfterComplete);
await host.DisposeAsync();
}
/// <summary>
/// The acknowledgement a CLIENT applied lives only on the node (the SDK applied it; no driver or projector
/// saw it) — the reason the carry-over reads the node rather than any host-side cache.
/// </summary>
[Trait("Category", "Unit")]
[Fact]
public async Task A_client_acknowledgement_survives_the_rebuild()
{
var (host, nm) = await BootAsync();
Materialise(nm, native: true);
nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw);
// What the SDK's Acknowledge handler does to the node; nothing else hears of it.
Condition(nm).SetAcknowledgedState(nm.SystemContext, true);
nm.RebuildAddressSpace();
Materialise(nm, native: true);
nm.CompleteAddressSpaceApply();
var restored = Condition(nm);
restored.ActiveState.Id.Value.ShouldBeTrue();
restored.AckedState.Id.Value.ShouldBeTrue();
restored.Retain.Value.ShouldBeTrue();
await host.DisposeAsync();
}
/// <summary>
/// Gitea #641 review — the comment a client left with its acknowledgement is condition state too (native
/// conditions support AddComment), and an event the condition reported AFTER the restore (a client method
/// call landing on an SDK thread before the apply completes) must not have its newer <c>Time</c> rolled
/// back by the deferred restore report.
/// </summary>
[Trait("Category", "Unit")]
[Fact]
public async Task The_comment_is_carried_and_a_newer_report_is_not_rolled_back()
{
var (host, nm) = await BootAsync();
var raisedAt = new DateTime(2026, 10, 8, 9, 0, 0, DateTimeKind.Utc);
Materialise(nm, native: true);
nm.WriteAlarmCondition(Alarm, Fault(acked: false), raisedAt, AddressSpaceRealm.Raw);
Condition(nm).Comment.Value = new LocalizedText("on it");
nm.RebuildAddressSpace();
Materialise(nm, native: true);
var later = raisedAt.AddMinutes(5);
nm.WriteAlarmQuality(Alarm, StatusCodes.BadNoCommunication, later, AddressSpaceRealm.Raw); // reports at `later`
nm.CompleteAddressSpaceApply();
var restored = Condition(nm);
restored.Comment.Value.Text.ShouldBe("on it");
restored.Time.Value.ShouldBe(later, "the restore report must not stamp the condition back to the raise time");
await host.DisposeAsync();
}
/// <summary>Falsification control: a condition in the no-event position comes back exactly as a fresh
/// materialise builds it, and reports nothing.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task A_normal_native_condition_reports_nothing_and_keeps_the_fresh_state()
{
var (host, nm) = await BootAsync();
Materialise(nm, native: true);
nm.WriteAlarmCondition(Alarm, Fault(acked: false) with { Active = false, Acknowledged = true, Message = "cleared" },
DateTime.UtcNow, AddressSpaceRealm.Raw);
nm.RebuildAddressSpace();
Materialise(nm, native: true);
var fresh = Condition(nm);
var idBefore = (byte[]?)fresh.EventId.Value?.Clone();
nm.CompleteAddressSpaceApply();
fresh.EventId.Value.ShouldBe(idBefore);
fresh.ActiveState.Id.Value.ShouldBeFalse();
fresh.AckedState.Id.Value.ShouldBeTrue();
fresh.Retain.Value.ShouldBeFalse();
fresh.Message.Value.Text.ShouldBe("Xtravel", "a normal condition keeps the materialise's message");
fresh.Severity.Value.ShouldBe((ushort)700, "and its authored severity");
await host.DisposeAsync();
}
/// <summary>
/// The source-data quality is carried for a NORMAL condition too, silently: quality is annotated only on a
/// driver's connectivity edge, so without the carry a rebuild left every normal native condition reading
/// <c>BadWaitingForInitialData</c> until the driver next flapped.
/// </summary>
[Trait("Category", "Unit")]
[Fact]
public async Task A_normal_condition_keeps_its_quality_without_an_event()
{
var (host, nm) = await BootAsync();
Materialise(nm, native: true);
nm.WriteAlarmQuality(Alarm, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
nm.RebuildAddressSpace();
Materialise(nm, native: true);
var fresh = Condition(nm);
var idBefore = (byte[]?)fresh.EventId.Value?.Clone();
nm.CompleteAddressSpaceApply();
fresh.Quality.Value.Code.ShouldBe(StatusCodes.Good);
fresh.EventId.Value.ShouldBe(idBefore);
await host.DisposeAsync();
}
/// <summary>A condition the rebuild did NOT re-create has its captured state discarded when the apply
/// completes, so a later deploy that adds the same RawPath back gets a fresh node, not a resurrection.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task A_condition_removed_by_the_rebuild_is_not_resurrected_by_a_later_add()
{
var (host, nm) = await BootAsync();
Materialise(nm, native: true);
nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw);
nm.RebuildAddressSpace();
nm.CompleteAddressSpaceApply(); // the alarm was removed: nothing re-materialised it
Materialise(nm, native: true); // a later, surgical re-add of the same RawPath
nm.CompleteAddressSpaceApply();
var readded = Condition(nm);
readded.ActiveState.Id.Value.ShouldBeFalse();
readded.AckedState.Id.Value.ShouldBeTrue();
await host.DisposeAsync();
}
/// <summary>A native condition re-materialised as SCRIPTED under the same id is a different alarm: nothing
/// is restored onto it (and the scripted engine's own #487 re-assert owns scripted state).</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task A_kind_swap_on_the_same_id_is_not_restored()
{
var (host, nm) = await BootAsync();
Materialise(nm, native: true);
nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw);
nm.RebuildAddressSpace();
Materialise(nm, native: false);
nm.CompleteAddressSpaceApply();
Condition(nm).ActiveState.Id.Value.ShouldBeFalse();
await host.DisposeAsync();
}
/// <summary>Scripted conditions are not captured — <c>ScriptedAlarmHostActor.ReassertConditionNodes</c>
/// (#487) already re-projects them after a reload, and a second source would double-report.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task A_scripted_condition_is_not_carried()
{
var (host, nm) = await BootAsync();
Materialise(nm, native: false);
nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw);
nm.RebuildAddressSpace();
Materialise(nm, native: false);
nm.CompleteAddressSpaceApply();
Condition(nm).ActiveState.Id.Value.ShouldBeFalse();
await host.DisposeAsync();
}
private static void Materialise(OtOpcUaNodeManager nm, bool native)
{
nm.EnsureFolder(Device, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
nm.MaterialiseAlarmCondition(Alarm, Device, "Xtravel", "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: native);
}
private static Opc.Ua.AlarmConditionState Condition(OtOpcUaNodeManager nm) =>
nm.TryGetAlarmCondition(Alarm, AddressSpaceRealm.Raw).ShouldNotBeNull();
private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm)> BootAsync()
{
var applicationUri = $"urn:OtOpcUa.Gitea641Test:{Guid.NewGuid():N}";
var started = await OpcUaHostStart.StartAsync(
port => new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.Gitea641Test",
ApplicationUri = applicationUri,
OpcUaPort = port,
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
Microsoft.Extensions.Logging.Abstractions.NullLogger<OpcUaApplicationHost>.Instance),
() => new OtOpcUaSdkServer(), Ct);
var host = started.Host;
var server = started.Server;
return (host, server.NodeManager!);
}
/// <summary>Cleans up the PKI root directory.</summary>
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); }
catch { /* best-effort cleanup */ }
}
}
}