From 58f8d57f5a03eccfc4cdb8335e8f94fd4fcbe7ea Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 17:05:22 -0500 Subject: [PATCH] test: expose concurrent SDK2 host disposal completion race --- .../Sdk2HostConcurrentDisposalTests.cs | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2HostConcurrentDisposalTests.cs diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2HostConcurrentDisposalTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2HostConcurrentDisposalTests.cs new file mode 100644 index 00000000..12414b79 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2HostConcurrentDisposalTests.cs @@ -0,0 +1,99 @@ +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); } + } + } + } + } + } + } +}