using System.Collections; using System.Runtime.CompilerServices; using System.Reflection; using System.Text.Json; using Opc.Ua; using Opc.Ua.Client; using Opc.Ua.Configuration; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; public sealed partial class Sdk2LifecycleSoakTests { public static bool CloseControlEnabled => Environment.GetEnvironmentVariable("OTOPCUA_SDK2_CLOSE_CONTROL") == "1"; [Fact(SkipUnless = nameof(CloseControlEnabled), Skip = "Opt in to the bounded SDK2 close/weak-root control.")] public async Task Closed_tokens_are_rejected_and_ownership_is_observed_without_harness_roots() { using var deadline = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); deadline.CancelAfter(TimeSpan.FromSeconds(120)); var ct = deadline.Token; // Positive and negative controls for the observer itself, independent of SDK objects. var alive = new object(); var aliveReference = new WeakReference(alive); var collectible = CollectibleReference(); var observer = await ObserveWeakAsync([new("retained-control", aliveReference), new("collectible-control", collectible)], ct); observer.Last().Alive.ShouldBe([true, false]); GC.KeepAlive(alive); WriteCloseControl("observer-controls", observer); // The helper returns no rig, application, config, Session, point or store objects. var stoppedTargets = await Task.Run(() => RunCloseFixture(ct), ct).WaitAsync(TimeSpan.FromSeconds(120), ct); var stopped = await ObserveWeakAsync(stoppedTargets, ct); WriteCloseControl("after-normal-host-stop", stopped); stopped.Last().Alive.ShouldAllBe(value => !value); } [MethodImpl(MethodImplOptions.NoInlining)] private static WeakReference CollectibleReference() => new(new object()); private sealed record WeakTarget(string Kind, WeakReference Reference); private sealed record WeakObservation(int Collection, string[] Kinds, bool[] Alive); private static async Task ObserveWeakAsync(WeakTarget[] targets, CancellationToken ct) { var observations = new List(); // Fixed count/delay; no extension or retrieving/resurrecting targets during collection. var delays = new[] { 50, 100, 250, 500, 1000 }; for (var i = 0; i < delays.Length; i++) { ct.ThrowIfCancellationRequested(); await Task.Run(() => { GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: true); GC.WaitForPendingFinalizers(); GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: true); }, ct).WaitAsync(TimeSpan.FromSeconds(5), ct); await Task.Delay(delays[i], ct); observations.Add(new(i + 1, targets.Select(t => t.Kind).ToArray(), targets.Select(t => t.Reference.IsAlive).ToArray())); } return observations.ToArray(); } private static void WriteCloseControl(string phase, object observation) => TestContext.Current.TestOutputHelper!.WriteLine(JsonSerializer.Serialize(new { phase, observation, acceptance = false })); [MethodImpl(MethodImplOptions.NoInlining)] private static WeakTarget[] RunCloseFixture(CancellationToken ct) => ExerciseCloseOwnershipAsync(ct).GetAwaiter().GetResult(); [MethodImpl(MethodImplOptions.NoInlining)] private static WeakTarget[] RunClosedSession(ServerEventRig rig, ApplicationConfiguration config, CancellationToken ct) => CloseAndDenyOldTokensAsync(rig, config, ct).GetAwaiter().GetResult(); [MethodImpl(MethodImplOptions.NoInlining)] private static async Task ExerciseCloseOwnershipAsync(CancellationToken ct) { var clientRoot = Path.Combine(Path.GetTempPath(), $"otua-close-client-{Guid.NewGuid():N}"); 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 }, }; ApplicationInstance? application = new(telemetry) { ApplicationConfiguration = config }; ServerEventRig? rig = null; Exception? originalFailure = null; try { await config.ValidateAsync(ApplicationType.Client, ct); await application.CheckApplicationInstanceCertificatesAsync(true, null, ct); rig = await ServerEventRig.StartAsync("SDK2CloseOwnership", encrypted: true, ct); Configure(rig); var closedTargets = RunClosedSession(rig, config, ct); var closed = await ObserveWeakAsync(closedTargets, ct); WriteCloseControl("closed-objects-same-host-live", closed); closed.Last().Alive.ShouldAllBe(value => !value); GC.KeepAlive(rig); var clientManager = config.CertificateManager; var serverManager = rig.Server.CertificateManager!; var serverApplication = RequiredField(rig, "_host").ApplicationInstance!; var cores = CertificateCores(clientManager).Concat(CertificateCores(serverManager)).ToArray(); var stoppedTargets = new List { new("client-application", new(application)), new("client-config", new(config)), new("client-manager", new(clientManager)), new("server-manager", new(serverManager)), new("server-application", new(serverApplication)), new("server-rig", new(rig)), new("server", new(rig.Server)), new("node-manager", new(rig.Nm)), new("event-source", new(rig.Nm.HistorianDataSource!)), }; foreach (var core in cores) { stoppedTargets.Add(new("certificate-validation-core", new(core))); foreach (var store in RequiredField(core, "m_stores").Values) if (store is not null) stoppedTargets.Add(new("cached-store", new(store))); } await rig.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), ct); await application.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), ct); application = null; RequiredField(clientManager, "m_disposed").ShouldBeTrue(); RequiredField(serverManager, "m_disposed").ShouldBeTrue(); cores.Sum(CachedStores).ShouldBe(0); (await DescriptorCountsAsync(ct, clientRoot, RequiredField(rig, "_pkiRoot"))).Pki.ShouldBe(0); rig = null; WriteCloseControl("sdk2-disposal-proofs", new { managersDisposed = 2, cachedStores = 0, pkiDescriptors = 0 }); return stoppedTargets.ToArray(); } catch (Exception error) { originalFailure = error; throw; } finally { var cleanupFailures = new List(); try { try { if (rig is not null) await rig.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20)); } catch (Exception error) { cleanupFailures.Add(error); } } finally { try { try { if (application is not null) await application.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20)); } catch (Exception error) { cleanupFailures.Add(error); } } finally { try { if (Directory.Exists(clientRoot)) Directory.Delete(clientRoot, true); } catch (Exception error) { cleanupFailures.Add(error); } } } if (cleanupFailures.Count > 0) { if (originalFailure is not null) cleanupFailures.Insert(0, originalFailure); throw new AggregateException("Close-control cleanup failed; original failure retained when present.", cleanupFailures); } } } [MethodImpl(MethodImplOptions.NoInlining)] private static async Task CloseAndDenyOldTokensAsync(ServerEventRig rig, ApplicationConfiguration config, CancellationToken ct) { using var old = await ConnectAsync(rig, config, null, ct); var authenticationProperty = typeof(ClientBase).GetProperty("AuthenticationToken", BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new InvalidOperationException("Exact SDK2 authentication diagnostic is unavailable."); authenticationProperty.PropertyType.ShouldBe(typeof(NodeId)); var oldAuthentication = authenticationProperty.GetValue(old).ShouldBeOfType(); oldAuthentication.IsNull.ShouldBeFalse(); var browse = await BrowseAsync(old, ct); ByteString history = default; for (var i = 0; i < 6; i++) { var point = (await PageAsync(old, default, false, ct)).ContinuationPoint; point.IsEmpty.ShouldBeFalse(); if (i == 0) history = point; } browse.IsEmpty.ShouldBeFalse(); history.IsEmpty.ShouldBeFalse(); var serverSession = rig.Server.CurrentInstance.SessionManager.GetSessions().Single(); var points = serverSession.ContinuationPoints; ContinuationCounts(serverSession).ShouldBe((1, 6)); var weak = new List { new("closed-client-session", new(old)), new("closed-server-session", new(serverSession)), new("closed-point-owner", new(points)), }; using (RequiredField(points, "m_lock").EnterScope()) foreach (var field in new[] { "m_browse", "m_history" }) foreach (var point in RequiredField(points, field)) { weak.Add(new(field, new(point))); if (point is Opc.Ua.Server.ContinuationPoint browsePoint && browsePoint.Data is { } data) weak.Add(new("browse-data", new(data))); if (field == "m_history") { var value = point.GetType().GetField("Value")!.GetValue(point)!; weak.Add(new("history-value", new(value))); weak.Add(new("history-state", new(value.GetType().GetProperty("State")!.GetValue(value)!))); } } await old.CloseAsync(ct).WaitAsync(TimeSpan.FromSeconds(10), ct); rig.Server.CurrentInstance.SessionManager.GetSessions().ShouldBeEmpty(); ContinuationCounts(serverSession).ShouldBe((0, 0)); using var fresh = await ConnectAsync(rig, config, null, ct); await OwnPointsRoundTripAsync(fresh, ct); (await fresh.ReadValueAsync(Raw(fresh, Scalar), ct)).StatusCode.ShouldBe(StatusCodes.Good); var requestHeader = new RequestHeader { AuthenticationToken = oldAuthentication }; var failure = await Should.ThrowAsync(async () => await fresh.ReadAsync(requestHeader, 0, TimestampsToReturn.Neither, [new ReadValueId { NodeId = Raw(fresh, Scalar), AttributeId = Attributes.Value }], ct)); failure.StatusCode.ShouldBe(StatusCodes.BadSessionIdInvalid); requestHeader.AuthenticationToken.ShouldBe(oldAuthentication); (await fresh.BrowseNextAsync(null, false, [browse], ct)).Results[0].StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid); var source = rig.Nm.HistorianDataSource.ShouldBeOfType(); var readsBefore = source.ReadCount; (await PageAsync(fresh, history, false, ct)).StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid); source.ReadCount.ShouldBe(readsBefore, "invalid old tokens must not reach the backend"); (await fresh.ReadValueAsync(Raw(fresh, Scalar), ct)).StatusCode.ShouldBe(StatusCodes.Good); await OwnPointsRoundTripAsync(fresh, ct); await fresh.CloseAsync(ct).WaitAsync(TimeSpan.FromSeconds(10), ct); rig.Server.CurrentInstance.SessionManager.GetSessions().ShouldBeEmpty(); WriteCloseControl("wire-contract", new { oldAuthStatus = failure.StatusCode.Code, oldBrowseRejected = true, oldHistoryRejected = true, abandonedBrowse = 1, abandonedHistory = 6, freshPositiveRounds = 2 }); return weak.ToArray(); } private static async Task OwnPointsRoundTripAsync(Opc.Ua.Client.ISession session, CancellationToken ct) { var browse = await BrowseAsync(session, ct); browse.IsEmpty.ShouldBeFalse(); (await session.BrowseNextAsync(null, false, [browse], ct)).Results[0].StatusCode.ShouldBe(StatusCodes.Good); var history = (await PageAsync(session, default, false, ct)).ContinuationPoint; history.IsEmpty.ShouldBeFalse(); var resumed = await PageAsync(session, history, false, ct); resumed.StatusCode.ShouldBe(StatusCodes.Good); resumed.ContinuationPoint.IsEmpty.ShouldBeFalse(); (await PageAsync(session, resumed.ContinuationPoint, true, ct)).StatusCode.ShouldBe(StatusCodes.Good); } }