/* ======================================================================== * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Generic; using System.IO; using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Moq; using NUnit.Framework; using Opc.Ua.Server.Fluent; using Opc.Ua.Server.RuntimeNodeSet; namespace Opc.Ua.Server.Tests.RuntimeNodeSet { /// /// Unit tests for the /// async-lifetime hook: ordering with the synchronous /// callback, single-seal /// semantics, and the generation-owner disposal guarantees provided by /// . /// [TestFixture] [Category("RuntimeNodeSet")] [Parallelizable(ParallelScope.All)] [FixtureLifeCycle(LifeCycle.InstancePerTestCase)] public sealed class RuntimeNodeSetConfigureAsyncTests { private const string kUriA = "urn:test:ConfigureAsyncModel"; private static readonly string[] s_expectedOrder = ["sync", "async"]; private sealed class TrackingAsyncDisposable : IAsyncDisposable { public bool Disposed { get; private set; } public ValueTask DisposeAsync() { Disposed = true; return default; } } private static StreamRuntimeNodeSetSource MakeStreamSource(string modelUri) { string xml = "\r\n" + "\r\n" + " \r\n" + " " + modelUri + "\r\n" + " \r\n" + " \r\n" + " \r\n" + " \r\n" + " \r\n" + " ConfigureAsyncRoot\r\n" + " \r\n" + " i=58\r\n" + " i=85\r\n" + " \r\n" + " \r\n" + ""; return RuntimeNodeSetSource.FromStream( modelUri, _ => new ValueTask( new MemoryStream(Encoding.UTF8.GetBytes(xml))), [modelUri]); } /// /// Creates an mock with the namespace /// table, type tree, and master node manager scaffolding needed to /// exercise /// and /// end to end. /// private static Mock BuildMockServer() { ILoggerFactory mockLoggerFactory = LoggerFactory.Create(b => b.AddDebug()); var mockTelemetry = new Mock(); mockTelemetry.SetupGet(t => t.LoggerFactory).Returns(mockLoggerFactory); var namespaceTable = new NamespaceTable(); var mockConfigurationNodeManager = new Mock(); var mockMasterNodeManager = new Mock(); mockMasterNodeManager .SetupGet(m => m.ConfigurationNodeManager) .Returns(mockConfigurationNodeManager.Object); var mockServer = new Mock(); mockServer.SetupGet(s => s.Telemetry).Returns(mockTelemetry.Object); mockServer.SetupGet(s => s.NamespaceUris).Returns(namespaceTable); mockServer.SetupGet(s => s.ServerUris).Returns(new StringTable()); mockServer.SetupGet(s => s.TypeTree).Returns(new TypeTable(namespaceTable)); mockServer.SetupGet(s => s.Factory).Returns(EncodeableFactory.Create()); mockServer.SetupGet(s => s.NodeManager).Returns(mockMasterNodeManager.Object); mockServer.SetupGet(s => s.MonitoredItemQueueFactory) .Returns(new MonitoredItemQueueFactory(mockTelemetry.Object)); var systemContext = new ServerSystemContext(mockServer.Object); mockServer.SetupGet(s => s.DefaultSystemContext).Returns(systemContext); return mockServer; } /// /// Asserts that has not been sealed yet by /// probing an unresolvable NodeId: an unsealed builder fails /// resolution with , while a /// sealed builder instead fails with /// before resolution is even /// attempted. /// private static void AssertBuilderNotSealed(INodeManagerBuilder builder) { ServiceResultException ex = Assert.Throws( () => builder.Node(new NodeId(Guid.NewGuid()))); Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadNodeIdUnknown)); } /// /// Asserts that has been sealed: any /// further Node(...) call must fail with /// . /// private static void AssertBuilderSealed(INodeManagerBuilder builder) { ServiceResultException ex = Assert.Throws( () => builder.Node(new NodeId(Guid.NewGuid()))); Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadInvalidState)); } private static async Task CreateManagerAsync( Action? configure = null, Func>? configureAsync = null) { StreamRuntimeNodeSetSource source = MakeStreamSource(kUriA); var factory = new RuntimeNodeSetNodeManagerFactory(new RuntimeNodeSetOptions { Sources = [source], DefaultNamespaceUri = kUriA, Configure = configure, ConfigureAsync = configureAsync }); return await factory.CreateAsync( BuildMockServer().Object, new ApplicationConfiguration(), CancellationToken.None).ConfigureAwait(false); } /// /// A sync-only callback /// keeps working exactly as before: it runs, the builder is sealed /// once, and no generation owner is tracked. /// [Test] public async Task SyncOnlyConfigureStillRunsAndSealsAsync() { bool configureRan = false; INodeManagerBuilder? capturedBuilder = null; IAsyncNodeManager manager = await CreateManagerAsync( configure: builder => { configureRan = true; capturedBuilder = builder; }).ConfigureAwait(false); var externalReferences = new Dictionary>(); await manager.CreateAddressSpaceAsync(externalReferences, CancellationToken.None) .ConfigureAwait(false); Assert.That(configureRan, Is.True); Assert.That(capturedBuilder, Is.Not.Null); AssertBuilderSealed(capturedBuilder!); await manager.DeleteAddressSpaceAsync(CancellationToken.None).ConfigureAwait(false); } /// /// An async-only /// callback runs before the builder is sealed: wiring calls made /// from inside the callback must succeed. /// [Test] public async Task AsyncOnlyConfigureRunsBeforeSealAsync() { bool configureAsyncRan = false; INodeManagerBuilder? capturedBuilder = null; IAsyncNodeManager manager = await CreateManagerAsync( configureAsync: async (builder, ct) => { await Task.Yield(); capturedBuilder = builder; AssertBuilderNotSealed(builder); configureAsyncRan = true; return null!; }).ConfigureAwait(false); var externalReferences = new Dictionary>(); await manager.CreateAddressSpaceAsync(externalReferences, CancellationToken.None) .ConfigureAwait(false); Assert.That(configureAsyncRan, Is.True); AssertBuilderSealed(capturedBuilder!); await manager.DeleteAddressSpaceAsync(CancellationToken.None).ConfigureAwait(false); } /// /// When both callbacks are set, Configure runs first, then /// ConfigureAsync, both against the same unsealed builder, /// with a single Seal() applied once after both complete. /// [Test] public async Task BothConfigureCallbacksRunInOrderWithSingleSealAsync() { var order = new List(); INodeManagerBuilder? capturedBuilder = null; IAsyncNodeManager manager = await CreateManagerAsync( configure: builder => { order.Add("sync"); capturedBuilder = builder; AssertBuilderNotSealed(builder); }, configureAsync: async (builder, ct) => { await Task.Yield(); order.Add("async"); Assert.That(builder, Is.SameAs(capturedBuilder)); // Still unsealed: the sync callback must not have sealed it. AssertBuilderNotSealed(builder); return null!; }).ConfigureAwait(false); var externalReferences = new Dictionary>(); await manager.CreateAddressSpaceAsync(externalReferences, CancellationToken.None) .ConfigureAwait(false); Assert.That(order, Is.EqualTo(s_expectedOrder)); AssertBuilderSealed(capturedBuilder!); await manager.DeleteAddressSpaceAsync(CancellationToken.None).ConfigureAwait(false); } /// /// The generation owner returned by ConfigureAsync is /// disposed asynchronously when the manager's address space is /// torn down via /// (the normal-removal path). /// [Test] public async Task OwnerDisposedOnDeleteAddressSpaceAsync() { await using TrackingAsyncDisposable owner = new(); IAsyncNodeManager manager = await CreateManagerAsync( configureAsync: (_, _) => new ValueTask(owner)) .ConfigureAwait(false); var externalReferences = new Dictionary>(); await manager.CreateAddressSpaceAsync(externalReferences, CancellationToken.None) .ConfigureAwait(false); Assert.That(owner.Disposed, Is.False); await manager.DeleteAddressSpaceAsync(CancellationToken.None).ConfigureAwait(false); Assert.That(owner.Disposed, Is.True); } /// /// If node-added notification fails after ConfigureAsync /// produced a generation owner, CreateAddressSpaceAsync /// disposes the owner before propagating the activation failure. /// [Test] public async Task OwnerDisposedWhenNodeAddedCallbackFailsAsync() { await using TrackingAsyncDisposable owner = new(); IAsyncNodeManager manager = await CreateManagerAsync( configure: builder => builder .Node("ConfigureAsyncRoot") .OnNodeAdded((_, _) => throw new InvalidOperationException( "Simulated node-added failure.")), configureAsync: (_, _) => new ValueTask(owner)) .ConfigureAwait(false); var externalReferences = new Dictionary>(); InvalidOperationException? exception = null; try { await manager.CreateAddressSpaceAsync( externalReferences, CancellationToken.None).ConfigureAwait(false); } catch (InvalidOperationException ex) { exception = ex; } Assert.That(exception, Is.Not.Null); Assert.That(owner.Disposed, Is.True); await manager.DeleteAddressSpaceAsync(CancellationToken.None).ConfigureAwait(false); } /// /// Each node-manager generation owns exactly one generation owner. /// Tearing down one generation (e.g. the old generation retired /// after a shadow reload) must not affect another, independently /// created generation's owner; that owner is only disposed when /// its own generation is retired. /// [Test] public async Task OwnerIsolatedAcrossGenerationsAndDisposedOnlyWhenRetiredAsync() { await using TrackingAsyncDisposable ownerOld = new(); await using TrackingAsyncDisposable ownerNew = new(); IAsyncNodeManager oldGeneration = await CreateManagerAsync( configureAsync: (_, _) => new ValueTask(ownerOld)) .ConfigureAwait(false); IAsyncNodeManager newGeneration = await CreateManagerAsync( configureAsync: (_, _) => new ValueTask(ownerNew)) .ConfigureAwait(false); var externalReferencesOld = new Dictionary>(); var externalReferencesNew = new Dictionary>(); await oldGeneration.CreateAddressSpaceAsync(externalReferencesOld, CancellationToken.None) .ConfigureAwait(false); await newGeneration.CreateAddressSpaceAsync(externalReferencesNew, CancellationToken.None) .ConfigureAwait(false); // Retiring the old generation (as happens once a shadow reload's // replacement generation has taken over all new requests) must // dispose only its own owner. await oldGeneration.DeleteAddressSpaceAsync(CancellationToken.None).ConfigureAwait(false); Assert.That(ownerOld.Disposed, Is.True); Assert.That(ownerNew.Disposed, Is.False); await newGeneration.DeleteAddressSpaceAsync(CancellationToken.None).ConfigureAwait(false); Assert.That(ownerNew.Disposed, Is.True); } } }