From 682b73bfe8189267f3ae251a4c59919373633759 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 12:41:51 -0500 Subject: [PATCH] test: add production client lifecycle soak harness --- .../opcua-client-lifecycle/.gitignore | 1 + .../opcua-client-lifecycle/Lifecycle.cs | 249 ++++++++++++ .../opcua-client-lifecycle/Lifecycle.csproj | 9 + .../opcua-client-lifecycle/ProcessHost.cs | 360 ++++++++++++++++++ .../opcua-client-lifecycle/README.md | 96 +++++ .../opcua-client-lifecycle/container.sh | 35 ++ .../evidence/2026-11-10-diagnostic/build.log | 63 +++ .../evidence/2026-11-10-diagnostic/client.log | 0 .../2026-11-10-diagnostic/command.json | 25 ++ .../2026-11-10-diagnostic/container.log | 0 .../full-duration-guard.log | 3 + .../2026-11-10-diagnostic/prepare.log | 0 .../2026-11-10-diagnostic/resources.jsonl | 8 + .../2026-11-10-diagnostic/result.json | 50 +++ .../2026-11-10-diagnostic/sdk-assemblies.json | 32 ++ .../server-live-resources.json | 1 + .../2026-11-10-diagnostic/server-ready.json | 1 + .../server-server-resources.json | 13 + .../evidence/2026-11-10-diagnostic/server.log | 0 .../2026-11-10-diagnostic/source.json | 17 + .../2026-11-10-diagnostic/summary.json | 40 ++ .../2026-11-10-diagnostic/verification.json | 36 ++ .../validation/opcua-client-lifecycle/run.py | 76 ++++ 23 files changed, 1115 insertions(+) create mode 100644 scripts/validation/opcua-client-lifecycle/.gitignore create mode 100644 scripts/validation/opcua-client-lifecycle/Lifecycle.cs create mode 100644 scripts/validation/opcua-client-lifecycle/Lifecycle.csproj create mode 100644 scripts/validation/opcua-client-lifecycle/ProcessHost.cs create mode 100644 scripts/validation/opcua-client-lifecycle/README.md create mode 100755 scripts/validation/opcua-client-lifecycle/container.sh create mode 100644 scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/build.log create mode 100644 scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/client.log create mode 100644 scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/command.json create mode 100644 scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/container.log create mode 100644 scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/full-duration-guard.log create mode 100644 scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/prepare.log create mode 100644 scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/resources.jsonl create mode 100644 scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/result.json create mode 100644 scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/sdk-assemblies.json create mode 100644 scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/server-live-resources.json create mode 100644 scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/server-ready.json create mode 100644 scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/server-server-resources.json create mode 100644 scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/server.log create mode 100644 scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/source.json create mode 100644 scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/summary.json create mode 100644 scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/verification.json create mode 100755 scripts/validation/opcua-client-lifecycle/run.py diff --git a/scripts/validation/opcua-client-lifecycle/.gitignore b/scripts/validation/opcua-client-lifecycle/.gitignore new file mode 100644 index 000000000..c18dd8d83 --- /dev/null +++ b/scripts/validation/opcua-client-lifecycle/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/scripts/validation/opcua-client-lifecycle/Lifecycle.cs b/scripts/validation/opcua-client-lifecycle/Lifecycle.cs new file mode 100644 index 000000000..b182006b7 --- /dev/null +++ b/scripts/validation/opcua-client-lifecycle/Lifecycle.cs @@ -0,0 +1,249 @@ +#pragma warning disable OTOPCUA0001 +using System.Collections; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Reflection; +using System.Security.Cryptography; +using System.Text.Json; +using Opc.Ua; +using Opc.Ua.Configuration; +using ZB.MOM.WW.OtOpcUa.Client.Shared; +using ZB.MOM.WW.OtOpcUa.Client.Shared.Models; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; +using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; +using WriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest; +using SharedSecurityMode = ZB.MOM.WW.OtOpcUa.Client.Shared.Models.SecurityMode; + +internal static partial class ProcessHost +{ + static object ServerCounts(OtOpcUaSdkServer server) + { + var sessions = server.CurrentInstance.SessionManager.GetSessions(); + var subs = server.CurrentInstance.SubscriptionManager.GetSubscriptions(); + return new { utc = DateTime.UtcNow, sessions = sessions.Count, subscriptions = subs.Count, + items = subs.Sum(s => s.MonitoredItemCount), serverProcess = Environment.ProcessId }; + } +} + +internal static class Lifecycle +{ + static string Root = ""; + static string Password => Environment.GetEnvironmentVariable("OPCUA_MATRIX_PASSWORD") ?? throw new InvalidOperationException("Missing fixture credential"); + static readonly JsonSerializerOptions Json = new() { WriteIndented = true }; + static int managerChecks, sharedPasses, driverPasses, browserPasses; + static readonly List Weak = []; + static void Require(bool pass, string message) { if (!pass) throw new InvalidOperationException(message); } + static object? FieldOrNull(object owner, string name) + { + var field = owner.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly) + ?? throw new InvalidOperationException("Diagnostic layout changed: " + owner.GetType().FullName + "." + name); + return field.GetValue(owner); + } + static object Field(object owner, string name) => FieldOrNull(owner, name) ?? throw new InvalidOperationException("Diagnostic owner null: " + name); + static void Empty(object owner, string name) => Require(Field(owner, name) is ICollection items && items.Count == 0, "Retained collection: " + name); + static bool Disposed(object manager) + { + Require(manager.GetType().FullName == "Opc.Ua.CertificateManager", "Unexpected SDK certificate manager type"); + Require(manager.GetType().Assembly.GetName().Version?.ToString() == "2.0.1.0", "Diagnostic requires SDK 2.0.1.0"); + var field = manager.GetType().GetField("m_disposed", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly); + Require(field?.FieldType == typeof(bool), "SDK 2.0.1 CertificateManager.m_disposed bool layout changed"); + return (bool)field!.GetValue(manager)!; + } + static object SharedManager(OpcUaClientService shared) => Field(Field(shared, "_session"), "_certificateManager"); + static object AppManager(object owner) => ((ApplicationInstance)Field(owner, "_ownedApplication")).ApplicationConfiguration!.CertificateManager; + static void CheckManager(object manager, bool disposed) + { + Require(Disposed(manager) == disposed, "Certificate manager disposal observation mismatch"); + if (disposed) managerChecks++; + } + static ConnectionSettings Settings(string endpoint, string? password = null) => new() + { + EndpointUrl = endpoint, Username = "apikey", Password = password ?? Password, + SecurityMode = SharedSecurityMode.SignAndEncrypt, AutoAcceptCertificates = false, + CertificateStorePath = Path.Combine(Root, "data", "shared-pki"), SessionTimeoutSeconds = 30 + }; + static OpcUaClientDriverOptions Options(string endpoint) => new() + { + EndpointUrl = endpoint, SecurityPolicy = OpcUaSecurityPolicy.Basic256Sha256, + SecurityMode = OpcUaSecurityMode.SignAndEncrypt, AuthType = OpcUaAuthType.Username, + Username = "apikey", Password = Password, AutoAcceptCertificates = false, + SessionName = "ClientLifecycle", ApplicationUri = "urn:localhost:ClientLifecycle", + RawTags = [new("scalar", "{\"nodeId\":\"nsu=https://zb.com/otopcua/raw;s=Matrix/dev/Scalar\"}", true), + new("vector", "{\"nodeId\":\"nsu=https://zb.com/otopcua/raw;s=Matrix/dev/Vector\"}", true)] + }; + static async Task Wait(Func condition, CancellationToken ct) + { + while (!condition()) await Task.Delay(25, ct); + } + static object? Clr(DataValue value) => ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(value.WrappedValue); + static async Task Prepare() + { + // Public connection failures create and release the same production applications as a live attempt. + // No production private method is invoked and no SDK state is written by diagnostics. + using var ct = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + using (var shared = new OpcUaClientService()) + { try { await shared.ConnectAsync(Settings("opc.tcp://127.0.0.1:1"), ct.Token); } catch { } } + await using (var driver = new OpcUaClientDriver(Options("opc.tcp://127.0.0.1:1"), "lifecycle")) + { try { await driver.InitializeAsync("{}", ct.Token); } catch { } } + var browser = new OpcUaClientDriverBrowser(); + try { await using var session = await browser.OpenAsync(JsonSerializer.Serialize(Options("opc.tcp://127.0.0.1:1")), ct.Token); } catch { } + foreach (var folder in new[] { "data/shared-pki", "data/OtOpcUa/pki", "data/OtOpcUa/adminui-browse-pki" }) + Require(Directory.GetFiles(Path.Combine(Root, folder, "own", "certs")).Length == 1, "Expected one reusable application DER certificate: " + folder); + } + static async Task Cycle(string endpoint, int cycle) + { + using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var ct = budget.Token; + using var shared = new OpcUaClientService(); Weak.Add(new(shared)); + await using var driver = new OpcUaClientDriver(Options(endpoint), "lifecycle"); Weak.Add(new(driver)); + var browser = new OpcUaClientDriverBrowser(); Weak.Add(new(browser)); + // Two connections on the same Shared service and two initializations on the same driver. + for (var pass = 0; pass < 2; pass++) + { + var seen = new ConcurrentQueue(); + EventHandler handler = (_, e) => { if (Clr(e.Value) is int n) seen.Enqueue(n); }; + shared.DataChanged += handler; + var info = await shared.ConnectAsync(Settings(endpoint), ct); + Require(info.SecurityMode == "SignAndEncrypt" && info.SecurityPolicyUri == SecurityPolicies.Basic256Sha256, "Shared policy downgrade"); + object? manager = SharedManager(shared); CheckManager(manager, false); + var nss = (string[])Clr(await shared.ReadValueAsync(VariableIds.Server_NamespaceArray, ct))!; + var ns = (ushort)Array.IndexOf(nss, "https://zb.com/otopcua/raw"); Require(ns > 0, "Raw namespace missing"); + var scalar = new NodeId("Matrix/dev/Scalar", ns); var vector = new NodeId("Matrix/dev/Vector", ns); + await shared.SubscribeAsync(scalar, 100, ct); await shared.SubscribeAsync(vector, 100, ct); + var target = cycle * 10 + pass; + Require(StatusCode.IsGood(await shared.WriteValueAsync(scalar, target, ct)), "Shared scalar write failed"); + Require(StatusCode.IsGood(await shared.WriteValueAsync(vector, new[] { target, 2, 3 }, ct)), "Shared vector write failed"); + await Wait(() => seen.Contains(target), ct); + Require(Equals(Clr(await shared.ReadValueAsync(scalar, ct)), target), "Shared scalar roundtrip mismatch"); + Require(((int[])Clr(await shared.ReadValueAsync(vector, ct))!).SequenceEqual(new[] { target, 2, 3 }), "Shared vector mismatch"); + Require((await shared.BrowseAsync(new NodeId("Matrix/dev", ns), ct)).Count > 0, "Shared browse empty"); + await shared.UnsubscribeAsync(scalar, ct); await shared.UnsubscribeAsync(vector, ct); + await shared.DisconnectAsync(ct); + Require(!shared.IsConnected && shared.CurrentConnectionInfo is null, "Shared public connection state retained"); + Require(FieldOrNull(shared, "_session") is null, "Shared session retained"); Empty(shared, "_activeDataSubscriptions"); + CheckManager(manager, true); manager = null; shared.DataChanged -= handler; sharedPasses++; + + if (pass == 0) await driver.InitializeAsync("{}", ct); + else await driver.ReinitializeAsync("{}", ct); + Require(driver.GetHealth().State == DriverState.Healthy, "Driver initialize health"); + manager = AppManager(driver); CheckManager(manager, false); + var values = new ConcurrentQueue(); + EventHandler driverHandler = (_, e) => { if (e.Snapshot.Value is int n) values.Enqueue(n); }; + driver.OnDataChange += driverHandler; + var sub = await driver.SubscribeAsync(["scalar", "vector"], TimeSpan.FromMilliseconds(100), ct); + target++; + var writes = await driver.WriteAsync([new WriteRequest("scalar", target), new WriteRequest("vector", new[] { target, 4, 5 })], ct); + Require(writes.Count == 2 && writes.All(w => w.StatusCode == 0), "Driver write failed"); + await Wait(() => values.Contains(target), ct); + var reads = await driver.ReadAsync(["scalar", "vector"], ct); + Require(reads.Count == 2 && reads.All(r => r.StatusCode == 0) && Equals(reads[0].Value, target) + && ((int[])reads[1].Value!).SequenceEqual(new[] { target, 4, 5 }), "Driver roundtrip mismatch"); + await driver.UnsubscribeAsync(sub, ct); sub = null!; + // Reinitialize a live connection with an active replacement subscription, as the watchdog does. + sub = await driver.SubscribeAsync(["scalar", "vector"], TimeSpan.FromMilliseconds(100), ct); + await driver.ReinitializeAsync("{}", ct); sub = null!; + CheckManager(manager, true); manager = null; + manager = AppManager(driver); CheckManager(manager, false); + Require(driver.GetHealth().State == DriverState.Healthy, "Live reinitialize failed"); + var reinitialized = await driver.ReadAsync(["scalar"], ct); + Require(reinitialized.Count == 1 && reinitialized[0].StatusCode == 0 && Equals(reinitialized[0].Value, target), "Live reinitialize read failed"); + await driver.ShutdownAsync(ct); driver.OnDataChange -= driverHandler; + Require(driver.GetHealth().State == DriverState.Unknown, "Driver shutdown public state retained"); + Require(FieldOrNull(driver, "k__BackingField") is null, "Driver session retained"); + Empty(driver, "_subscriptions"); Empty(driver, "_alarmSubscriptions"); + CheckManager(manager, true); manager = null; driverPasses++; + + var browse = await browser.OpenAsync(JsonSerializer.Serialize(Options(endpoint)), ct); Weak.Add(new(browse)); + manager = AppManager(browse); CheckManager(manager, false); + Require((await browse.RootAsync(ct)).Count > 0, "Browser root empty"); + await browse.DisposeAsync().AsTask().WaitAsync(ct); CheckManager(manager, true); manager = null; browserPasses++; + } + } + static object Resources(int cycle, double elapsed) + { + GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); + var survivors = Weak.Count(w => w.IsAlive); Weak.RemoveAll(w => !w.IsAlive); + Require(survivors == 0, "Disposed production client/driver/browser object retained after full GC"); + var fd = Directory.GetFiles("/proc/self/fd"); + var owned = 0; var pki = 0; + foreach (var path in fd) + { + var target = new FileInfo(path).LinkTarget; + if (target is null || !Path.IsPathFullyQualified(target)) continue; + target = Path.GetFullPath(target); + if (target.StartsWith(Path.GetFullPath(Root) + Path.DirectorySeparatorChar, StringComparison.Ordinal)) owned++; + if (target.StartsWith(Path.Combine(Path.GetFullPath(Root), "data") + Path.DirectorySeparatorChar, StringComparison.Ordinal)) pki++; + } + using var p = Process.GetCurrentProcess(); + return new { cycle, elapsedSeconds = elapsed, utc = DateTime.UtcNow, p.WorkingSet64, p.PrivateMemorySize64, + managedBytes = GC.GetTotalMemory(false), allocatedBytes = GC.GetTotalAllocatedBytes(), + gc = new[] { GC.CollectionCount(0), GC.CollectionCount(1), GC.CollectionCount(2) }, + fdCount = fd.Length, ownedRootFdCount = owned, ownedPkiFdCount = pki, survivingProductionObjects = survivors, + managerDisposalsVerified = managerChecks, sharedPasses, driverPasses, browserPasses }; + } + static async Task Run(string endpoint, int seconds, bool full) + { + Require(!full || seconds >= 7200, "Full soak requires >=7200 requested seconds"); + // Deliberate owned-manager positive control proves the exact-tag read-only diagnostic sees both states. + var control = CertificateManagerFactory.Create(new SecurityConfiguration(), DefaultTelemetry.Create(_ => { })); + Require(!Disposed(control), "Disposal diagnostic control false-state failure"); + await control.DisposeAsync(); Require(Disposed(control), "Disposal diagnostic control true-state failure"); + control = null!; + var originalCerts = CertificateHashes(); + var elapsed = Stopwatch.StartNew(); var cycle = 0; + File.AppendAllText(Path.Combine(Root, "resources.jsonl"), JsonSerializer.Serialize(Resources(0, 0)) + "\n"); + while (elapsed.Elapsed.TotalSeconds < seconds) + { + await Cycle(endpoint, ++cycle).WaitAsync(TimeSpan.FromSeconds(65)); + // The async cycle has returned, dropping its strong manager/session/item/event references. + await Task.Delay(300); + var serverPath = Path.Combine(Root, "server", "live-resources.json"); + var settledOk = false; + for (var attempt = 0; attempt < 30; attempt++) + { + using var settled = JsonDocument.Parse(await File.ReadAllTextAsync(serverPath)); + settledOk = new[] { "sessions", "subscriptions", "items" }.All(name => settled.RootElement.GetProperty(name).GetInt32() == 0); + if (settledOk) break; + await Task.Delay(100); + } + Require(settledOk, "Server sessions/subscriptions/items retained after bounded settling"); + if (cycle % 10 == 0 || elapsed.Elapsed.TotalSeconds >= seconds) + File.AppendAllText(Path.Combine(Root, "resources.jsonl"), JsonSerializer.Serialize(Resources(cycle, elapsed.Elapsed.TotalSeconds)) + "\n"); + } + Require(elapsed.Elapsed.TotalSeconds >= seconds && (!full || elapsed.Elapsed.TotalSeconds >= 7200), "Real elapsed duration gate failed"); + Require(managerChecks == cycle * 8 && sharedPasses == cycle * 2 && driverPasses == cycle * 2 && browserPasses == cycle * 2, "Incomplete lifecycle accounting"); + Require(CertificateHashes().SequenceEqual(originalCerts), "Application certificate unexpectedly rotated or PKI changed"); + var summary = new { status = "passed", diagnosticPositiveControl = "exact SDK2 manager false-before/true-after DisposeAsync", applicationCertificateHashes = originalCerts, requestedSeconds = seconds, elapsedSeconds = elapsed.Elapsed.TotalSeconds, + fullTwoHourLifecycle = full && elapsed.Elapsed.TotalSeconds >= 7200, cycles = cycle, sharedPasses, driverPasses, browserPasses, + managerDisposalsVerified = managerChecks, finalResources = Resources(cycle, elapsed.Elapsed.TotalSeconds), + scope = "Production Shared connect/disconnect/reconnect; production driver Initialize/Reinitialize/Shutdown/dispose; production browser open/browse/dispose; scalar and Int32[3] read/write/subscribe/unsubscribe; strict mutual trusted DER/Basic256Sha256 SignAndEncrypt/API-key fixture. No network stalls, revocation, surgical edits/rebuild, CP abandonment or Windows/AVEVA/performance G4 claim." }; + File.WriteAllText(Path.Combine(Root, "summary.json"), JsonSerializer.Serialize(summary, Json)); + } + static string[] CertificateHashes() => Directory.GetFiles(Path.Combine(Root, "data"), "*.der", SearchOption.AllDirectories) + .Where(p => p.Contains(Path.DirectorySeparatorChar + "own" + Path.DirectorySeparatorChar + "certs" + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + .Order().Select(p => Path.GetRelativePath(Root, p) + ":" + Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(p))).ToLowerInvariant()).ToArray(); + public static async Task Main(string[] args) + { + if (args[0] == "server") return await ProcessHost.HelperMain(args); + Root = Path.GetFullPath(args[1]); Directory.CreateDirectory(Root); + try + { + var local = Path.GetFullPath(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)); + Require(local == Path.Combine(Root, "data"), "XDG task PKI isolation failed"); + if (args[0] == "prepare") { await Prepare(); return 0; } + Require(args[0] == "run", "Unknown mode"); + await Run(args[2], int.Parse(args[3]), args[4] == "full"); + var sdk = AppDomain.CurrentDomain.GetAssemblies().Where(a => a.GetName().Name!.StartsWith("Opc.Ua", StringComparison.Ordinal)) + .Select(a => new { name = a.GetName().Name, version = a.GetName().Version?.ToString(), sha256 = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(a.Location))).ToLowerInvariant() }); + File.WriteAllText(Path.Combine(Root, "sdk-assemblies.json"), JsonSerializer.Serialize(sdk, Json)); + return 0; + } + catch (Exception error) + { + File.WriteAllText(Path.Combine(Root, "failure.txt"), error.ToString().Replace(Password, "")); + return 1; + } + } +} diff --git a/scripts/validation/opcua-client-lifecycle/Lifecycle.csproj b/scripts/validation/opcua-client-lifecycle/Lifecycle.csproj new file mode 100644 index 000000000..a7018f6a9 --- /dev/null +++ b/scripts/validation/opcua-client-lifecycle/Lifecycle.csproj @@ -0,0 +1,9 @@ + +ExeLifecycleSDK2 + + + + + + + \ No newline at end of file diff --git a/scripts/validation/opcua-client-lifecycle/ProcessHost.cs b/scripts/validation/opcua-client-lifecycle/ProcessHost.cs new file mode 100644 index 000000000..2a7322055 --- /dev/null +++ b/scripts/validation/opcua-client-lifecycle/ProcessHost.cs @@ -0,0 +1,360 @@ +// Copy of compatibility ProcessHost at 7b353d7b; lifecycle-owned changes only. +#pragma warning disable OTOPCUA0001 // This wire harness deliberately exercises public driver capabilities directly. +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Client; +using ZB.MOM.WW.OtOpcUa.Client.Shared; +using ZB.MOM.WW.OtOpcUa.Client.Shared.Models; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; +using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; +using AlarmEventArgs = ZB.MOM.WW.OtOpcUa.Client.Shared.Models.AlarmEventArgs; +using WriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest; +using SharedSecurityMode = ZB.MOM.WW.OtOpcUa.Client.Shared.Models.SecurityMode; +using CoreHistory = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult; + +internal static partial class ProcessHost +{ + static readonly DateTime Epoch = new(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc); + const string RawUri = "https://zb.com/otopcua/raw", UnsUri = "https://zb.com/otopcua/uns"; + const string Folder = "Matrix/dev", Scalar = Folder + "/Scalar", Vector = Folder + "/Vector", Alarm = Folder + "/Alarm"; + static readonly CancellationToken Ct = new CancellationTokenSource(TimeSpan.FromSeconds(int.Parse(Environment.GetEnvironmentVariable("OPCUA_LIFECYCLE_SERVER_SECONDS") ?? "240"))).Token; + static string Password => Environment.GetEnvironmentVariable("OPCUA_MATRIX_PASSWORD") ?? throw new InvalidOperationException("Missing ephemeral credential"); + static readonly List Cases = []; + static readonly List Observations = []; + static int Failures; + static string Root = ""; + static object? Clr(DataValue v) + { +#if SDK2 + return ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(v.WrappedValue); +#else + return v.Value; +#endif + } + static DateTime SourceTime(DataValue value) + { +#if SDK2 + return value.SourceTimestamp.ToDateTime(); +#else + return value.SourceTimestamp; +#endif + } + static DateTime ServerTime(DataValue value) + { +#if SDK2 + return value.ServerTimestamp.ToDateTime(); +#else + return value.ServerTimestamp; +#endif + } + static void Require(bool yes, string message) { if (!yes) throw new InvalidOperationException(message); } + static async Task Case(string name, Func action) + { + try { await action(); Cases.Add(new { name, status = "passed" }); } + catch (Exception e) { Failures++; Cases.Add(new { name, status = "failed", error = e.ToString() }); } + } + static async Task Wait(Func predicate) + { + for (int i = 0; i < 200; i++) { if (predicate()) return; await Task.Delay(50, Ct); } + Require(false, "Notification deadline exceeded"); + } + static ConnectionSettings Settings(string endpoint) => new() + { + EndpointUrl = endpoint, + Username = "apikey", + Password = Password, + SecurityMode = SharedSecurityMode.SignAndEncrypt, + AutoAcceptCertificates = false, + CertificateStorePath = Environment.GetEnvironmentVariable("OPCUA_MATRIX_CLIENT_PKI") ?? Path.Combine(Root, "client-pki") + }; + public static async Task HelperMain(string[] args) + { + Root = Path.GetFullPath(args[1]); Directory.CreateDirectory(Root); + try + { + switch (args[0]) + { + case "local-data-path": Console.WriteLine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)); return 0; + case "driver": await Driver(args[2]); break; + case "server": await Serve(); return 0; + case "client": await Client(args[2]); break; + case "performance": await Performance(args[2]); break; + default: throw new ArgumentException("Expected prepare/server/client"); + } + } + catch (Exception e) { Failures++; Cases.Add(new { name = "process", status = "failed", error = e.ToString() }); } + File.WriteAllText(Path.Combine(Root, "result.json"), JsonSerializer.Serialize(new { sdk = typeof(NodeId).Assembly.GetName().Version?.ToString(), process = Environment.ProcessId, cases = Cases, observations = Observations, failures = Failures, loadedSdkAssemblies = AppDomain.CurrentDomain.GetAssemblies().Where(a => a.GetName().Name!.StartsWith("Opc.Ua", StringComparison.Ordinal)).Select(a => new { name = a.GetName().Name, version = a.GetName().Version?.ToString(), path = a.Location }) }, new JsonSerializerOptions { WriteIndented = true })); + return Failures == 0 ? 0 : 1; + } + static async Task Serve() + { + var options = new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.Matrix", + ApplicationUri = "urn:OtOpcUa.Matrix.ClientLifecycle", + PublicHostname = "127.0.0.1", + PkiStoreRoot = Path.Combine(Root, "server-pki"), + AllowAnonymous = false, + AutoAcceptUntrustedClientCertificates = false, + EnabledSecurityProfiles = Environment.GetEnvironmentVariable("OPCUA_MATRIX_SERVER_SECURITY") == "none" + ? [OpcUaSecurityProfile.None] : [OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt] + }; + using (var listener = new TcpListener(IPAddress.Loopback, 0)) { listener.Start(); options.OpcUaPort = ((IPEndPoint)listener.LocalEndpoint).Port; } + var server = new OtOpcUaSdkServer(); + await using var host = new OpcUaApplicationHost(options, NullLogger.Instance, new Auth()); + await host.StartAsync(server, Ct); + var nm = server.NodeManager!; var sink = new SdkAddressSpaceSink(nm); + var authority = new DataPlaneAuthorityState(); + authority.Apply("matrix", "rev1", [new ApiKey { ApiKeyId = "matrix", Name = "matrix", SecretHash = "fixture", Principal = "matrix", Enabled = true, CreatedUtc = DateTime.UtcNow.AddDays(-1) }], + [new NodeAcl { NodeAclId = "all", ClusterId = "matrix", Principal = "matrix", ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = (NodePermissions)int.MaxValue }], new Dictionary>()); + nm.DataPlaneAuthority = authority; + nm.Authorizer = new DataPlaneAuthorizer(authority, new TriePermissionEvaluator(authority.Tries), true, NullLogger.Instance, TimeProvider.System); + var history = new Source(Root); nm.HistorianDataSource = history; + sink.EnsureFolder(Folder, null, "dev", AddressSpaceRealm.Raw); + sink.EnsureFolder("Eq", null, "Eq", AddressSpaceRealm.Uns); + foreach (var realm in new[] { AddressSpaceRealm.Raw, AddressSpaceRealm.Uns }) + { + var scalar = realm == AddressSpaceRealm.Raw ? Scalar : "Eq/Scalar"; + var vector = realm == AddressSpaceRealm.Raw ? Vector : "Eq/Vector"; + var parent = realm == AddressSpaceRealm.Raw ? Folder : "Eq"; + sink.EnsureVariable(scalar, parent, "Scalar", "Int32", true, realm, "matrix.scalar"); + sink.EnsureVariable(vector, parent, "Vector", "Int32", true, realm, isArray: true, arrayLength: 3); + sink.WriteValue(scalar, 17, 0, Epoch, realm); sink.WriteValue(vector, new[] { 1, 2, 3 }, 0, Epoch, realm); + } + sink.AddReference("Eq/Scalar", AddressSpaceRealm.Uns, Scalar, AddressSpaceRealm.Raw); + sink.MaterialiseAlarmCondition(Alarm, Folder, "Alarm", "OffNormalAlarm", 800, AddressSpaceRealm.Raw, isNative: true); + sink.WireAlarmNotifiers(Alarm, AddressSpaceRealm.Raw, ["Eq"], AddressSpaceRealm.Uns); + sink.EnsureVariable(Folder + "/UnsetTimestamp", Folder, "UnsetTimestamp", "Int32", false, AddressSpaceRealm.Raw); + sink.WriteValue(Folder + "/UnsetTimestamp", 3, 0, DateTime.MinValue, AddressSpaceRealm.Raw); + nm.NodeWriteGateway = new Gateway(); + var state = new AlarmConditionSnapshot(true, false, true, true, AlarmShelvingKind.Unshelved, 800, "matrix-alarm"); + nm.NativeAlarmAckRouter = _ => Task.Run(() => { state = state with { Acknowledged = true }; sink.WriteAlarmCondition(Alarm, state, DateTime.UtcNow, AddressSpaceRealm.Raw); }); + File.WriteAllText(Path.Combine(Root, "endpoint.txt"), $"opc.tcp://127.0.0.1:{options.OpcUaPort}/OtOpcUa"); + File.WriteAllText(Path.Combine(Root, "ready.json"), JsonSerializer.Serialize(new { endpoint = $"opc.tcp://127.0.0.1:{options.OpcUaPort}/OtOpcUa", process = Environment.ProcessId, sdk = typeof(NodeId).Assembly.GetName().Version?.ToString(), rawUri = RawUri, unsUri = UnsUri })); + int tick = 0; + while (!File.Exists(Path.Combine(Root, "stop"))) + { + sink.WriteAlarmCondition(Alarm, state with { Message = ++tick % 2 == 0 ? "matrix-alarm" : "matrix-tick" }, DateTime.UtcNow, AddressSpaceRealm.Raw); + var counts = ServerCounts(server); + File.WriteAllText(Path.Combine(Root, "live-resources.tmp"), JsonSerializer.Serialize(counts)); + File.Move(Path.Combine(Root, "live-resources.tmp"), Path.Combine(Root, "live-resources.json"), true); + await Task.Delay(250, Ct); + } + using var process = System.Diagnostics.Process.GetCurrentProcess(); + File.WriteAllText(Path.Combine(Root, "server-resources.json"), JsonSerializer.Serialize(new { process.WorkingSet64, process.PrivateMemorySize64, process.HandleCount, threads=process.Threads.Count, cpuMilliseconds=process.TotalProcessorTime.TotalMilliseconds, allocatedBytes=GC.GetTotalAllocatedBytes(), collections=new[]{GC.CollectionCount(0),GC.CollectionCount(1),GC.CollectionCount(2)} }, new JsonSerializerOptions {WriteIndented=true})); + } + static async Task Client(string endpoint) + { + using var client = new OpcUaClientService(); + await Case("trusted-SignAndEncrypt-API-key", async () => { var info = await client.ConnectAsync(Settings(endpoint), Ct); Require(info.SecurityMode == "SignAndEncrypt" && info.SecurityPolicyUri == SecurityPolicies.Basic256Sha256, "Unexpected negotiated policy"); Observations.Add(new { info.SecurityMode, info.SecurityPolicyUri }); }); + if (!client.IsConnected) return; + var namespaces = (string[])Clr(await client.ReadValueAsync(VariableIds.Server_NamespaceArray, Ct))!; + var raw = (ushort)Array.IndexOf(namespaces, RawUri); var uns = (ushort)Array.IndexOf(namespaces, UnsUri); + NodeId ScalarId(ushort ns) => new(ns == raw ? Scalar : "Eq/Scalar", ns); + NodeId VectorId(ushort ns) => new(ns == raw ? Vector : "Eq/Vector", ns); + await Case("raw-UNS-URI-browse", async () => + { + Require(raw > 0 && uns > 0, "Missing namespace URI"); + var rawChildren = await client.BrowseAsync(new NodeId(Folder, raw), Ct); + var unsChildren = await client.BrowseAsync(new NodeId("Eq", uns), Ct); + Observations.Add(new { rawChildren, unsChildren }); + // Supplement the production Shared results with unfiltered wire-reference metadata. + var adapter = typeof(OpcUaClientService).GetField("_session", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(client)!; + var sdkSession = (Opc.Ua.Client.Session)adapter.GetType().GetField("_session", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(adapter)!; + var (_, _, rawReferences) = await sdkSession.BrowseAsync(null, null, new NodeId(Folder, raw), 0u, + BrowseDirection.Forward, ReferenceTypeIds.References, true, 0u); + Observations.Add(new { rawReferences = (rawReferences.ToArray() ?? []).Select(reference => new { nodeId = reference.NodeId.ToString(), browseName = reference.BrowseName.ToString(), nodeClass = reference.NodeClass.ToString(), typeDefinition = reference.TypeDefinition.ToString(), referenceType = reference.ReferenceTypeId.ToString(), reference.IsForward }).ToArray() }); + var expectedRaw = new[] { Scalar, Vector, Alarm, Folder + "/UnsetTimestamp" }.Select(id => new NodeId(id, raw).ToString()); + Require(expectedRaw.All(id => rawChildren.Any(child => child.NodeId == id)), "Missing raw children: " + string.Join(",", expectedRaw.Where(id => !rawChildren.Any(child => child.NodeId == id)))); + Require(new[] { "Eq/Scalar", "Eq/Vector" }.Select(id => new NodeId(id, uns).ToString()).All(id => unsChildren.Any(child => child.NodeId == id)), "Missing UNS scalar/array children"); + }); + await Case("initial-quality-explicit-source-and-server-timestamps", async () => { var initial = await client.ReadValueAsync(ScalarId(raw), Ct); Require(StatusCode.IsGood(initial.StatusCode), "Bad initial quality"); Require(SourceTime(initial) == Epoch && ServerTime(initial) > Epoch, "Lost explicit initial timestamps"); Observations.Add(new { sourceTimestamp = SourceTime(initial).ToString("O"), serverTimestamp = ServerTime(initial).ToString("O"), contract = "Source supplied as 2026-10-01T00:00:00Z; server timestamp observed without normalization or historian algorithm changes" }); }); + foreach (var ns in new[] { raw, uns }) + { + await Case($"scalar-read-write-ns-{ns}", async () => { Require(Clr(await client.ReadValueAsync(ScalarId(ns), Ct)) is int, "Scalar lost Int32 type"); Require(StatusCode.IsGood(await client.WriteValueAsync(ScalarId(ns), 42, Ct)), "Scalar write failed"); Require((int)Clr(await client.ReadValueAsync(ScalarId(ns), Ct))! == 42, "Scalar round trip failed"); }); + await Case($"typed-array-read-write-ns-{ns}", async () => { Require(Clr(await client.ReadValueAsync(VectorId(ns), Ct)) is int[], "Array lost Int32[] type"); Require(StatusCode.IsGood(await client.WriteValueAsync(VectorId(ns), new[] { 7, 8, 9 }, Ct)), "Array write failed"); Require(((int[])Clr(await client.ReadValueAsync(VectorId(ns), Ct))!).SequenceEqual(new[] { 7, 8, 9 }), "Array round trip failed"); }); + } + await Case("monitor-quality-timestamps", async () => { var seen = new ConcurrentQueue(); client.DataChanged += (_, e) => seen.Enqueue(e.Value); await client.SubscribeAsync(ScalarId(raw), 100, Ct); await client.WriteValueAsync(ScalarId(raw), 73, Ct); await Wait(() => seen.Any(v => Equals(Clr(v), 73))); var v = seen.Last(v => Equals(Clr(v), 73)); Require(StatusCode.IsGood(v.StatusCode) && SourceTime(v) > Epoch && ServerTime(v) > Epoch, "Bad monitor quality or missing timestamps"); Observations.Add(new { sourceTimestamp = SourceTime(v).ToString("O"), serverTimestamp = ServerTime(v).ToString("O"), status = v.StatusCode.ToString() }); await client.UnsubscribeAsync(ScalarId(raw), Ct); }); + await Case("event-filter-and-native-ack", async () => { var alarms = new ConcurrentQueue(); client.AlarmEvent += (_, a) => alarms.Enqueue(a); await client.SubscribeAlarmsAsync(ObjectIds.Server, 100, Ct); await Wait(() => alarms.Any(a => a.Message == "matrix-alarm" && a.ActiveState && a.Severity == 800)); var alarm = alarms.Last(a => a.Message == "matrix-alarm"); Require(alarm.EventId?.Length > 0, "Missing event ID"); Require(StatusCode.IsGood(await client.AcknowledgeAlarmAsync(new NodeId(Alarm, raw).ToString(), alarm.EventId!, "matrix-ack", Ct)), "Ack refused"); await Wait(() => alarms.Any(a => a.Message == "matrix-alarm" && a.AckedState)); await client.UnsubscribeAlarmsAsync(Ct); }); + await Case("raw-history-CP-paging", async () => { var values = await client.HistoryReadRawAsync(ScalarId(raw), Epoch, Epoch.AddMinutes(1), 100, Ct); Require(values.Count == 25, "Expected 25 raw samples, got " + values.Count); Require(values.Select(v => Convert.ToInt32(Clr(v))).SequenceEqual(Enumerable.Range(0, 25)), "Raw page loss/duplicates/order"); }); + await Case("event-history-CP-paging", async () => + { + var filter = new EventFilter(); +#if SDK2 + filter.SelectClauses += new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId")) { AttributeId = Attributes.Value }; +#else + filter.SelectClauses.Add(new SimpleAttributeOperand {TypeDefinitionId=ObjectTypeIds.BaseEventType,BrowsePath=new QualifiedNameCollection {new QualifiedName("EventId")},AttributeId=Attributes.Value}); +#endif + var events = await client.HistoryReadEventsAsync(new NodeId(Folder, raw), Epoch, Epoch.AddMinutes(1), filter, 100, Ct); Require(events.Count == 25, "Expected 25 event samples, got " + events.Count); +#if SDK2 + var ids = events.Select(e => System.Text.Encoding.UTF8.GetString((byte[])ZB.MOM.WW.OtOpcUa.Client.Shared.Helpers.ValueConverter.ToClrObject(e.Fields[0])!)); +#else + var ids=events.Select(e=>System.Text.Encoding.UTF8.GetString((byte[])e.Fields[0].Value)); +#endif + Require(ids.SequenceEqual(Enumerable.Range(0, 25).Select(i => $"e{i:00}")), "Event history loss/duplicates/order across timestamp ties"); + }); + await Case("event-history-where-filter-positive-and-negative", async () => + { + foreach (var type in new[] { ObjectTypeIds.BaseEventType, ObjectTypeIds.AuditEventType }) + { + var filter = new EventFilter(); +#if SDK2 + filter.SelectClauses += new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId")) { AttributeId = Attributes.Value }; + filter.WhereClause.Push(FilterOperator.OfType, Variant.From(type)); +#else + filter.SelectClauses.Add(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType,new QualifiedName("EventId")){AttributeId=Attributes.Value}); + filter.WhereClause.Push(FilterOperator.OfType,new LiteralOperand(new Variant(type))); +#endif + var events = await client.HistoryReadEventsAsync(new NodeId(Folder, raw), Epoch, Epoch.AddMinutes(1), filter, 100, Ct); + Require(events.Count == (type == ObjectTypeIds.BaseEventType ? 25 : 0), $"Where filter for {type} expected {(type == ObjectTypeIds.BaseEventType ? 25 : 0)} got {events.Count}"); + } + }); + var unset = await client.ReadValueAsync(new NodeId(Folder + "/UnsetTimestamp", raw), Ct); + Observations.Add(new { sourceTimestamp = SourceTime(unset).ToString("O"), serverTimestamp = ServerTime(unset).ToString("O"), timestampObservation = "Fixture source supplied DateTime.MinValue (unset). These are unmodified application/SDK wire observations; no historian algorithm or expected timestamp was changed." }); + await Benchmark(client, ScalarId(raw), VectorId(raw)); + await client.DisconnectAsync(Ct); + } + static OpcUaClientDriverOptions DriverOptions(string endpoint) => new() + { + EndpointUrl = endpoint, + SecurityPolicy = OpcUaSecurityPolicy.Basic256Sha256, + SecurityMode = OpcUaSecurityMode.SignAndEncrypt, + AuthType = OpcUaAuthType.Username, + Username = "apikey", + Password = Password, + AutoAcceptCertificates = false, + RawTags = [new("scalar",JsonSerializer.Serialize(new {nodeId=$"nsu={RawUri};s={Scalar}"}),true), + new("vector",JsonSerializer.Serialize(new {nodeId=$"nsu={RawUri};s={Vector}"}),true), + new("uns",JsonSerializer.Serialize(new {nodeId=$"nsu={UnsUri};s=Eq/Scalar"}),true)], + SessionName = "MatrixDriver", + ApplicationUri = "urn:localhost:MatrixDriver" + }; + static string IsolatedLocalData() + { + var local = Path.GetFullPath(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)); + Require(local.StartsWith(Path.GetFullPath(Root) + Path.DirectorySeparatorChar, StringComparison.Ordinal), "Driver/CLI canonical PKI must be in task-owned root: " + local); + return local; + } + static async Task Driver(string endpoint) + { + IsolatedLocalData(); + await using var driver = new OpcUaClientDriver(DriverOptions(endpoint), "matrix"); + await Case("driver-trusted-encrypted-API-key-connect", () => driver.InitializeAsync("{}", Ct)); + await Case("driver-raw-UNS-URI-scalar-and-array-read", async () => + { + var values = await driver.ReadAsync(["scalar", "vector", "uns"], Ct); + Require(values.Count == 3 && values.All(x => x.StatusCode == 0), "Read failure"); + Require(values[0].Value is int && values[1].Value is int[] && values[2].Value is int, "Lost scalar/array type"); + }); + await Case("driver-scalar-array-write-roundtrip", async () => + { + var writes = await driver.WriteAsync([new WriteRequest("scalar", 91), new WriteRequest("vector", new[] { 11, 12, 13 })], Ct); + Require(writes.All(x => x.StatusCode == 0), "Write failed"); + var values = await driver.ReadAsync(["scalar", "vector"], Ct); + Require(Equals(values[0].Value, 91) && ((int[])values[1].Value!).SequenceEqual(new[] { 11, 12, 13 }), "Write round trip mismatch"); + }); + await Case("driver-monitor-quality-timestamps", async () => + { + var seen = new ConcurrentQueue(); + driver.OnDataChange += (_, e) => seen.Enqueue(e.Snapshot); + var subscription = await driver.SubscribeAsync(["scalar"], TimeSpan.FromMilliseconds(100), Ct); + await driver.WriteAsync([new WriteRequest("scalar", 92)], Ct); + await Wait(() => seen.Any(x => Equals(x.Value, 92))); + var value = seen.Last(x => Equals(x.Value, 92)); + Require(value.StatusCode == 0 && value.SourceTimestampUtc.HasValue && value.ServerTimestampUtc > Epoch, "Missing monitor quality/timestamps"); + Observations.Add(new { value.SourceTimestampUtc, value.ServerTimestampUtc, value.StatusCode }); + await driver.UnsubscribeAsync(subscription, Ct); + }); + Observations.Add(new { capability = "driver-history", status = "not-applicable", evidence = "Driver has no IHistoryProvider/HistoryRead capability; .claude/rules/drivers.md #655. Shared/CLI own history." }); + await driver.ShutdownAsync(Ct); + } + static async Task Performance(string endpoint) + { + using var client = new OpcUaClientService(); + var info = await client.ConnectAsync(Settings(endpoint), Ct); + Require(info.SecurityMode == "SignAndEncrypt" && info.SecurityPolicyUri == SecurityPolicies.Basic256Sha256, "Unexpected benchmark endpoint policy"); + var namespaces = (string[])Clr(await client.ReadValueAsync(VariableIds.Server_NamespaceArray, Ct))!; + var raw = (ushort)Array.IndexOf(namespaces, RawUri); + Require(raw > 0, "Missing benchmark raw namespace"); + Observations.Add(new {info.SecurityMode, info.SecurityPolicyUri, tieredCompilation=Environment.GetEnvironmentVariable("DOTNET_TieredCompilation"), scope="Two scalar/Int32[3] variables; zero data monitors during measurement; five fixture variables and one native condition"}); + await Case("warmed-production-client-read-write-measurement", () => Benchmark(client, new NodeId(Scalar, raw), new NodeId(Vector, raw))); + await client.DisconnectAsync(Ct); + } + static async Task Benchmark(OpcUaClientService client, NodeId scalar, NodeId vector) + { + // Local diagnostic subset; excludes reconnect, history and load. + var rounds = int.Parse(Environment.GetEnvironmentVariable("OPCUA_MATRIX_PERF_ROUNDS") ?? "3"); + var warmups = int.Parse(Environment.GetEnvironmentVariable("OPCUA_MATRIX_PERF_WARMUPS") ?? "10"); + var measured = int.Parse(Environment.GetEnvironmentVariable("OPCUA_MATRIX_PERF_MEASURED") ?? "50"); + Require(rounds > 0 && warmups >= 0 && measured > 0, "Invalid performance sample counts"); + for (int round = 1; round <= rounds; round++) + { + for (int warmup = 0; warmup < warmups; warmup++) + { + await client.ReadValueAsync(scalar, Ct); await client.WriteValueAsync(scalar, warmup, Ct); + await client.ReadValueAsync(vector, Ct); await client.WriteValueAsync(vector, new[] { warmup, warmup + 1, warmup + 2 }, Ct); + } + var samples = new Dictionary>(); + var before = System.Diagnostics.Process.GetCurrentProcess(); var cpu = before.TotalProcessorTime; + var allocated = GC.GetTotalAllocatedBytes(); var gc = new[] { GC.CollectionCount(0), GC.CollectionCount(1), GC.CollectionCount(2) }; + foreach (var operation in new[] { "scalar-read", "scalar-write", "array-read", "array-write" }) + { + var times = new List(); + for (int i = 0; i < measured; i++) + { + var start = System.Diagnostics.Stopwatch.GetTimestamp(); + if (operation == "scalar-read") await client.ReadValueAsync(scalar, Ct); + else if (operation == "scalar-write") Require(StatusCode.IsGood(await client.WriteValueAsync(scalar, i, Ct)), "Benchmark scalar write"); + else if (operation == "array-read") await client.ReadValueAsync(vector, Ct); + else Require(StatusCode.IsGood(await client.WriteValueAsync(vector, new[] { i, i + 1, i + 2 }, Ct)), "Benchmark array write"); + times.Add(System.Diagnostics.Stopwatch.GetElapsedTime(start).TotalMilliseconds); + } + samples[operation] = times; + } + before.Refresh(); + Observations.Add(new + { + benchmarkRound = round, + warmupOperationsPerKind = 10, + operationsPerKind = 50, + latencyMilliseconds = samples.ToDictionary(x => x.Key, x => new { p50 = x.Value.Order().ElementAt((int)Math.Ceiling(measured * 0.50) - 1), p95 = x.Value.Order().ElementAt((int)Math.Ceiling(measured * 0.95) - 1), p99 = x.Value.Order().ElementAt((int)Math.Ceiling(measured * 0.99) - 1), throughputOperationsPerSecond = measured * 1000.0 / x.Value.Sum() }), + clientCpuMilliseconds = (before.TotalProcessorTime - cpu).TotalMilliseconds, + clientAllocatedBytes = GC.GetTotalAllocatedBytes() - allocated, clientAllocatedBytesPerOperation = (GC.GetTotalAllocatedBytes() - allocated) / (4.0 * measured), + clientWorkingSetBytes = before.WorkingSet64, + clientPrivateBytes = before.PrivateMemorySize64, + clientThreads = before.Threads.Count, + clientHandles = before.HandleCount, + collections = new[] { GC.CollectionCount(0) - gc[0], GC.CollectionCount(1) - gc[1], GC.CollectionCount(2) - gc[2] } + }); + } + } + sealed class Auth : IOpcUaUserAuthenticator + { + public Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct) => Task.FromResult(username == "apikey" && password == Password ? OpcUaUserAuthResult.AllowKey("matrix", [OpcUaDataPlaneRoles.WriteOperate, OpcUaDataPlaneRoles.AlarmAck], "matrix") : OpcUaUserAuthResult.Deny("Invalid ephemeral fixture key")); + } + sealed class Gateway : IOpcUaNodeWriteGateway { public Task WriteAsync(string id, object? value, AddressSpaceRealm realm, CancellationToken ct) => Task.FromResult(new NodeWriteOutcome(true, null)); } + sealed class Source(string root) : IHistorianDataSource + { + void Log(string operation, int rows) => File.AppendAllText(Path.Combine(root, "history-requests.jsonl"), JsonSerializer.Serialize(new { operation, rows }) + "\n"); + public int MaxRawValuesPerRead => 7; + public Task ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) + { var all = Enumerable.Range(0, 25).Select(i => new DataValueSnapshot(i, 0, Epoch.AddSeconds(i), Epoch.AddSeconds(i))).Where(v => v.SourceTimestampUtc >= s && v.SourceTimestampUtc < e).ToList(); var rows = all.Take((int)(m == 0 ? 7 : m)).ToArray(); Log("raw", rows.Length); return Task.FromResult(new CoreHistory(rows, all.Count > rows.Length ? [1] : null)); } + public Task ReadEventsAsync(string? source, DateTime s, DateTime e, int max, CancellationToken ct) + { var all = Enumerable.Range(0, 25).Select(i => new HistoricalEvent($"e{i:00}", "matrix", Epoch.AddSeconds(i / 2), Epoch, "matrix-history", 500)).Where(v => v.EventTimeUtc >= s && v.EventTimeUtc < e).ToList(); var rows = all.Take(Math.Min(7, max <= 0 ? 7 : max)).ToArray(); Log("events", rows.Length); return Task.FromResult(new HistoricalEventsResult(rows, all.Count > rows.Length ? [1] : null)); } + public Task ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) => NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct); + public Task ReadAtTimeAsync(string r, IReadOnlyList t, CancellationToken ct) => NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct); + public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); + public void Dispose() { } + } +} diff --git a/scripts/validation/opcua-client-lifecycle/README.md b/scripts/validation/opcua-client-lifecycle/README.md new file mode 100644 index 000000000..19f15c92b --- /dev/null +++ b/scripts/validation/opcua-client-lifecycle/README.md @@ -0,0 +1,96 @@ +# Production OPC UA client lifecycle diagnostic + +This standalone harness runs the unchanged production `OpcUaClientService`, +`OpcUaClientDriver`, and `OpcUaClientDriverBrowser` in one persistent Linux client +process against a separate process using the production SDK server and node +manager. Both processes load OPC Foundation SDK 2.0.1. The server fixture is a +local copy of the compatibility harness at commit `7b353d7b`; only this directory's +copy has configurable lifetime and atomic public resource snapshots. + +Run a short diagnostic from a checkout containing the SDK 2.0.1 changes: + +```sh +python3 scripts/validation/opcua-client-lifecycle/run.py \ + --duration 90 --output /tmp/otopcua-client-lifecycle-diagnostic-unique +``` + +The coordinator must launch and own the full process separately: + +```sh +python3 scripts/validation/opcua-client-lifecycle/run.py \ + --full --duration 7200 --output /tmp/otopcua-client-lifecycle-full-unique +``` + +The output path must not exist. `--full` rejects a duration below 7200 seconds; +`fullTwoHourLifecycle` is true only after the client monotonic clock has actually +elapsed at least 7200 seconds and every final check completes. A diagnostic pass +cannot satisfy that gate. The outer process also has a duration plus 300-second +hard deadline and forcibly removes its own container on timeout. + +The launcher publishes this standalone project, then uses the locally cached +official `mcr.microsoft.com/dotnet/sdk:10.0.103-noble` Linux arm64 image with +`--network none`, no host ports and fresh task-owned PKI. `XDG_DATA_HOME` is the +only storage environment override; it never changes `HOME`. A generated +credential stays in the child process environment. The output root is mode 0700 +and contains generated private keys: **never commit the entire output directory**. +Commit only the selected redacted JSON/log receipts under `evidence/`. + +Preparation invokes bounded failing connections through each product's public +API to create its own application certificate. It does not reflectively invoke +configuration builders or replace the store. Before the live session, public DER +certificates are exchanged explicitly among the three clients and server. +Autoaccept is disabled on both ends. Negotiation must be Basic256Sha256 +SignAndEncrypt and API-key-shaped credentials must pass the fixture authenticator +and production authority/ACL path. Existing own DER hashes must remain unchanged +across all loop iterations. + +Every cycle performs two Shared connect/read/write/subscribe/unsubscribe/ +disconnect rounds on the same object. It performs two driver initialize/read/ +write/subscribe/unsubscribe rounds, each followed by a live reinitialize with an +active subscription, then shutdown. It opens, browses and disposes two production +AdminUI browser sessions. Scalar Int32 and Int32[3] writes must roundtrip exactly, +and both Shared and driver scalar data notifications must arrive. + +The external harness reads ownership fields for diagnostics only. Fields are +looked up by exact name, with missing/null layouts failing the run. It does not +write reflected fields or change SDK state. For SDK 2.0.1.0 only, each captured +`CertificateManager.m_disposed` boolean must be false while connected and true +after release. An independently created, deliberately disposed SDK manager proves +the before/after probe. All strong manager references are dropped each pass. +Eight released managers are checked per cycle: two Shared, four driver (including +live reinitialization), and two browser. Production session and data subscription +collections must clear, and public Shared connection information and driver health +must return to disconnected/Unknown. Collected weak references to disposed client, +driver, browser and browse-session objects must have no survivors at samples. + +After every cycle the server's public session/subscription/monitored-item counts +must return to zero within a bounded settle period. Resource samples capture GC, +managed allocation/retained bytes, RSS/private bytes, global FDs and task/PKI FDs. +Owned-path FD matching canonicalizes absolute paths and uses a path-separator +boundary. Sampling starts cold, then every ten cycles and at completion. RSS and +FD observations require interpretation after warmup; they are not performance +budgets or an automatic G4 acceptance decision. + +Artifacts are `result.json`, `source.json`, `command.json`, `build.log`, +`container.log`, and `task/{summary.json,resources.jsonl,sdk-assemblies.json, +client.log,server.log,prepare.log,server/live-resources.json}`. A failure additionally +writes a redacted `task/failure.txt`. The server uses a fixed application URI and +the three client certificate stores are reused for the entire process lifetime. + +This is a small local lifecycle gate, not the entire Task 12 gate. It does not +cover certificate revocation, network stalls, node surgical edits/rebuild, +continuation-point abandonment/churn, representative driver fanout/load, +Windows install/publish, ScadaBridge or AVEVA interoperability. It makes no +baseline/candidate performance or full G4 claim. The coordinator's separate server +lifecycle harness supplies complementary SDK session/CP/server mutation coverage. + +The checked-in 2026-11-10 diagnostic completed **91.104 seconds**, 68 cycles, +136 Shared rounds, 136 driver rounds (plus 136 live driver reinitializations), +136 browser sessions, and 544 verified released certificate managers. The final +sample had zero surviving weak product objects and zero owned PKI FDs; server +sessions/subscriptions/items returned to zero after every cycle. Warm samples +showed 146–148 total FDs; the final managed heap was 4,737,536 bytes and RSS was +181,018,624 bytes. The deliberate manager before/after control passed and +`--full --duration 90` exited 2 before creating artifacts. This is diagnostic +coverage only; the two-hour gate remains pending until a coordinator-owned full +run completes. Receipts are under `evidence/2026-11-10-diagnostic/`. diff --git a/scripts/validation/opcua-client-lifecycle/container.sh b/scripts/validation/opcua-client-lifecycle/container.sh new file mode 100755 index 000000000..05bbd0c60 --- /dev/null +++ b/scripts/validation/opcua-client-lifecycle/container.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Disposable network-isolated container only. All PKI is owned by this fresh task root. +set -euo pipefail +mkdir -p /task/data /task/server +export XDG_DATA_HOME=/task/data +export OPCUA_LIFECYCLE_SERVER_SECONDS=$((LIFECYCLE_SECONDS + 300)) +dotnet /app/Lifecycle.dll prepare /task > /task/prepare.log 2>&1 +mkdir -p /task/server/server-pki/trusted/certs +for pki in /task/data/shared-pki /task/data/OtOpcUa/pki /task/data/OtOpcUa/adminui-browse-pki; do + cp "$pki"/own/certs/* /task/server/server-pki/trusted/certs/ +done +dotnet /app/Lifecycle.dll server /task/server > /task/server.log 2>&1 & +server_pid=$! +cleanup() { + touch /task/server/stop + for attempt in $(seq 1 150); do + kill -0 "$server_pid" 2>/dev/null || break + sleep 0.1 + done + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" || true +} +trap cleanup EXIT +for attempt in $(seq 1 600); do + test ! -f /task/server/ready.json || break + kill -0 "$server_pid" || exit 1 + sleep 0.1 +done +test -f /task/server/ready.json +for pki in /task/data/shared-pki /task/data/OtOpcUa/pki /task/data/OtOpcUa/adminui-browse-pki; do + mkdir -p "$pki/trusted/certs" + cp /task/server/server-pki/own/certs/* "$pki/trusted/certs/" +done +endpoint=$(cat /task/server/endpoint.txt) +dotnet /app/Lifecycle.dll run /task "$endpoint" "$LIFECYCLE_SECONDS" "$LIFECYCLE_MODE" > /task/client.log 2>&1 diff --git a/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/build.log b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/build.log new file mode 100644 index 000000000..b14e5257a --- /dev/null +++ b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/build.log @@ -0,0 +1,63 @@ + + Determining projects to restore... + All projects are up-to-date for restore. + ZB.MOM.WW.OtOpcUa.Analyzers -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/bin/Release/netstandard2.0/ZB.MOM.WW.OtOpcUa.Analyzers.dll + ZB.MOM.WW.OtOpcUa.Core.Abstractions -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Core.Abstractions.dll + ZB.MOM.WW.OtOpcUa.Commons -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Commons/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Commons.dll + ZB.MOM.WW.OtOpcUa.Cluster -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Cluster.dll + ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.dll + ZB.MOM.WW.OtOpcUa.Core.Scripting -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Core.Scripting.dll + ZB.MOM.WW.OtOpcUa.Core.VirtualTags -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.dll + ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.dll + ZB.MOM.WW.OtOpcUa.Configuration -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Configuration.dll + ZB.MOM.WW.OtOpcUa.Core -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Core/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Core.dll + ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol.dll + ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess.dll + ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.dll + ZB.MOM.WW.OtOpcUa.Security -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Security/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Security.dll + ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.dll + ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.dll + ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts.dll + ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts.dll + ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts.dll + ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts.dll + ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.dll + ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.dll + ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.dll + ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.dll + ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.dll + ZB.MOM.WW.OtOpcUa.ControlPlane -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.ControlPlane.dll + ZB.MOM.WW.OtOpcUa.OpcUaServer -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.OpcUaServer.dll + ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.dll + ZB.MOM.WW.OtOpcUa.Runtime -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Runtime.dll + ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.dll + ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.dll + ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.dll + ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.dll + ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.dll + ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.dll + ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport.dll + ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.dll + ZB.MOM.WW.OtOpcUa.AdminUI -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.AdminUI.dll + ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.dll + ZB.MOM.WW.OtOpcUa.Driver.AbCip -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.dll + ZB.MOM.WW.OtOpcUa.Driver.AbLegacy -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.dll + ZB.MOM.WW.OtOpcUa.Driver.Calculation -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.Calculation.dll + ZB.MOM.WW.OtOpcUa.Driver.FOCAS -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.dll + ZB.MOM.WW.OtOpcUa.Driver.Galaxy -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.dll + ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.dll + ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.dll + ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.dll + ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.dll + ZB.MOM.WW.OtOpcUa.Driver.Modbus -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.Modbus.dll + ZB.MOM.WW.OtOpcUa.Driver.Mqtt -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.dll + ZB.MOM.WW.OtOpcUa.Driver.MTConnect -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.dll + ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.dll + ZB.MOM.WW.OtOpcUa.Driver.S7 -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.S7.dll + ZB.MOM.WW.OtOpcUa.Driver.Sql -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.Sql.dll + ZB.MOM.WW.OtOpcUa.Driver.TwinCAT -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.dll + ZB.MOM.WW.OtOpcUa.ScriptWorker -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.ScriptWorker/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.ScriptWorker.dll + ZB.MOM.WW.OtOpcUa.Host -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Host/bin/Release/net10.0/OtOpcUa.Host.dll + ZB.MOM.WW.OtOpcUa.Client.Shared -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/bin/Release/net10.0/ZB.MOM.WW.OtOpcUa.Client.Shared.dll + Lifecycle -> /Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/scripts/validation/opcua-client-lifecycle/bin/Release/net10.0/Lifecycle.dll + Lifecycle -> /private/tmp/opcua2-production-client-diagnostic-20261110c/app/ diff --git a/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/client.log b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/client.log new file mode 100644 index 000000000..e69de29bb diff --git a/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/command.json b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/command.json new file mode 100644 index 000000000..a15472ec8 --- /dev/null +++ b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/command.json @@ -0,0 +1,25 @@ +{ + "argv": [ + "docker", + "run", + "--rm", + "--name", + "otopcua-client-lifecycle-a2c271bbeb54", + "--network", + "none", + "-e", + "OPCUA_MATRIX_PASSWORD", + "-e", + "LIFECYCLE_SECONDS", + "-e", + "LIFECYCLE_MODE", + "-v", + "/private/tmp/opcua2-production-client-diagnostic-20261110c/app:/app:ro", + "-v", + "/private/tmp/opcua2-production-client-diagnostic-20261110c/task:/task", + "mcr.microsoft.com/dotnet/sdk:10.0.103-noble", + "bash", + "/task/container.sh" + ], + "timeoutSeconds": 390 +} diff --git a/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/container.log b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/container.log new file mode 100644 index 000000000..e69de29bb diff --git a/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/full-duration-guard.log b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/full-duration-guard.log new file mode 100644 index 000000000..abae39c01 --- /dev/null +++ b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/full-duration-guard.log @@ -0,0 +1,3 @@ +usage: run.py [-h] --output OUTPUT [--duration DURATION] [--full] + [--image IMAGE] +run.py: error: --full requires duration >=7200; diagnostic duration must be positive diff --git a/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/prepare.log b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/prepare.log new file mode 100644 index 000000000..e69de29bb diff --git a/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/resources.jsonl b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/resources.jsonl new file mode 100644 index 000000000..f4c18c16a --- /dev/null +++ b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/resources.jsonl @@ -0,0 +1,8 @@ +{"cycle":0,"elapsedSeconds":0,"utc":"2026-11-10T17:39:34.5140163Z","WorkingSet64":50884608,"PrivateMemorySize64":87019520,"managedBytes":243384,"allocatedBytes":307688,"gc":[2,2,2],"fdCount":66,"ownedRootFdCount":4,"ownedPkiFdCount":0,"survivingProductionObjects":0,"managerDisposalsVerified":0,"sharedPasses":0,"driverPasses":0,"browserPasses":0} +{"cycle":10,"elapsedSeconds":13.7816334,"utc":"2026-11-10T17:39:48.307566Z","WorkingSet64":168873984,"PrivateMemorySize64":210952192,"managedBytes":4622440,"allocatedBytes":53385048,"gc":[5,4,4],"fdCount":146,"ownedRootFdCount":4,"ownedPkiFdCount":0,"survivingProductionObjects":0,"managerDisposalsVerified":80,"sharedPasses":20,"driverPasses":20,"browserPasses":20} +{"cycle":20,"elapsedSeconds":26.955914,"utc":"2026-11-10T17:40:01.4729188Z","WorkingSet64":181080064,"PrivateMemorySize64":230060032,"managedBytes":4716120,"allocatedBytes":100286768,"gc":[8,6,6],"fdCount":146,"ownedRootFdCount":4,"ownedPkiFdCount":0,"survivingProductionObjects":0,"managerDisposalsVerified":160,"sharedPasses":40,"driverPasses":40,"browserPasses":40} +{"cycle":30,"elapsedSeconds":40.3288294,"utc":"2026-11-10T17:40:14.8512936Z","WorkingSet64":181923840,"PrivateMemorySize64":230674432,"managedBytes":4624608,"allocatedBytes":146998480,"gc":[11,8,8],"fdCount":146,"ownedRootFdCount":4,"ownedPkiFdCount":0,"survivingProductionObjects":0,"managerDisposalsVerified":240,"sharedPasses":60,"driverPasses":60,"browserPasses":60} +{"cycle":40,"elapsedSeconds":53.6437668,"utc":"2026-11-10T17:40:28.1646671Z","WorkingSet64":183074816,"PrivateMemorySize64":232931328,"managedBytes":4503968,"allocatedBytes":193703712,"gc":[14,10,10],"fdCount":148,"ownedRootFdCount":4,"ownedPkiFdCount":0,"survivingProductionObjects":0,"managerDisposalsVerified":320,"sharedPasses":80,"driverPasses":80,"browserPasses":80} +{"cycle":50,"elapsedSeconds":66.9338459,"utc":"2026-11-10T17:40:41.4571518Z","WorkingSet64":184758272,"PrivateMemorySize64":232972288,"managedBytes":4503736,"allocatedBytes":240424880,"gc":[17,12,12],"fdCount":148,"ownedRootFdCount":4,"ownedPkiFdCount":0,"survivingProductionObjects":0,"managerDisposalsVerified":400,"sharedPasses":100,"driverPasses":100,"browserPasses":100} +{"cycle":60,"elapsedSeconds":80.254683,"utc":"2026-11-10T17:40:54.7806201Z","WorkingSet64":180539392,"PrivateMemorySize64":233132032,"managedBytes":4504072,"allocatedBytes":287112440,"gc":[20,14,14],"fdCount":148,"ownedRootFdCount":4,"ownedPkiFdCount":0,"survivingProductionObjects":0,"managerDisposalsVerified":480,"sharedPasses":120,"driverPasses":120,"browserPasses":120} +{"cycle":68,"elapsedSeconds":91.0947059,"utc":"2026-11-10T17:41:05.614513Z","WorkingSet64":180760576,"PrivateMemorySize64":233250816,"managedBytes":4739896,"allocatedBytes":324761520,"gc":[23,16,16],"fdCount":148,"ownedRootFdCount":4,"ownedPkiFdCount":0,"survivingProductionObjects":0,"managerDisposalsVerified":544,"sharedPasses":136,"driverPasses":136,"browserPasses":136} diff --git a/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/result.json b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/result.json new file mode 100644 index 000000000..74aa1d803 --- /dev/null +++ b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/result.json @@ -0,0 +1,50 @@ +{ + "exitCode": 0, + "containerElapsedSeconds": 93.73282008292153, + "summary": { + "status": "passed", + "diagnosticPositiveControl": "exact SDK2 manager false-before/true-after DisposeAsync", + "applicationCertificateHashes": [ + "data/OtOpcUa/adminui-browse-pki/own/certs/OtOpcUa AdminUI Browse [14579B861300836A2A96549E307DB4123778B0C5].der:89e21ac01a5582dd04df77add2fad19495c695b69f4a48a05d2c71283325503b", + "data/OtOpcUa/pki/own/certs/ClientLifecycle [AF2D40052FCDDC9A8C09686E6E8E0F17CFF5A979].der:e9bdc63bc2cc0b7901389f958b2fd6bfaf5369f439dc996e152668a3fa3c381c", + "data/shared-pki/own/certs/OtOpcUaClient [91C87A8D0AC11919E0AFD44457BF932F3587F1B0].der:b4f2018a43f161e18d25f7b8bb80590ccec13afcf7b9189f6d97e10a68b55602" + ], + "requestedSeconds": 90, + "elapsedSeconds": 91.1041217, + "fullTwoHourLifecycle": false, + "cycles": 68, + "sharedPasses": 136, + "driverPasses": 136, + "browserPasses": 136, + "managerDisposalsVerified": 544, + "finalResources": { + "cycle": 68, + "elapsedSeconds": 91.1041218, + "utc": "2026-11-10T17:41:05.6187359Z", + "WorkingSet64": 181018624, + "PrivateMemorySize64": 233250816, + "managedBytes": 4737536, + "allocatedBytes": 324891240, + "gc": [ + 25, + 18, + 18 + ], + "fdCount": 148, + "ownedRootFdCount": 4, + "ownedPkiFdCount": 0, + "survivingProductionObjects": 0, + "managerDisposalsVerified": 544, + "sharedPasses": 136, + "driverPasses": 136, + "browserPasses": 136 + }, + "scope": "Production Shared connect/disconnect/reconnect; production driver Initialize/Reinitialize/Shutdown/dispose; production browser open/browse/dispose; scalar and Int32[3] read/write/subscribe/unsubscribe; strict mutual trusted DER/Basic256Sha256 SignAndEncrypt/API-key fixture. No network stalls, revocation, surgical edits/rebuild, CP abandonment or Windows/AVEVA/performance G4 claim." + }, + "applicationCertificates": { + "data/OtOpcUa/adminui-browse-pki/own/certs/OtOpcUa AdminUI Browse [14579B861300836A2A96549E307DB4123778B0C5].der": "89e21ac01a5582dd04df77add2fad19495c695b69f4a48a05d2c71283325503b", + "data/OtOpcUa/pki/own/certs/ClientLifecycle [AF2D40052FCDDC9A8C09686E6E8E0F17CFF5A979].der": "e9bdc63bc2cc0b7901389f958b2fd6bfaf5369f439dc996e152668a3fa3c381c", + "data/shared-pki/own/certs/OtOpcUaClient [91C87A8D0AC11919E0AFD44457BF932F3587F1B0].der": "b4f2018a43f161e18d25f7b8bb80590ccec13afcf7b9189f6d97e10a68b55602", + "server/server-pki/own/certs/OtOpcUa.Matrix [29412ADF65A3E370B5CDCABE9386217B43E1C1C1].der": "c3beb0f1c0536635c7ceb0dcfc42c6220d78d4a38c00ef199c137a6ba36bb04c" + } +} diff --git a/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/sdk-assemblies.json b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/sdk-assemblies.json new file mode 100644 index 000000000..94f0a870f --- /dev/null +++ b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/sdk-assemblies.json @@ -0,0 +1,32 @@ +[ + { + "name": "Opc.Ua.Core", + "version": "2.0.1.0", + "sha256": "9d0857aa6b7f3c49037d4ea557b4da8edf07e146069464481783e66c5c9cd35a" + }, + { + "name": "Opc.Ua.Types", + "version": "2.0.1.0", + "sha256": "c542f69882f4e8a4ebd5ccc28a3881d6967c7ffb6dcc3c56dc6c776f39c2ede1" + }, + { + "name": "Opc.Ua.Security.Certificates", + "version": "2.0.1.0", + "sha256": "7f4969aceb2f0db5328c6882cb4b117342f3b18905c38acdba600683860c4fd0" + }, + { + "name": "Opc.Ua.Core.Types", + "version": "2.0.1.0", + "sha256": "a7dd141b8dabe8848ff28c2fb8e16a85a0508e6a49e6b1a1c1e267175b99af96" + }, + { + "name": "Opc.Ua.Client", + "version": "2.0.1.0", + "sha256": "620da406fabdbc75d11f9827ea7e39d2d3c762471bd2264b0d11f1d6cd9247cd" + }, + { + "name": "Opc.Ua.Configuration", + "version": "2.0.1.0", + "sha256": "6ed6a594c93c3ff35d1a16539a9fe87c1e2640549cc2f48ed6bae333dc8bf0e7" + } +] \ No newline at end of file diff --git a/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/server-live-resources.json b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/server-live-resources.json new file mode 100644 index 000000000..bf6a0854d --- /dev/null +++ b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/server-live-resources.json @@ -0,0 +1 @@ +{"utc":"2026-11-10T17:41:05.3943073Z","sessions":0,"subscriptions":0,"items":0,"serverProcess":26} \ No newline at end of file diff --git a/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/server-ready.json b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/server-ready.json new file mode 100644 index 000000000..d89d52068 --- /dev/null +++ b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/server-ready.json @@ -0,0 +1 @@ +{"endpoint":"opc.tcp://127.0.0.1:36237/OtOpcUa","process":26,"sdk":"2.0.1.0","rawUri":"https://zb.com/otopcua/raw","unsUri":"https://zb.com/otopcua/uns"} \ No newline at end of file diff --git a/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/server-server-resources.json b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/server-server-resources.json new file mode 100644 index 000000000..bd1f5af37 --- /dev/null +++ b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/server-server-resources.json @@ -0,0 +1,13 @@ +{ + "WorkingSet64": 232546304, + "PrivateMemorySize64": 263188480, + "HandleCount": 151, + "threads": 17, + "cpuMilliseconds": 11661.139, + "allocatedBytes": 334834272, + "collections": [ + 9, + 1, + 0 + ] +} \ No newline at end of file diff --git a/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/server.log b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/server.log new file mode 100644 index 000000000..e69de29bb diff --git a/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/source.json b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/source.json new file mode 100644 index 000000000..ea6f85c02 --- /dev/null +++ b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/source.json @@ -0,0 +1,17 @@ +{ + "commit": "7b353d7bae5311a2f0a33c0a82c88d2c5b49d76f", + "root": "/Users/dohertj2/.codex/worktrees/opcua2-client-lifecycle/OtOpcUa", + "durationSeconds": 90, + "mode": "diagnostic", + "image": { + "Id": "sha256:b04611ee9e1b7d36797012ed921962c57189cb4dbb3dff12afb8e158110f293b", + "RepoDigests": [ + "mcr.microsoft.com/dotnet/sdk@sha256:e362a8dbcd691522456da26a5198b8f3ca1d7641c95624fadc5e3e82678bd08a" + ], + "Architecture": "arm64", + "Os": "linux" + }, + "network": "none", + "startedUtc": "2026-11-10T17:39:26Z", + "scope": "actual unchanged production Shared/OpcUaClientDriver/AdminUI browser in a repeated same-process loop; SDK 2.0.1 only" +} diff --git a/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/summary.json b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/summary.json new file mode 100644 index 000000000..061073998 --- /dev/null +++ b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/summary.json @@ -0,0 +1,40 @@ +{ + "status": "passed", + "diagnosticPositiveControl": "exact SDK2 manager false-before/true-after DisposeAsync", + "applicationCertificateHashes": [ + "data/OtOpcUa/adminui-browse-pki/own/certs/OtOpcUa AdminUI Browse [14579B861300836A2A96549E307DB4123778B0C5].der:89e21ac01a5582dd04df77add2fad19495c695b69f4a48a05d2c71283325503b", + "data/OtOpcUa/pki/own/certs/ClientLifecycle [AF2D40052FCDDC9A8C09686E6E8E0F17CFF5A979].der:e9bdc63bc2cc0b7901389f958b2fd6bfaf5369f439dc996e152668a3fa3c381c", + "data/shared-pki/own/certs/OtOpcUaClient [91C87A8D0AC11919E0AFD44457BF932F3587F1B0].der:b4f2018a43f161e18d25f7b8bb80590ccec13afcf7b9189f6d97e10a68b55602" + ], + "requestedSeconds": 90, + "elapsedSeconds": 91.1041217, + "fullTwoHourLifecycle": false, + "cycles": 68, + "sharedPasses": 136, + "driverPasses": 136, + "browserPasses": 136, + "managerDisposalsVerified": 544, + "finalResources": { + "cycle": 68, + "elapsedSeconds": 91.1041218, + "utc": "2026-11-10T17:41:05.6187359Z", + "WorkingSet64": 181018624, + "PrivateMemorySize64": 233250816, + "managedBytes": 4737536, + "allocatedBytes": 324891240, + "gc": [ + 25, + 18, + 18 + ], + "fdCount": 148, + "ownedRootFdCount": 4, + "ownedPkiFdCount": 0, + "survivingProductionObjects": 0, + "managerDisposalsVerified": 544, + "sharedPasses": 136, + "driverPasses": 136, + "browserPasses": 136 + }, + "scope": "Production Shared connect/disconnect/reconnect; production driver Initialize/Reinitialize/Shutdown/dispose; production browser open/browse/dispose; scalar and Int32[3] read/write/subscribe/unsubscribe; strict mutual trusted DER/Basic256Sha256 SignAndEncrypt/API-key fixture. No network stalls, revocation, surgical edits/rebuild, CP abandonment or Windows/AVEVA/performance G4 claim." +} \ No newline at end of file diff --git a/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/verification.json b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/verification.json new file mode 100644 index 000000000..928b3d9f9 --- /dev/null +++ b/scripts/validation/opcua-client-lifecycle/evidence/2026-11-10-diagnostic/verification.json @@ -0,0 +1,36 @@ +{ + "diagnosticExitCode": 0, + "fullDurationGuardExitCode": 2, + "rawArtifactRoot": "/private/tmp/opcua2-production-client-diagnostic-20261110c", + "shortRunDoesNotMeetTwoHourGate": true, + "harnessFiles": [ + { + "path": "scripts/validation/opcua-client-lifecycle/.gitignore", + "sha256": "32dae3052f331ee34d628ef535709b301259a45df7c7522c4d35dcf49873f00b" + }, + { + "path": "scripts/validation/opcua-client-lifecycle/Lifecycle.cs", + "sha256": "609b73b55c4ad2ed7dcd70f42dbc702cc233cd2f9ace8c686f8fe1cbb8471f3a" + }, + { + "path": "scripts/validation/opcua-client-lifecycle/Lifecycle.csproj", + "sha256": "379ce48ae014f4a52c9e9096a3fce5046689475a08408f279e430463f5f19085" + }, + { + "path": "scripts/validation/opcua-client-lifecycle/ProcessHost.cs", + "sha256": "89b6f518c9706753e7f62bda4f8312e3390cf8d0ced99d847e8df52d935720d6" + }, + { + "path": "scripts/validation/opcua-client-lifecycle/README.md", + "sha256": "05cc5b39f58abd7381b3eec51e0fe18bc6de6f525430810ef21b4679fe1e5d20" + }, + { + "path": "scripts/validation/opcua-client-lifecycle/container.sh", + "sha256": "899cea38d6a7e21448308dea8931a3d2fe0986d736eaf96e4769ef1637ed5aae" + }, + { + "path": "scripts/validation/opcua-client-lifecycle/run.py", + "sha256": "8da86e2215fcaa883a9212b6dde0e897795efd5d75307af6318345b61c965ad8" + } + ] +} diff --git a/scripts/validation/opcua-client-lifecycle/run.py b/scripts/validation/opcua-client-lifecycle/run.py new file mode 100755 index 000000000..2a01a45bf --- /dev/null +++ b/scripts/validation/opcua-client-lifecycle/run.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Build and run isolated actual production Shared/driver/browser lifecycle loops.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import secrets +import shutil +import subprocess +import time +import uuid + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[2] + + +def write(path, value): + path.write_text(json.dumps(value, indent=2) + '\n') + + +def certs(root): + return {str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(root.rglob('*.der')) if '/own/certs/' in str(p)} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', required=True, type=Path, help='Fresh nonexistent task evidence root, includes private PKI; never commit entire output') + parser.add_argument('--duration', type=int, default=90) + parser.add_argument('--full', action='store_true', help='Requires >=7200 seconds of real completed client runtime') + parser.add_argument('--image', default='mcr.microsoft.com/dotnet/sdk:10.0.103-noble') + args = parser.parse_args() + if args.duration <= 0 or (args.full and args.duration < 7200): + parser.error('--full requires duration >=7200; diagnostic duration must be positive') + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=False) + os.chmod(output, 0o700) + build = output / 'app' + task = output / 'task' + task.mkdir() + image = json.loads(subprocess.check_output(['docker', 'image', 'inspect', args.image, '--format', '{{json .}}'], text=True)) + if image['Architecture'] != 'arm64' or image['Os'] != 'linux': + raise RuntimeError('Expected cached official Linux arm64 SDK image') + write(output / 'source.json', {'commit': subprocess.check_output(['git', '-C', str(ROOT), 'rev-parse', 'HEAD'], text=True).strip(), + 'root': str(ROOT), 'durationSeconds': args.duration, 'mode': 'full' if args.full else 'diagnostic', + 'image': {k: image.get(k) for k in ('Id', 'RepoDigests', 'Architecture', 'Os')}, 'network': 'none', + 'startedUtc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), + 'scope': 'actual unchanged production Shared/OpcUaClientDriver/AdminUI browser in a repeated same-process loop; SDK 2.0.1 only'}) + with (output / 'build.log').open('w') as log: + subprocess.run(['dotnet', 'publish', str(HERE / 'Lifecycle.csproj'), '-c', 'Release', '-m:1', '--nologo', '-o', str(build)], stdout=log, stderr=subprocess.STDOUT, check=True, timeout=300) + shutil.copy2(HERE / 'container.sh', task / 'container.sh') + name = 'otopcua-client-lifecycle-' + uuid.uuid4().hex[:12] + env = dict(os.environ, OPCUA_MATRIX_PASSWORD='otua_lifecycle_' + secrets.token_urlsafe(32), + LIFECYCLE_SECONDS=str(args.duration), LIFECYCLE_MODE='full' if args.full else 'diagnostic') + command = ['docker', 'run', '--rm', '--name', name, '--network', 'none', + '-e', 'OPCUA_MATRIX_PASSWORD', '-e', 'LIFECYCLE_SECONDS', '-e', 'LIFECYCLE_MODE', + '-v', str(build) + ':/app:ro', '-v', str(task) + ':/task', args.image, 'bash', '/task/container.sh'] + write(output / 'command.json', {'argv': command, 'timeoutSeconds': args.duration + 300}) + started = time.monotonic() + try: + with (output / 'container.log').open('w') as log: + result = subprocess.run(command, stdout=log, stderr=subprocess.STDOUT, env=env, timeout=args.duration + 300) + except BaseException: + subprocess.run(['docker', 'rm', '-f', name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=20) + raise + summary = json.loads((task / 'summary.json').read_text()) if (task / 'summary.json').exists() else None + write(output / 'result.json', {'exitCode': result.returncode, 'containerElapsedSeconds': time.monotonic() - started, + 'summary': summary, 'applicationCertificates': certs(task)}) + if result.returncode or not summary or (args.full and not summary['fullTwoHourLifecycle']): + raise RuntimeError('Lifecycle run failed; inspect task/failure.txt, client.log and server.log: ' + str(output)) + print(json.dumps({'output': str(output), 'summary': summary}, indent=2)) + + +if __name__ == '__main__': + main()