Files
lmxopcua/scripts/validation/opcua-client-lifecycle/Lifecycle.cs
T
2026-11-10 12:44:04 -05:00

250 lines
18 KiB
C#

#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<WeakReference> 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<bool> 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<int>();
EventHandler<DataChangedEventArgs> 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<int>();
EventHandler<DataChangeEventArgs> 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, "<Session>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<int> 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, "<fixture-secret>"));
return 1;
}
}
}