using System.Diagnostics; using System.Collections; using System.Reflection; using System.Net; using System.Net.Sockets; using System.Runtime; using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Client; using Opc.Ua.Configuration; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; var root = Path.GetFullPath(args[0]); Directory.CreateDirectory(root); var seconds = int.Parse(args[1]); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(seconds + 120)); var ct = timeout.Token; var clientPki = Path.Combine(root, "client-pki"); var telemetry = DefaultTelemetry.Create(_ => { }); var config = new ApplicationConfiguration(telemetry); config.ApplicationName = "NativeLifecycleControl"; config.ApplicationUri = "urn:NativeLifecycleControl:" + Guid.NewGuid(); config.ApplicationType = ApplicationType.Client; config.SecurityConfiguration = new SecurityConfiguration { ApplicationCertificate = new CertificateIdentifier { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(clientPki, "own"), SubjectName = "CN=NativeLifecycleControl" }, TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(clientPki, "issuer") }, TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(clientPki, "trusted") }, RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(clientPki, "rejected") }, AutoAcceptUntrustedCertificates = true }; config.ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 15000 }; var app = new ApplicationInstance(telemetry) { ApplicationConfiguration = config }; await config.ValidateAsync(ApplicationType.Client, ct); await app.CheckApplicationInstanceCertificatesAsync(true, null, ct); int port; using (var listener = new TcpListener(IPAddress.Loopback, 0)) { listener.Start(); port = ((IPEndPoint)listener.LocalEndpoint).Port; } var options = new OpcUaApplicationHostOptions { ApplicationName = "NativeLifecycleServer", ApplicationUri = "urn:NativeLifecycleServer:" + Guid.NewGuid(), OpcUaPort = port, PublicHostname = "127.0.0.1", PkiStoreRoot = Path.Combine(root, "server-pki"), AllowAnonymous = false, AutoAcceptUntrustedClientCertificates = true, EnabledSecurityProfiles = [OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt] }; OpcUaApplicationHost host = null!; OtOpcUaSdkServer server = null!; async Task Boot() { server = new OtOpcUaSdkServer(); host = new OpcUaApplicationHost(options, NullLogger.Instance, new Auth()); await host.StartAsync(server, ct); var n = server.NodeManager!; n.NodeWriteGateway = new Gateway(); n.EnsureFolder("Control", null, "Control", AddressSpaceRealm.Raw); n.EnsureFolder("Equipment", null, "Equipment", AddressSpaceRealm.Uns); n.EnsureVariable("Control/Scalar", "Control", "Scalar", "Int32", true, AddressSpaceRealm.Raw); n.EnsureVariable("Control/Array", "Control", "Array", "Int16", true, AddressSpaceRealm.Raw, isArray: true, arrayLength: 4); n.EnsureVariable("Equipment/Scalar", "Equipment", "Scalar", "Int32", true, AddressSpaceRealm.Uns); for (var i = 0; i < 16; i++) n.EnsureVariable("Control/Browse" + i, "Control", "Browse" + i, "Int32", false, AddressSpaceRealm.Raw); n.MaterialiseAlarmCondition("Control/Alarm", "Control", "Alarm", "OffNormalAlarm", 500, AddressSpaceRealm.Raw, isNative: true); n.WriteValue("Control/Scalar", 0, 0, DateTime.UtcNow, AddressSpaceRealm.Raw); n.WriteValue("Control/Array", new short[] { 0, 1, 2, 3 }, 0, DateTime.UtcNow, AddressSpaceRealm.Raw); n.WriteValue("Equipment/Scalar", 0, 0, DateTime.UtcNow, AddressSpaceRealm.Uns); } async Task Connect() { var t = DefaultTelemetry.Create(_ => { }); var endpoint = await CoreClientUtils.SelectEndpointAsync(config, $"opc.tcp://127.0.0.1:{port}/OtOpcUa", true, t, ct); var factory = new DefaultSessionFactory(t); var identity = new UserIdentity("control", Encoding.UTF8.GetBytes(FixtureCredential.Value)); return await factory.CreateAsync(config, new ConfiguredEndpoint(null, endpoint ?? throw new Exception("Missing endpoint"), EndpointConfiguration.Create(config)), false, false, "NativeLifecycleControl", 15000, identity, default, ct); } static DataValue Value(object v) { #if SDK2 if (!VariantHelper.TryCastFrom(v, out var variant)) throw new Exception("Unsupported control value"); return new DataValue(variant); #else return new DataValue(new Variant(v)); #endif } static object? Clr(DataValue v) { #if SDK2 return v.WrappedValue.AsBoxedObject(Variant.BoxingBehavior.Legacy); #else return v.Value; #endif } object? CertificateStats() { #if SDK2 return new { created = Opc.Ua.Security.Certificates.Certificate.InstancesCreated, disposed = Opc.Ua.Security.Certificates.Certificate.InstancesDisposed, outstanding = Opc.Ua.Security.Certificates.Certificate.InstancesLeaked }; #else return null; #endif } void Sample(int cycle, string kind) { var live = GC.GetTotalMemory(true); using var p = Process.GetCurrentProcess(); p.Refresh(); var m = GC.GetGCMemoryInfo(); Console.WriteLine(JsonSerializer.Serialize(new { kind, cycle, pid = Environment.ProcessId, live, rss = p.WorkingSet64, gcCommitted = m.TotalCommittedBytes, gcHeap = m.HeapSizeBytes, fragmented = m.FragmentedBytes, jit = JitInfo.GetCompiledMethodCount(), jitIl = JitInfo.GetCompiledILBytes(), sdk = typeof(NodeId).Assembly.GetName().Version?.ToString(), scope = "matched encrypted public lifecycle subset; no history/revocation/proxy", assemblies = AppDomain.CurrentDomain.GetAssemblies().Length, dynamicAssemblies = AppDomain.CurrentDomain.GetAssemblies().Count(a => a.IsDynamic), certificate = CertificateStats(), timers = Timer.ActiveCount, threads = p.Threads.Count, sessions = server.CurrentInstance.SessionManager.GetSessions().Count, subscriptions = server.CurrentInstance.SubscriptionManager.GetSubscriptions().Count })); Console.Out.Flush(); } int cycles = 0, restarts = 0; await Boot(); var watch = Stopwatch.StartNew(); Sample(0, "start"); try { while (watch.Elapsed.TotalSeconds < seconds) { using (var owner = await Connect()) using (var other = await Connect()) { var ns = (ushort)owner.NamespaceUris.GetIndex("https://zb.com/otopcua/raw"); var scalar = new NodeId("Control/Scalar", ns); var array = new NodeId("Control/Array", ns); using var subscription = new Subscription(owner.DefaultSubscription) { PublishingInterval = 100 }; owner.AddSubscription(subscription); await subscription.CreateAsync(ct); subscription.AddItem(new MonitoredItem(subscription.DefaultItem) { StartNodeId = scalar, AttributeId = Attributes.Value, SamplingInterval = 50 }); subscription.AddItem(new MonitoredItem(subscription.DefaultItem) { StartNodeId = array, AttributeId = Attributes.Value, SamplingInterval = 50 }); await subscription.ApplyChangesAsync(ct); var response = await owner.WriteAsync(null, [new WriteValue { NodeId = scalar, AttributeId = Attributes.Value, Value = Value(++cycles) }, new WriteValue { NodeId = array, AttributeId = Attributes.Value, Value = Value(new short[] { 1, 2, 3, 4 }) }], ct); if (response.Results.ToList().Any(s => StatusCode.IsBad(s))) throw new Exception("Control write denied"); if (!Equals(Clr(await owner.ReadValueAsync(scalar, ct)), cycles)) throw new Exception("Scalar changed"); if (!((short[])Clr(await owner.ReadValueAsync(array, ct))!).SequenceEqual(new short[] { 1, 2, 3, 4 })) throw new Exception("Array changed"); var browse = await owner.BrowseAsync(null, null, 1, [new BrowseDescription { NodeId = new NodeId("Control", ns), BrowseDirection = BrowseDirection.Forward, ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IncludeSubtypes = true, ResultMask = (uint)BrowseResultMask.All }], ct); await Task.Delay(1000, ct); await subscription.DeleteAsync(true, ct); await owner.CloseAsync(ct); await other.CloseAsync(ct); } if (server.CurrentInstance.SessionManager.GetSessions().Count != 0 || server.CurrentInstance.SubscriptionManager.GetSubscriptions().Count != 0) throw new Exception("Control retained sessions/subscriptions"); Sample(cycles, "sample"); if (cycles % 4 == 0) { using var parked = await Connect(); using var sub = new Subscription(parked.DefaultSubscription) { PublishingInterval = 100 }; parked.AddSubscription(sub); await sub.CreateAsync(ct); sub.AddItem(new MonitoredItem(sub.DefaultItem) { StartNodeId = new NodeId("Control/Scalar", (ushort)parked.NamespaceUris.GetIndex("https://zb.com/otopcua/raw")), AttributeId = Attributes.Value, SamplingInterval = 50 }); await sub.ApplyChangesAsync(ct); await host.DisposeAsync(); await Boot(); restarts++; } } Console.WriteLine(JsonSerializer.Serialize(new { kind = "complete", cycles, restarts, seconds = watch.Elapsed.TotalSeconds, fullGate = false })); } finally { await host.DisposeAsync(); #if SDK2 await app.DisposeAsync(); #endif } #if SDK2 GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); var certificateType = typeof(Opc.Ua.Security.Certificates.Certificate); var report = new Dictionary(); var trackingEnabled = Environment.GetEnvironmentVariable("OPCUA_CERTIFICATE_LEAK_TRACKING") == "1"; if (trackingEnabled) { foreach (var name in new[] { "EnumerateLiveCertificates", "EnumerateUnreachableUndisposedCertificates" }) { var method = certificateType.GetMethod(name, BindingFlags.Static | BindingFlags.NonPublic) ?? throw new InvalidOperationException("SDK2 diagnostic method missing: " + name); var values = method.Invoke(null, null) as IEnumerable ?? throw new InvalidOperationException("SDK2 diagnostic method return type changed: " + name); var stacks = new List(); foreach (var value in values) { var field = value!.GetType().GetField(name.Contains("Unreachable") ? "Item2" : "Item4") ?? throw new InvalidOperationException("SDK2 diagnostic tuple field missing: " + name); if (field.FieldType != typeof(string)) throw new InvalidOperationException("SDK2 diagnostic stack field type changed"); // Deliberately omit tuple Item1 (certificate identity). stacks.Add((string)field.GetValue(value)!); } report[name] = new { count = stacks.Count, allocations = stacks.GroupBy(x => x).OrderByDescending(g => g.Count()).Take(15) .Select(g => new { count = g.Count(), stack = g.Key }).ToArray() }; } } Console.WriteLine(JsonSerializer.Serialize(new { kind = "certificate-final-diagnostics", trackingEnabled, stats = CertificateStats(), report })); #endif class Auth : IOpcUaUserAuthenticator { public Task AuthenticateUserNameAsync(string user, string password, CancellationToken ct) => Task.FromResult(user == "control" && password == FixtureCredential.Value ? OpcUaUserAuthResult.Allow("control", [OpcUaDataPlaneRoles.WriteOperate]) : OpcUaUserAuthResult.Deny("Control credential refused")); } class Gateway : IOpcUaNodeWriteGateway { public Task WriteAsync(string n, object? v, AddressSpaceRealm r, CancellationToken ct) => Task.FromResult(new NodeWriteOutcome(true, null)); } static class FixtureCredential { internal static string Value { get; } = Environment.GetEnvironmentVariable("OPCUA_MEMORY_PASSWORD") ?? throw new InvalidOperationException("Set OPCUA_MEMORY_PASSWORD for the task-owned fixture."); }