Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2LifecycleSoakTests.cs
T

678 lines
41 KiB
C#

using System.Collections;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Net.Sockets;
using System.Reflection;
using System.Text;
using System.Text.Json;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ClientSession = Opc.Ua.Client.ISession;
using Subscription = Opc.Ua.Client.Subscription;
using MonitoredItem = Opc.Ua.Client.MonitoredItem;
using ServerSession = Opc.Ua.Server.ISession;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// Opt-in Task 12 lifecycle evidence. Diagnostic mode is deliberately separate from the two-hour gate.
/// Run only this test in an otherwise idle test process; RSS/FD counts describe the whole process.
/// No key, certificate, store path, token, or OPC UA payload is written to the JSONL evidence.
/// </summary>
[Trait("Category", "E2E")]
public sealed class Sdk2LifecycleSoakTests
{
private const string Scalar = ServerEventRig.RawDeviceFolder + "/SoakScalar";
private const string ArrayTag = ServerEventRig.RawDeviceFolder + "/SoakArray";
private const string UnsScalar = "SoakEquipment/Scalar";
private static readonly DateTime Epoch = new(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc);
public static bool Enabled => Environment.GetEnvironmentVariable("OTOPCUA_SDK2_SOAK") == "1";
[Fact(SkipUnless = nameof(Enabled), Skip = "Opt in with OTOPCUA_SDK2_SOAK=1 and an explicit diagnostic/full mode.")]
public async Task Repeated_lifecycles_return_resources_to_a_steady_baseline()
{
var mode = Environment.GetEnvironmentVariable("OTOPCUA_SDK2_SOAK_MODE");
(mode is "diagnostic" or "full").ShouldBeTrue("explicit diagnostic or full mode is required");
var seconds = int.Parse(Environment.GetEnvironmentVariable("OTOPCUA_SDK2_SOAK_SECONDS")
?? (mode == "full" ? "7200" : "90"), CultureInfo.InvariantCulture);
if (mode == "full") seconds.ShouldBeGreaterThanOrEqualTo(7200);
else seconds.ShouldBeInRange(60, 120);
var output = Environment.GetEnvironmentVariable("OTOPCUA_SDK2_SOAK_OUTPUT")
?? throw new InvalidOperationException("An absolute, fresh OTOPCUA_SDK2_SOAK_OUTPUT JSONL path is required.");
Path.IsPathFullyQualified(output).ShouldBeTrue();
Directory.CreateDirectory(Path.GetDirectoryName(output)!);
await using var evidence = new StreamWriter(new FileStream(output, FileMode.CreateNew, FileAccess.Write, FileShare.Read));
using var run = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
run.CancelAfter(TimeSpan.FromSeconds(seconds + 120));
var ct = run.Token;
var watch = Stopwatch.StartNew();
var clientRoot = Path.Combine(Path.GetTempPath(), $"otua-soak-client-{Guid.NewGuid():N}");
ApplicationInstance? application = null;
ServerEventRig? rig = null;
string? serverRoot = null;
var cycles = 0;
var restarts = 0;
Sample? baseline = null;
var retained = new Queue<Sample>();
try
{
var telemetry = DefaultTelemetry.Create(_ => { });
var security = TestClientSecurity.Build(clientRoot);
security.ApplicationCertificate!.SubjectName = "CN=SDK2LifecycleSoak";
var config = new ApplicationConfiguration(telemetry)
{
ApplicationName = "SDK2LifecycleSoak", ApplicationUri = $"urn:SDK2LifecycleSoak:{Guid.NewGuid():N}",
ApplicationType = ApplicationType.Client, SecurityConfiguration = security,
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 15_000 },
TransportQuotas = new TransportQuotas { OperationTimeout = 5000 },
};
application = new ApplicationInstance(telemetry) { ApplicationConfiguration = config };
await config.ValidateAsync(ApplicationType.Client, ct);
await application.CheckApplicationInstanceCertificatesAsync(true, null, ct);
rig = await ServerEventRig.StartAsync("SDK2LifecycleSoak", encrypted: true, ct);
serverRoot = RequiredField<string>(rig, "_pkiRoot");
Configure(rig);
// The required observation period excludes initial certificate creation and server startup.
watch.Restart();
await LogAsync(evidence, new
{
kind = "start", utc = DateTimeOffset.UtcNow, processId = Environment.ProcessId,
mode, requestedSeconds = seconds, minimumFullSeconds = 7200,
build = Environment.GetEnvironmentVariable("OTOPCUA_SOAK_BUILD_SHA"),
sdk = typeof(OperationContext).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion,
diagnosticInstrumentation = "SDK2.0.1 CP lists and certificate-validation cached stores; read-only",
limits = new { retainedManagedGrowthBytes = 64L << 20, rssGrowthBytes = 128L << 20, fdGrowth = 32 },
coverage = "encrypted sessions; scalar/Int16[] Raw+UNS; subscription removal; browse abandonment; history release/cross-session/close; surgical changes/rebuild; revocation; paused TCP read/recovery; same-PKI restart",
});
while (watch.Elapsed < TimeSpan.FromSeconds(seconds))
{
var cycle = Stopwatch.StartNew();
await ExerciseAsync(rig, config, ++cycles, evidence, ct);
await EventuallyAsync(() => PublicCounts(rig) == (0, 0, 0), ct);
var sample = await CaptureAsync(rig, config.CertificateManager, watch.Elapsed, cycles, clientRoot, serverRoot, ct);
// Retain the failing observation too; only the later quiescent row proves every gate passed.
await LogAsync(evidence, new { kind = "resource-observation", resources = sample });
sample.Sessions.ShouldBe(0); sample.Subscriptions.ShouldBe(0); sample.Items.ShouldBe(0);
sample.BrowsePoints.ShouldBe(0); sample.HistoryPoints.ShouldBe(0);
sample.LiveDataItems.ShouldBe(0); sample.LiveEventItems.ShouldBe(0); sample.MonitoredNodes.ShouldBe(0);
sample.PkiDescriptors.ShouldBe(0, "quiescent certificate file descriptors must be closed");
if (cycles >= 3)
{
baseline ??= sample;
sample.CachedCertificateStores.ShouldBe(baseline.CachedCertificateStores);
sample.Variables.ShouldBe(baseline.Variables); sample.Folders.ShouldBe(baseline.Folders);
sample.Conditions.ShouldBe(baseline.Conditions);
sample.Descriptors.ShouldBeLessThanOrEqualTo(baseline.Descriptors + 32);
sample.ManagedBytes.ShouldBeLessThanOrEqualTo(baseline.ManagedBytes + (64L << 20));
sample.RssBytes.ShouldBeLessThanOrEqualTo(baseline.RssBytes + (128L << 20));
retained.Enqueue(sample);
if (retained.Count > 24) retained.Dequeue();
if (retained.Count == 24)
{
var window = retained.ToArray();
var strictlyIncreasing = window.Zip(window.Skip(1)).All(pair => pair.Second.ManagedBytes > pair.First.ManagedBytes);
(strictlyIncreasing && window[^1].ManagedBytes - window[0].ManagedBytes > (16L << 20)).ShouldBeFalse(
"retained managed memory must not grow monotonically across 24 quiescent cycles");
}
}
await LogAsync(evidence, new { kind = "quiescent", cycleSeconds = cycle.Elapsed.TotalSeconds, resources = sample });
if (cycles % 4 == 0)
{
var restart = Stopwatch.StartNew();
await RestartWithActiveResourcesAsync(rig, config, ct).WaitAsync(TimeSpan.FromSeconds(25), ct);
Configure(rig);
restarts++;
await LogAsync(evidence, new { kind = "restart", cycles, restarts, seconds = restart.Elapsed.TotalSeconds });
}
}
cycles.ShouldBeGreaterThanOrEqualTo(4, "diagnostic mode must exercise at least one actual restart");
restarts.ShouldBeGreaterThan(0);
watch.Elapsed.TotalSeconds.ShouldBeGreaterThanOrEqualTo(seconds);
var finalClientCores = CertificateCores(config.CertificateManager);
var finalServerCores = CertificateCores(rig.Server.CertificateManager!);
await rig.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), ct);
rig = null;
await application.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), ct);
application = null;
finalClientCores.Sum(CachedStores).ShouldBe(0);
finalServerCores.Sum(CachedStores).ShouldBe(0);
(await DescriptorCountsAsync(ct, clientRoot, serverRoot)).Pki.ShouldBe(0);
await LogAsync(evidence, new { kind = "complete", utc = DateTimeOffset.UtcNow, mode, cycles, restarts, seconds = watch.Elapsed.TotalSeconds,
satisfiesTwoHourDuration = mode == "full" && watch.Elapsed.TotalSeconds >= 7200 });
}
catch (Exception error)
{
await LogAsync(evidence, new { kind = "failed", cycles, restarts, seconds = watch.Elapsed.TotalSeconds,
exceptionType = error.GetType().FullName });
throw;
}
finally
{
if (rig is not null) await rig.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), TestContext.Current.CancellationToken);
if (application is not null) await application.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), TestContext.Current.CancellationToken);
if (Directory.Exists(clientRoot)) Directory.Delete(clientRoot, true);
if (serverRoot is not null) Directory.Exists(serverRoot).ShouldBeFalse("the task-owned server PKI must be released");
}
}
private static void Configure(ServerEventRig rig)
{
rig.MaterialiseNative();
rig.Nm.HistorianDataSource = new EventHistoryPagingOverTheWireTests.SqlLikeEventSource(Enumerable.Range(0, 40)
.Select(i => new HistoricalEvent($"soak-{i}", "source", Epoch.AddSeconds(i), Epoch, "soak", 500)).ToList());
rig.Nm.NodeWriteGateway = new AcceptWrites();
MaterialiseValues(rig);
rig.Enforce(true, [ServerEventRig.Key()], Grant());
}
private static ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl Grant() => ServerEventRig.ClusterGrant(
ServerEventRig.KeyPrincipal, "soak-grant", NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe
| NodePermissions.WriteOperate | NodePermissions.HistoryRead);
private static void MaterialiseValues(ServerEventRig rig)
{
rig.Sink.EnsureFolder(ServerEventRig.RawDeviceFolder, null, "Device", AddressSpaceRealm.Raw);
rig.Sink.EnsureVariable(Scalar, ServerEventRig.RawDeviceFolder, "Scalar", "Int32", true, AddressSpaceRealm.Raw);
rig.Sink.EnsureVariable(ArrayTag, ServerEventRig.RawDeviceFolder, "Array", "Int16", true, AddressSpaceRealm.Raw,
isArray: true, arrayLength: 4);
rig.Sink.EnsureFolder("SoakEquipment", null, "Equipment", AddressSpaceRealm.Uns);
rig.Sink.EnsureVariable(UnsScalar, "SoakEquipment", "Scalar", "Int32", true, AddressSpaceRealm.Uns);
for (var i = 0; i < 16; i++) rig.Sink.EnsureVariable($"{ServerEventRig.RawDeviceFolder}/Browse{i}",
ServerEventRig.RawDeviceFolder, $"Browse{i}", "Int32", false, AddressSpaceRealm.Raw);
rig.Sink.WriteValue(Scalar, 0, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
rig.Sink.WriteValue(ArrayTag, new short[] { 0, 1, 2, 3 }, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
rig.Sink.WriteValue(UnsScalar, 0, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
}
private static async Task ExerciseAsync(ServerEventRig rig, ApplicationConfiguration config, int cycle,
StreamWriter evidence, CancellationToken ct)
{
await using var proxy = new PauseProxy(new Uri(rig.EndpointUrl).Port);
using var owner = await ConnectAsync(rig, config, proxy.Port, ct);
using var other = await ConnectAsync(rig, config, null, ct);
var ownedSessions = rig.Server.CurrentInstance.SessionManager.GetSessions().ToArray();
ownedSessions.Length.ShouldBe(2);
var scalar = Raw(owner, Scalar);
var array = Raw(owner, ArrayTag);
var unsNs = owner.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
unsNs.ShouldBeGreaterThan(0);
var uns = new NodeId(UnsScalar, (ushort)unsNs);
owner.NamespaceUris.GetString(scalar.NamespaceIndex).ShouldBe(V3NodeIds.RawNamespaceUri);
owner.NamespaceUris.GetString(uns.NamespaceIndex).ShouldBe(V3NodeIds.UnsNamespaceUri);
using var subscription = new Subscription(owner.DefaultSubscription) { PublishingInterval = 100, KeepAliveCount = 10 };
owner.AddSubscription(subscription);
try
{
await subscription.CreateAsync(ct);
var seen = new ConcurrentDictionary<NodeId, Variant>();
foreach (var id in new[] { scalar, array, uns })
{
var item = new MonitoredItem(subscription.DefaultItem) { StartNodeId = id, AttributeId = Attributes.Value,
SamplingInterval = 50, QueueSize = 10 };
item.Notification += (sender, _) =>
{
foreach (var value in sender.DequeueValues()) if (StatusCode.IsGood(value.StatusCode)) seen[id] = value.WrappedValue;
};
subscription.AddItem(item);
}
await subscription.ApplyChangesAsync(ct);
subscription.MonitoredItems.ShouldAllBe(item => ServiceResult.IsGood(item.Status.Error));
PublicCounts(rig).ShouldBe((2, 1, 3));
var values = new short[] { (short)(cycle % 30000), 2, 3, 4 };
var writes = await owner.WriteAsync(null, new[]
{
new WriteValue { NodeId = scalar, AttributeId = Attributes.Value, Value = new DataValue(Variant.From(cycle)) },
new WriteValue { NodeId = array, AttributeId = Attributes.Value, Value = new DataValue(SdkTestValues.FromClr(values)) },
new WriteValue { NodeId = uns, AttributeId = Attributes.Value, Value = new DataValue(Variant.From(cycle)) },
}, ct);
writes.Results.ToList().ShouldAllBe(status => status == StatusCodes.Good);
(await owner.ReadValueAsync(scalar, ct)).WrappedValue.GetInt32().ShouldBe(cycle);
(await owner.ReadValueAsync(array, ct)).ClrValue().ShouldBeOfType<short[]>().ShouldBe(values);
(await owner.ReadValueAsync(uns, ct)).WrappedValue.GetInt32().ShouldBe(cycle);
await EventuallyAsync(() => seen.TryGetValue(scalar, out var value) && value.GetInt32() == cycle
&& seen.ContainsKey(array) && seen.TryGetValue(uns, out var mirror) && mirror.GetInt32() == cycle, ct);
// One explicitly released and one deliberately abandoned browse point.
var browse = await BrowseAsync(owner, ct);
browse.IsEmpty.ShouldBeFalse();
(await owner.BrowseNextAsync(null, true, [browse], ct)).Results[0].StatusCode.ShouldBe(StatusCodes.Good);
(await BrowseAsync(owner, ct)).IsEmpty.ShouldBeFalse();
// Production history points: cross-session denial, single-use resume, explicit release and close cleanup.
var first = (await PageAsync(owner, default, false, ct)).ContinuationPoint;
first.IsEmpty.ShouldBeFalse();
(await PageAsync(other, first, false, ct)).StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid);
var next = (await PageAsync(owner, first, false, ct)).ContinuationPoint;
next.IsEmpty.ShouldBeFalse();
(await PageAsync(owner, next, true, ct)).StatusCode.ShouldBe(StatusCodes.Good);
for (var i = 0; i < 6; i++) (await PageAsync(owner, default, false, ct)).ContinuationPoint.IsEmpty.ShouldBeFalse();
var held = ownedSessions.Select(ContinuationCounts).ToArray();
held.Sum(count => count.Browse).ShouldBeGreaterThan(0);
held.Sum(count => count.History).ShouldBeGreaterThan(0);
var active = PublicCounts(rig);
await LogAsync(evidence, new { kind = "active", cycle, sessions = active.Sessions,
subscriptions = active.Subscriptions, items = active.Items, browsePoints = held.Sum(x => x.Browse), historyPoints = held.Sum(x => x.History) });
rig.Nm.UpdateTagAttributes(Scalar, false, null, "Int32", false, null, AddressSpaceRealm.Raw).ShouldBeTrue();
rig.Nm.UpdateTagAttributes(Scalar, true, null, "Int32", false, null, AddressSpaceRealm.Raw).ShouldBeTrue();
rig.Nm.RemoveVariableNode(ArrayTag, AddressSpaceRealm.Raw).ShouldBeTrue();
rig.Sink.EnsureVariable(ArrayTag, ServerEventRig.RawDeviceFolder, "Array", "Int16", true, AddressSpaceRealm.Raw,
isArray: true, arrayLength: 4);
rig.Sink.WriteValue(ArrayTag, values, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
var authority = rig.Nm.DataPlaneAuthority.ShouldBeOfType<DataPlaneAuthorityState>();
authority.Apply(ServerEventRig.Cluster, $"revoked-{cycle}", [], [Grant()],
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
var denied = await owner.ReadAsync(null, 0, TimestampsToReturn.Neither,
[new ReadValueId { NodeId = scalar, AttributeId = Attributes.Value }], ct);
denied.Results[0].StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
rig.Enforce(true, [ServerEventRig.Key()], Grant());
proxy.Pause();
var stalled = Stopwatch.StartNew();
string stalledStatus;
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(1));
await owner.ReadValueAsync(scalar, timeout.Token).WaitAsync(TimeSpan.FromSeconds(5), ct);
throw new InvalidOperationException("The deliberately paused network unexpectedly completed the read.");
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested) { stalledStatus = "OperationCanceled"; }
// Exact-tag ChannelAsyncOperation maps a canceled completion waiter to BadRequestInterrupted.
catch (ServiceResultException error) when (error.StatusCode == StatusCodes.BadTimeout
|| error.StatusCode == StatusCodes.BadRequestCancelledByClient || error.StatusCode == StatusCodes.BadRequestTimeout
|| error.StatusCode == StatusCodes.BadRequestInterrupted) { stalledStatus = error.StatusCode.ToString(); }
finally { proxy.Resume(); }
(await owner.ReadValueAsync(scalar, ct)).StatusCode.ShouldBe(StatusCodes.Good);
await LogAsync(evidence, new { kind = "network-stall-recovered", cycle, status = stalledStatus, seconds = stalled.Elapsed.TotalSeconds });
await subscription.DeleteAsync(true, ct);
await owner.RemoveSubscriptionAsync(subscription, ct);
rig.Sink.RebuildAddressSpace();
Configure(rig);
}
finally
{
proxy.Resume();
await owner.CloseAsync(ct).WaitAsync(TimeSpan.FromSeconds(10), ct);
await other.CloseAsync(ct).WaitAsync(TimeSpan.FromSeconds(10), ct);
foreach (var closed in ownedSessions)
{
ContinuationCounts(closed).ShouldBe((0, 0));
}
}
}
private static async Task RestartWithActiveResourcesAsync(ServerEventRig rig, ApplicationConfiguration config,
CancellationToken ct)
{
using var client = await ConnectAsync(rig, config, null, ct);
using var subscription = new Subscription(client.DefaultSubscription) { PublishingInterval = 100 };
client.AddSubscription(subscription);
await subscription.CreateAsync(ct);
subscription.AddItem(new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = Raw(client, Scalar), AttributeId = Attributes.Value, SamplingInterval = 50,
});
await subscription.ApplyChangesAsync(ct);
PublicCounts(rig).ShouldBe((1, 1, 1));
(await BrowseAsync(client, ct)).IsEmpty.ShouldBeFalse();
(await PageAsync(client, default, false, ct)).ContinuationPoint.IsEmpty.ShouldBeFalse();
var oldInternal = rig.Server.CurrentInstance;
var oldSessionManager = oldInternal.SessionManager;
var oldSubscriptionManager = oldInternal.SubscriptionManager;
var oldSessions = oldSessionManager.GetSessions().ToArray();
var oldSubscriptions = oldSubscriptionManager.GetSubscriptions().ToArray();
var oldManager = rig.Server.CertificateManager!;
var oldCores = CertificateCores(oldManager);
await rig.RestartAsync(ct).WaitAsync(TimeSpan.FromSeconds(20), ct);
oldSessionManager.GetSessions().ShouldBeEmpty();
oldSubscriptionManager.GetSubscriptions().ShouldBeEmpty();
oldSubscriptions.ShouldAllBe(item => item.MonitoredItemCount == 0);
foreach (var session in oldSessions) ContinuationCounts(session).ShouldBe((0, 0));
RequiredField<bool>(oldManager, "m_disposed").ShouldBeTrue();
oldCores.Sum(CachedStores).ShouldBe(0, "stopped server validators must release every cached store");
PublicCounts(rig).ShouldBe((0, 0, 0));
}
private static async Task<ClientSession> ConnectAsync(ServerEventRig rig, ApplicationConfiguration config,
int? proxyPort, CancellationToken ct)
{
var telemetry = DefaultTelemetry.Create(_ => { });
var endpoint = await CoreClientUtils.SelectEndpointAsync(config, rig.EndpointUrl, true, telemetry, ct)
?? throw new InvalidOperationException("No encrypted endpoint.");
endpoint.SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt);
if (proxyPort is { } port) endpoint.EndpointUrl = $"opc.tcp://127.0.0.1:{port}/OtOpcUa";
return await new DefaultSessionFactory(telemetry).CreateAsync(config,
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(config)), false, false,
"SDK2LifecycleSoak", 15_000, new UserIdentity("apikey", Encoding.UTF8.GetBytes(ServerEventRig.KeyPassword)), default, ct);
}
private static NodeId Raw(ClientSession session, string id)
{
var ns = session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
ns.ShouldBeGreaterThan(0);
return new NodeId(id, (ushort)ns);
}
private static async Task<ByteString> BrowseAsync(ClientSession session, CancellationToken ct) =>
(await session.BrowseAsync(null, null, 1, [new BrowseDescription
{
NodeId = Raw(session, ServerEventRig.RawDeviceFolder), BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IncludeSubtypes = true,
ResultMask = (uint)BrowseResultMask.All,
}], ct)).Results[0].ContinuationPoint;
private static async Task<Opc.Ua.HistoryReadResult> PageAsync(ClientSession session, ByteString point,
bool release, CancellationToken ct) => (await session.HistoryReadAsync(null, new ExtensionObject(new ReadEventDetails
{
StartTime = Epoch.AddDays(-1), EndTime = Epoch.AddDays(1), NumValuesPerNode = 4,
Filter = new EventFilter { SelectClauses = [new SimpleAttributeOperand(ObjectTypeIds.BaseEventType,
QualifiedName.From(BrowseNames.EventId))] },
}), TimestampsToReturn.Both, release,
[new HistoryReadValueId { NodeId = Raw(session, ServerEventRig.RawDeviceFolder), ContinuationPoint = point }], ct)).Results[0];
private static (int Sessions, int Subscriptions, int Items) PublicCounts(ServerEventRig rig)
{
var subscriptions = rig.Server.CurrentInstance.SubscriptionManager.GetSubscriptions();
return (rig.Server.CurrentInstance.SessionManager.GetSessions().Count, subscriptions.Count,
subscriptions.Sum(subscription => subscription.MonitoredItemCount));
}
// Exact-tag diagnostic instrumentation only. Fail if fields/types change; never replace or mutate a store.
private static (int Browse, int History) ContinuationCounts(ServerSession session)
{
var points = session.ContinuationPoints;
using (RequiredField<System.Threading.Lock>(points, "m_lock").EnterScope())
return (NullableCollectionCount(points, "m_browse"), NullableCollectionCount(points, "m_history"));
}
private static object[] CertificateCores(object manager)
{
using (RequiredField<System.Threading.Lock>(manager, "m_certificatesLock").EnterScope())
{
var cores = new List<object>();
foreach (var field in new[] { "m_peerCore", "m_userCore", "m_httpsCore" })
if (Field(manager, field).GetValue(manager) is { } core) cores.Add(core);
cores.AddRange(RequiredField<IDictionary>(manager, "m_customCores").Values.Cast<object>());
return cores.Distinct(System.Collections.Generic.ReferenceEqualityComparer.Instance).ToArray();
}
}
private static int CachedStores(object core) => RequiredField<IDictionary>(core, "m_stores").Count;
private static int NullableCollectionCount(object owner, string name) => Field(owner, name).GetValue(owner) switch
{
null => 0,
ICollection items => items.Count,
_ => throw new InvalidOperationException($"SDK diagnostic field {name} no longer has the expected collection type."),
};
private static FieldInfo Field(object owner, string name) => owner.GetType().GetField(name,
BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new InvalidOperationException($"Missing SDK diagnostic field {name}.");
private static T RequiredField<T>(object owner, string name) => Field(owner, name).GetValue(owner) is T value ? value
: throw new InvalidOperationException($"SDK diagnostic field {name} no longer has the expected type {typeof(T).Name}.");
private sealed record Sample(int Cycle, double Seconds, int Sessions, int Subscriptions, int Items,
int BrowsePoints, int HistoryPoints, int LiveDataItems, int LiveEventItems, int MonitoredNodes,
int Variables, int Folders, int Conditions, int CachedCertificateStores,
int Descriptors, int PkiDescriptors, long ManagedBytes, long RssBytes, long AllocatedBytes,
int Gen0, int Gen1, int Gen2, long GcCommittedBytes, long GcHeapBytes, long GcFragmentedBytes,
long PinnedObjects, long PendingFinalizers, long JitMethods, long JitIlBytes, long ActiveTimers, int Threads);
private static async Task<Sample> CaptureAsync(ServerEventRig rig, object clientManager, TimeSpan elapsed, int cycle,
string clientRoot, string serverRoot, CancellationToken ct)
{
var counts = PublicCounts(rig);
var points = rig.Server.CurrentInstance.SessionManager.GetSessions().Select(ContinuationCounts).ToArray();
var descriptors = await DescriptorCountsAsync(ct, clientRoot, serverRoot);
var retained = GC.GetTotalMemory(forceFullCollection: true);
var memory = GC.GetGCMemoryInfo();
using var process = Process.GetCurrentProcess();
process.Refresh();
return new Sample(cycle, elapsed.TotalSeconds, counts.Sessions, counts.Subscriptions, counts.Items,
points.Sum(x => x.Browse), points.Sum(x => x.History), rig.Nm.LiveItemCount, rig.Nm.LiveEventItemCount,
rig.Nm.MonitoredNodeCount, rig.Nm.VariableCount, rig.Nm.FolderCount, rig.Nm.AlarmConditionCount,
CertificateCores(rig.Server.CertificateManager!).Sum(CachedStores) + CertificateCores(clientManager).Sum(CachedStores),
descriptors.Total, descriptors.Pki, retained, process.WorkingSet64, GC.GetTotalAllocatedBytes(),
GC.CollectionCount(0), GC.CollectionCount(1), GC.CollectionCount(2), memory.TotalCommittedBytes,
memory.HeapSizeBytes, memory.FragmentedBytes, memory.PinnedObjectsCount, memory.FinalizationPendingCount,
System.Runtime.JitInfo.GetCompiledMethodCount(), System.Runtime.JitInfo.GetCompiledILBytes(), Timer.ActiveCount,
process.Threads.Count);
}
public static bool DescriptorProbeSupported => OperatingSystem.IsLinux() || OperatingSystem.IsMacOS();
[Fact(SkipUnless = nameof(DescriptorProbeSupported), Skip = "Descriptor probes require Linux procfs or macOS lsof.")]
public async Task Descriptor_probe_observes_an_open_PKI_file_through_a_symlink_alias_and_its_close()
{
var ct = TestContext.Current.CancellationToken;
var temporary = Path.Combine(OperatingSystem.IsMacOS() ? "/tmp" : Path.GetTempPath(), $"otua-pki-probe-{Guid.NewGuid():N}");
var pki = Path.Combine(temporary, "pki");
var sibling = Path.Combine(temporary, "pki-other");
var alias = Path.Combine(temporary, "pki-alias");
Directory.CreateDirectory(pki);
Directory.CreateDirectory(sibling);
try
{
// On macOS the temporary root itself can use /var -> /private/var. The additional
// task-owned alias also checks links whose target contains a linked parent component.
Directory.CreateSymbolicLink(alias, pki);
await using var outside = new FileStream(Path.Combine(sibling, "control"), FileMode.Create,
FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete);
CanonicalPath(alias).ShouldBe(CanonicalPath(pki));
var before = (await DescriptorCountsAsync(ct, pki, alias)).Pki;
before.ShouldBe(0, "a sibling sharing the PKI path prefix is outside the root");
int opened;
await using (var held = new FileStream(Path.Combine(alias, "control"), FileMode.Create,
FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete))
{
opened = (await DescriptorCountsAsync(ct, pki, alias)).Pki;
opened.ShouldBeGreaterThan(0, "the positive control must observe the task-owned file opened through the alias");
}
var closed = (await DescriptorCountsAsync(ct, pki, alias)).Pki;
closed.ShouldBe(0, "closing the positive control must release its descriptor");
TestContext.Current.TestOutputHelper!.WriteLine($"PKI descriptor control: before={before}, opened={opened}, closed={closed}; siblingExcluded=true; aliasResolved=true");
}
finally
{
if (Directory.Exists(alias)) Directory.Delete(alias);
if (Directory.Exists(temporary)) Directory.Delete(temporary, recursive: true);
}
}
[Fact(SkipUnless = nameof(DescriptorProbeSupported), Skip = "The bounded probe control uses a task-owned Unix process.")]
public async Task Descriptor_probe_times_out_and_reaps_a_process_that_keeps_both_streams_open()
{
var ct = TestContext.Current.CancellationToken;
using var process = new Process
{
StartInfo = new ProcessStartInfo("/bin/sh")
{
RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false,
ArgumentList = { "-c", "printf control; printf control >&2; exec /bin/sleep 30" },
},
};
var elapsed = Stopwatch.StartNew();
await Should.ThrowAsync<TimeoutException>(() => ReadDescriptorProbeAsync(process, TimeSpan.FromMilliseconds(250), ct));
elapsed.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(4), "timeout includes stream drains and bounded process cleanup");
process.HasExited.ShouldBeTrue("the task-owned timed-out process must be killed and reaped before returning");
TestContext.Current.TestOutputHelper!.WriteLine($"Descriptor timeout control: deadlineMs=250, elapsedMs={elapsed.Elapsed.TotalMilliseconds:F0}, childExited={process.HasExited}");
}
private static async Task<(int Total, int Pki)> DescriptorCountsAsync(CancellationToken ct, params string[] roots)
{
ct.ThrowIfCancellationRequested();
var canonicalRoots = roots.Select(root => CanonicalPath(root)).Distinct(StringComparer.Ordinal).ToArray();
if (OperatingSystem.IsLinux())
{
var targets = Directory.EnumerateFileSystemEntries("/proc/self/fd")
.Select(path => new FileInfo(path).LinkTarget).ToArray();
return (targets.Length, targets.Count(path => IsWithinRoots(path, canonicalRoots)));
}
if (OperatingSystem.IsMacOS())
{
using var process = new Process { StartInfo = new ProcessStartInfo("/usr/sbin/lsof")
{ RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false } };
foreach (var arg in new[] { "-a", "-p", Environment.ProcessId.ToString(CultureInfo.InvariantCulture), "-Fnf" })
process.StartInfo.ArgumentList.Add(arg);
var text = await ReadDescriptorProbeAsync(process, TimeSpan.FromSeconds(5), ct);
process.ExitCode.ShouldBe(0);
var total = 0;
var pki = 0;
var descriptor = false;
foreach (var line in text.Split('\n'))
{
if (line.StartsWith('f'))
{
descriptor = line.Length > 1 && char.IsDigit(line[1]);
if (descriptor) total++;
}
else if (descriptor && line.StartsWith('n') && IsWithinRoots(line[1..], canonicalRoots)) pki++;
}
return (total, pki);
}
throw new PlatformNotSupportedException("This soak requires Linux procfs or macOS lsof for actual descriptor evidence.");
}
private static async Task<string> ReadDescriptorProbeAsync(Process process, TimeSpan timeout, CancellationToken ct)
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
deadline.CancelAfter(timeout);
process.Start();
var stdout = process.StandardOutput.ReadToEndAsync(deadline.Token);
var stderr = process.StandardError.ReadToEndAsync(deadline.Token);
var exited = process.WaitForExitAsync(deadline.Token);
try
{
// Waiting for exit alone does not bound reads: both redirected pipes must also finish.
await Task.WhenAll(stdout, stderr, exited).WaitAsync(deadline.Token).ConfigureAwait(false);
return await stdout.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
try { if (!process.HasExited) process.Kill(entireProcessTree: true); }
catch (InvalidOperationException) when (process.HasExited) { /* It exited between the check and kill. */ }
await process.WaitForExitAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
// Observe both cancelled drains; never leave a pipe read running after returning.
try { await Task.WhenAll(stdout, stderr).WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); }
catch (OperationCanceledException) { }
if (ct.IsCancellationRequested) throw;
throw new TimeoutException("Descriptor probe exceeded its process and redirected-stream deadline.");
}
}
private static bool IsWithinRoots(string? path, IReadOnlyList<string> roots)
{
if (path is null || !Path.IsPathFullyQualified(path)) return false;
// procfs marks an unlinked but still open file; its containing physical directory still counts.
const string deleted = " (deleted)";
if (path.EndsWith(deleted, StringComparison.Ordinal)) path = path[..^deleted.Length];
var canonical = CanonicalPath(path);
return roots.Any(root => canonical.Equals(root, StringComparison.Ordinal)
|| canonical.StartsWith(Path.EndsInDirectorySeparator(root) ? root : root + Path.DirectorySeparatorChar,
StringComparison.Ordinal));
}
private static string CanonicalPath(string path, int depth = 0)
{
if (depth > 32) throw new IOException("Descriptor path link resolution exceeded its bound.");
var full = Path.GetFullPath(path);
var current = Path.GetPathRoot(full)!;
foreach (var component in full[current.Length..].Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries))
{
current = Path.Combine(current, component);
FileSystemInfo entry = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current);
if (entry.LinkTarget is not null)
{
var target = entry.ResolveLinkTarget(returnFinalTarget: true)
?? throw new IOException("Descriptor path link resolution returned no target.");
// Resolve parent links too: an alias target can itself begin with /var or /tmp.
current = CanonicalPath(target.FullName, depth + 1);
}
}
return Path.TrimEndingDirectorySeparator(current);
}
private static async Task EventuallyAsync(Func<bool> condition, CancellationToken ct)
{
var deadline = Stopwatch.StartNew();
while (!condition() && deadline.Elapsed < TimeSpan.FromSeconds(10)) await Task.Delay(25, ct);
condition().ShouldBeTrue("resource or notification condition did not settle within ten seconds");
}
private static async Task LogAsync(StreamWriter output, object value)
{
await output.WriteLineAsync(JsonSerializer.Serialize(value));
await output.FlushAsync();
}
private sealed class AcceptWrites : IOpcUaNodeWriteGateway
{
public Task<NodeWriteOutcome> WriteAsync(string id, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
Task.FromResult(new NodeWriteOutcome(true, null));
}
/// <summary>Task-owned TCP relay. Pause blocks forwarding in both directions without closing either socket.</summary>
private sealed class PauseProxy : IAsyncDisposable
{
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
private readonly CancellationTokenSource _stop = new();
private readonly ConcurrentBag<TcpClient> _sockets = [];
private readonly ConcurrentBag<Task> _pumps = [];
private readonly Task _accept;
private TaskCompletionSource? _pause;
internal int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
internal PauseProxy(int targetPort)
{
_listener.Start();
_accept = AcceptAsync(targetPort);
}
internal void Pause() => Volatile.Write(ref _pause, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously));
internal void Resume() => Interlocked.Exchange(ref _pause, null)?.TrySetResult();
private async Task AcceptAsync(int port)
{
try
{
while (!_stop.IsCancellationRequested)
{
var incoming = await _listener.AcceptTcpClientAsync(_stop.Token);
_sockets.Add(incoming);
var outgoing = new TcpClient(); _sockets.Add(outgoing);
await outgoing.ConnectAsync(IPAddress.Loopback, port, _stop.Token);
_pumps.Add(PumpAsync(incoming.GetStream(), outgoing.GetStream()));
_pumps.Add(PumpAsync(outgoing.GetStream(), incoming.GetStream()));
}
}
catch (OperationCanceledException) when (_stop.IsCancellationRequested) { }
}
private async Task PumpAsync(NetworkStream source, NetworkStream destination)
{
var buffer = new byte[16 * 1024];
try
{
while (true)
{
var length = await source.ReadAsync(buffer, _stop.Token);
if (length == 0) return;
if (Volatile.Read(ref _pause) is { } paused) await paused.Task.WaitAsync(_stop.Token);
await destination.WriteAsync(buffer.AsMemory(0, length), _stop.Token);
}
}
catch (OperationCanceledException) when (_stop.IsCancellationRequested) { }
catch (IOException) when (_stop.IsCancellationRequested) { }
}
public async ValueTask DisposeAsync()
{
Resume(); _stop.Cancel(); _listener.Stop();
foreach (var socket in _sockets) socket.Dispose();
try { await _accept; await Task.WhenAll(_pumps); }
finally { _stop.Dispose(); }
}
}
}