using System.Collections.Concurrent; using System.Diagnostics; using System.Text; using Opc.Ua; using Opc.Ua.Client; using Opc.Ua.Configuration; using Shouldly; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ClientSession = Opc.Ua.Client.ISession; using Subscription = Opc.Ua.Client.Subscription; using MonitoredItem = Opc.Ua.Client.MonitoredItem; internal static partial class Canonical { private static void Configure(BaselineRig rig) { Ledger.Record("configure"); rig.MaterialiseNative(); rig.Nm.HistorianDataSource = new SqlLikeEventSource(Enumerable.Range(0, 40) .Select(i => new HistoricalEvent($"soak-{i}", "source", Epoch.AddSeconds(i), Epoch, "soak", 500)).ToList()); rig.Nm.NodeWriteGateway = new AcceptWrites(); MaterialiseValues(rig); rig.Enforce(true, [BaselineRig.Key()], Grant()); } private static ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl Grant() => BaselineRig.ClusterGrant( BaselineRig.KeyPrincipal, "soak-grant", NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.WriteOperate | NodePermissions.HistoryRead); private static void MaterialiseValues(BaselineRig rig) { rig.Sink.EnsureFolder(BaselineRig.RawDeviceFolder, null, "Device", AddressSpaceRealm.Raw); rig.Sink.EnsureVariable(Scalar, BaselineRig.RawDeviceFolder, "Scalar", "Int32", true, AddressSpaceRealm.Raw); rig.Sink.EnsureVariable(ArrayTag, BaselineRig.RawDeviceFolder, "Array", "Int16", true, AddressSpaceRealm.Raw, isArray: true, arrayLength: 4); rig.Sink.EnsureFolder("SoakEquipment", null, "Equipment", AddressSpaceRealm.Uns); rig.Sink.EnsureVariable(UnsScalar, "SoakEquipment", "Scalar", "Int32", true, AddressSpaceRealm.Uns); for (var i = 0; i < 16; i++) rig.Sink.EnsureVariable($"{BaselineRig.RawDeviceFolder}/Browse{i}", BaselineRig.RawDeviceFolder, $"Browse{i}", "Int32", false, AddressSpaceRealm.Raw); rig.Sink.WriteValue(Scalar, 0, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); rig.Sink.WriteValue(ArrayTag, new short[] { 0, 1, 2, 3 }, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); rig.Sink.WriteValue(UnsScalar, 0, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); } private static async Task ExerciseAsync(BaselineRig rig, ApplicationConfiguration config, int cycle, StreamWriter evidence, CancellationToken ct) { await using var proxy = new PauseProxy(new Uri(rig.EndpointUrl).Port); using var owner = await ConnectAsync(rig, config, proxy.Port, ct); using var other = await ConnectAsync(rig, config, null, ct); var ownedSessions = rig.Server.CurrentInstance.SessionManager.GetSessions().ToArray(); ownedSessions.Length.ShouldBe(2); var scalar = Raw(owner, Scalar); var array = Raw(owner, ArrayTag); var unsNs = owner.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri); unsNs.ShouldBeGreaterThan(0); var uns = new NodeId(UnsScalar, (ushort)unsNs); owner.NamespaceUris.GetString(scalar.NamespaceIndex).ShouldBe(V3NodeIds.RawNamespaceUri); owner.NamespaceUris.GetString(uns.NamespaceIndex).ShouldBe(V3NodeIds.UnsNamespaceUri); using var subscription = new Subscription(owner.DefaultSubscription) { PublishingInterval = 100, KeepAliveCount = 10 }; owner.AddSubscription(subscription); try { await Ledger.Call("subscription-create", () => subscription.CreateAsync(ct)); var seen = new ConcurrentDictionary(); foreach (var id in new[] { scalar, array, uns }) { var item = new MonitoredItem(subscription.DefaultItem) { StartNodeId = id, AttributeId = Attributes.Value, SamplingInterval = 50, QueueSize = 10 }; item.Notification += (sender, _) => { foreach (var value in sender.DequeueValues()) if (StatusCode.IsGood(value.StatusCode)) seen[id] = value.WrappedValue; }; subscription.AddItem(item); } await Ledger.Call("subscription-apply", () => subscription.ApplyChangesAsync(ct)); subscription.MonitoredItems.ShouldAllBe(item => ServiceResult.IsGood(item.Status.Error)); PublicCounts(rig).ShouldBe((2, 1, 3)); var values = new short[] { (short)(cycle % 30000), 2, 3, 4 }; var writes = await Ledger.Call("write", () => owner.WriteAsync(null, new[] { new WriteValue { NodeId = scalar, AttributeId = Attributes.Value, Value = new DataValue(new Variant(cycle)) }, new WriteValue { NodeId = array, AttributeId = Attributes.Value, Value = new DataValue(new Variant(values)) }, new WriteValue { NodeId = uns, AttributeId = Attributes.Value, Value = new DataValue(new Variant(cycle)) }, }, ct)); writes.Results.ToList().ShouldAllBe(status => status == StatusCodes.Good); (await Ledger.Call("read-value", () => owner.ReadValueAsync(scalar, ct))).Value.ShouldBeOfType().ShouldBe(cycle); (await Ledger.Call("read-value", () => owner.ReadValueAsync(array, ct))).Value.ShouldBeOfType().ShouldBe(values); (await Ledger.Call("read-value", () => owner.ReadValueAsync(uns, ct))).Value.ShouldBeOfType().ShouldBe(cycle); await EventuallyAsync(() => seen.TryGetValue(scalar, out var value) && ScalarValue(value) == cycle && seen.ContainsKey(array) && seen.TryGetValue(uns, out var mirror) && ScalarValue(mirror) == cycle, ct); // One explicitly released and one deliberately abandoned browse point. var browse = await BrowseAsync(owner, ct); RequirePoint(browse); (await Ledger.Call("browse-next", () => owner.BrowseNextAsync(null, true, [browse], ct))).Results[0].StatusCode.ShouldBe(StatusCodes.Good); RequirePoint(await BrowseAsync(owner, ct)); // Production history points: cross-session denial, single-use resume, explicit release and close cleanup. var first = (await PageAsync(owner, default, false, ct)).ContinuationPoint; RequirePoint(first); (await PageAsync(other, first, false, ct)).StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid); var next = (await PageAsync(owner, first, false, ct)).ContinuationPoint; RequirePoint(next); (await PageAsync(owner, next, true, ct)).StatusCode.ShouldBe(StatusCodes.Good); for (var i = 0; i < 6; i++) RequirePoint((await PageAsync(owner, default, false, ct)).ContinuationPoint); var held = ownedSessions.Select(ContinuationCounts).ToArray(); held.Sum(count => count.Browse).ShouldBeGreaterThan(0); held.Sum(count => count.History).ShouldBeGreaterThan(0); var active = PublicCounts(rig); await LogAsync(evidence, new { kind = "active", cycle, sessions = active.Sessions, subscriptions = active.Subscriptions, items = active.Items, browsePoints = held.Sum(x => x.Browse), historyPoints = held.Sum(x => x.History) }); rig.Nm.UpdateTagAttributes(Scalar, false, null, "Int32", false, null, AddressSpaceRealm.Raw).ShouldBeTrue(); Ledger.Record("writable-off"); rig.Nm.UpdateTagAttributes(Scalar, true, null, "Int32", false, null, AddressSpaceRealm.Raw).ShouldBeTrue(); Ledger.Record("writable-on"); rig.Nm.RemoveVariableNode(ArrayTag, AddressSpaceRealm.Raw).ShouldBeTrue(); Ledger.Record("variable-remove"); rig.Sink.EnsureVariable(ArrayTag, BaselineRig.RawDeviceFolder, "Array", "Int16", true, AddressSpaceRealm.Raw, isArray: true, arrayLength: 4); rig.Sink.WriteValue(ArrayTag, values, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); var authority = rig.Nm.DataPlaneAuthority.ShouldBeOfType(); Ledger.Record("key-revoke"); authority.Apply(BaselineRig.Cluster, $"revoked-{cycle}", [], [Grant()], new Dictionary>()); var denied = await Ledger.Call("read-service", () => owner.ReadAsync(null, 0, TimestampsToReturn.Neither, [new ReadValueId { NodeId = scalar, AttributeId = Attributes.Value }], ct)); denied.Results[0].StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); rig.Enforce(true, [BaselineRig.Key()], Grant()); proxy.Pause(); Ledger.Record("network-pause"); var stalled = Stopwatch.StartNew(); string stalledStatus; try { using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); timeout.CancelAfter(TimeSpan.FromSeconds(1)); await Ledger.Call("read-value", () => owner.ReadValueAsync(scalar, timeout.Token)).WaitAsync(TimeSpan.FromSeconds(5), ct); throw new InvalidOperationException("The deliberately paused network unexpectedly completed the read."); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { stalledStatus = "OperationCanceled"; } // Exact-tag ChannelAsyncOperation maps a canceled completion waiter to BadRequestInterrupted. catch (ServiceResultException error) when (error.StatusCode == StatusCodes.BadTimeout || error.StatusCode == StatusCodes.BadRequestCancelledByClient || error.StatusCode == StatusCodes.BadRequestTimeout || error.StatusCode == StatusCodes.BadRequestInterrupted) { stalledStatus = error.StatusCode.ToString(); } finally { proxy.Resume(); } (await Ledger.Call("read-value", () => owner.ReadValueAsync(scalar, ct))).StatusCode.ShouldBe(StatusCodes.Good); await LogAsync(evidence, new { kind = "network-stall-recovered", cycle, status = stalledStatus, seconds = stalled.Elapsed.TotalSeconds }); await Ledger.Call("subscription-delete", () => subscription.DeleteAsync(true, ct)); await Ledger.Call("subscription-remove", () => owner.RemoveSubscriptionAsync(subscription, ct)); rig.Sink.RebuildAddressSpace(); Ledger.Record("address-space-rebuild"); Configure(rig); Ledger.Record("cycle-wire-body-completed"); } finally { proxy.Resume(); await Ledger.Call("session-close", () => owner.CloseAsync(ct)).WaitAsync(TimeSpan.FromSeconds(10), ct); await Ledger.Call("session-close", () => other.CloseAsync(ct)).WaitAsync(TimeSpan.FromSeconds(10), ct); foreach (var closed in ownedSessions) { var before = ContinuationCounts(closed); if (before != (0, 0)) { await LogAsync(evidence, new { kind = "closed-session-observation", cycle, activeSessions = rig.Server.CurrentInstance.SessionManager.GetSessions().Count, browsePoints = before.Browse, historyPoints = before.History, diagnosticReferenceHeld = true }); await Task.Delay(250, ct); // Read-only failure investigation; never turns the failed guard into a pass. var after = ContinuationCounts(closed); await LogAsync(evidence, new { kind = "closed-session-after-fixed-observation", cycle, observationMilliseconds = 250, browsePoints = after.Browse, historyPoints = after.History }); } before.ShouldBe((0, 0)); } } } private static async Task RestartWithActiveResourcesAsync(BaselineRig rig, ApplicationConfiguration config, CancellationToken ct) { using var client = await ConnectAsync(rig, config, null, ct); using var subscription = new Subscription(client.DefaultSubscription) { PublishingInterval = 100 }; client.AddSubscription(subscription); await Ledger.Call("subscription-create", () => subscription.CreateAsync(ct)); subscription.AddItem(new MonitoredItem(subscription.DefaultItem) { StartNodeId = Raw(client, Scalar), AttributeId = Attributes.Value, SamplingInterval = 50, }); await Ledger.Call("subscription-apply", () => subscription.ApplyChangesAsync(ct)); PublicCounts(rig).ShouldBe((1, 1, 1)); RequirePoint(await BrowseAsync(client, ct)); RequirePoint((await PageAsync(client, default, false, ct)).ContinuationPoint); var oldInternal = rig.Server.CurrentInstance; var oldSessionManager = oldInternal.SessionManager; var oldSubscriptionManager = oldInternal.SubscriptionManager; var oldSessions = oldSessionManager.GetSessions().ToArray(); var oldSubscriptions = oldSubscriptionManager.GetSubscriptions().ToArray(); var oldValidator = rig.Validator; var oldStores = BaselineMetrics.CachedStoreReferences(oldValidator); await rig.RestartAsync(ct).WaitAsync(TimeSpan.FromSeconds(20), ct); oldSessionManager.GetSessions().ShouldBeEmpty(); oldSubscriptionManager.GetSubscriptions().ShouldBeEmpty(); oldSubscriptions.ShouldAllBe(item => item.MonitoredItemCount == 0); foreach (var session in oldSessions) ContinuationCounts(session).ShouldBe((0, 0)); await LogAsync(CurrentEvidence, new { kind = "original-validator-after-stop", cachedStoreReferencesBefore = oldStores, cachedStoreReferencesAfter = BaselineMetrics.CachedStoreReferences(oldValidator), disposalFlagObservable = false, reason = "SDK1.5 has no CertificateManager disposal API or disposed flag" }); PublicCounts(rig).ShouldBe((0, 0, 0)); } private static async Task ConnectAsync(BaselineRig rig, ApplicationConfiguration config, int? proxyPort, CancellationToken ct) { var telemetry = DefaultTelemetry.Create(_ => { }); var endpoint = await CoreClientUtils.SelectEndpointAsync(config, rig.EndpointUrl, true, telemetry, ct) ?? throw new InvalidOperationException("No encrypted endpoint."); endpoint.SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt); if (proxyPort is { } port) endpoint.EndpointUrl = $"opc.tcp://127.0.0.1:{port}/OtOpcUa"; return await Ledger.Call("session-create", () => new DefaultSessionFactory(telemetry).CreateAsync(config, new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(config)), false, false, "SDK2LifecycleSoak", 15_000, new UserIdentity("apikey", Encoding.UTF8.GetBytes(BaselineRig.KeyPassword)), default, ct)); } private static NodeId Raw(ClientSession session, string id) { var ns = session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); ns.ShouldBeGreaterThan(0); return new NodeId(id, (ushort)ns); } private static async Task BrowseAsync(ClientSession session, CancellationToken ct) => (await Ledger.Call("browse", () => session.BrowseAsync(null, null, 1, [new BrowseDescription { NodeId = Raw(session, BaselineRig.RawDeviceFolder), BrowseDirection = BrowseDirection.Forward, ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IncludeSubtypes = true, ResultMask = (uint)BrowseResultMask.All, }], ct))).Results[0].ContinuationPoint; private static async Task PageAsync(ClientSession session, byte[]? point, bool release, CancellationToken ct) => (await Ledger.Call("history-read", () => session.HistoryReadAsync(null, new ExtensionObject(new ReadEventDetails { StartTime = Epoch.AddDays(-1), EndTime = Epoch.AddDays(1), NumValuesPerNode = 4, Filter = new EventFilter { SelectClauses = [new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName(BrowseNames.EventId))] }, }), TimestampsToReturn.Both, release, [new HistoryReadValueId { NodeId = Raw(session, BaselineRig.RawDeviceFolder), ContinuationPoint = point }], ct))).Results[0]; }