using Opc.Ua; using Opc.Ua.Configuration; using Opc.Ua.Server; using Shouldly; using Xunit; using ClientSession = Opc.Ua.Client.ISession; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; public sealed partial class Sdk2LifecycleSoakTests { /// Concurrent host disposal awaits real encrypted-session shutdown before clearing ownership. [Fact] public async Task Concurrent_host_disposal_waits_for_the_actual_encrypted_session_to_close() { var ct = TestContext.Current.CancellationToken; var clientRoot = Path.Combine(Path.GetTempPath(), $"otua-dispose-client-{Guid.NewGuid():N}"); ApplicationInstance? application = null; ServerEventRig? rig = null; ClientSession? client = null; ISessionManager? sessionManager = null; SessionEventHandler? closing = null; using var release = new ManualResetEventSlim(false); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var calls = new List(); var timedOut = 0; var callbackCount = 0; 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("SDK2ConcurrentDisposal", encrypted: true, ct); Configure(rig); client = await ConnectAsync(rig, config, null, ct); sessionManager = rig.Server.CurrentInstance.SessionManager; sessionManager.GetSessions().Count.ShouldBe(1); var sessionId = client.SessionId; closing = (session, reason) => { if (session.Id != sessionId || reason != SessionEventReason.Closing) return; Interlocked.Increment(ref callbackCount); entered.TrySetResult(); // SDK swallows event exceptions; keep an explicit failure flag for this bounded barrier. if (!release.Wait(TimeSpan.FromSeconds(15))) Interlocked.Exchange(ref timedOut, 1); }; sessionManager.SessionClosing += closing; var host = RequiredField(rig, "_host"); var manager = rig.Server.CertificateManager!; calls.Add(Task.Run(async () => await host.DisposeAsync(), ct)); await entered.Task.WaitAsync(TimeSpan.FromSeconds(10), ct); for (var i = 0; i < 3; i++) calls.Add(host.DisposeAsync().AsTask()); calls.ShouldAllBe(call => !call.IsCompleted, "every caller must await the actual shutdown owner"); host.Server.ShouldBeSameAs(rig.Server); RequiredField(manager, "m_disposed").ShouldBeFalse(); release.Set(); await Task.WhenAll(calls).WaitAsync(TimeSpan.FromSeconds(20), ct); Volatile.Read(ref timedOut).ShouldBe(0); Volatile.Read(ref callbackCount).ShouldBe(1); host.Server.ShouldBeNull(); host.ApplicationInstance.ShouldBeNull(); RequiredField(manager, "m_disposed").ShouldBeTrue(); CertificateCores(manager).Sum(CachedStores).ShouldBe(0); } finally { // Removing a handler takes the SDK's event lock: release before awaiting or unsubscribing. release.Set(); try { await Task.WhenAll(calls).WaitAsync(TimeSpan.FromSeconds(20), CancellationToken.None); } finally { try { if (sessionManager is not null && closing is not null) sessionManager.SessionClosing -= closing; } finally { try { client?.Dispose(); } finally { try { if (rig is not null) await rig.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), CancellationToken.None); } finally { try { if (application is not null) await application.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), CancellationToken.None); } finally { if (Directory.Exists(clientRoot)) Directory.Delete(clientRoot, true); } } } } } } } }