mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-19 15:22:15 +08:00
9b034f8291
# Description Nullable reference types are now enabled once, in `common.props`, and every project in the repository builds warning-clean with them. **Configuration** - `<Nullable>enable</Nullable>` lives in `common.props`. All per-project `<Nullable>` settings are removed: 178 project files (enable, annotations, and the `disable` in the three Aot test projects). - 419 per-file `#nullable enable`/`restore` directives and the 4 `#nullable disable` directives are removed. - Kept on purpose: - The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`, `PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the project setting in generated code, so the directive is required there. - `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against the 1.5.378 packages and does not import the repository props. - `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now describe the central setting. **Migration** (the 43 unset and 13 annotations-only projects, roughly 10k diagnostics) - **Production code: annotated at the declarations.** Covers `tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the LDS sample. - Members, parameters and returns are nullable where null actually flows. - Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`, `IsMethodTypeDesign`). - `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take `params object?[]`. `Template.AddReplacement` takes `string?` (rendering already skips null replacements). - `IRegisteredServerStore.RegisterAsync` returns `Task<RegistrationEntry?>`: it returns null when a registration is removed. - Dereferences backed by an invariant (for example, the validated model) use `!`, so runtime behaviour is unchanged. - **Behavioural changes in production code** (small, deliberate): - `NodeSetToModelDesign` throws `InvalidOperationException` when the file is not a NodeSet. Before, it threw a NullReferenceException on the next line. - `ModelDesignValidator` starts its lookup tables empty instead of null. A redundant `if (m_nodes == null) return;` guard is gone. - **Test code: minimal annotations.** - `?` on values that can be null, `!` where the test asserts non-null. - `= null!` for fixture members assigned in SetUp. - `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the `IEncodeable` constraint. - A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`) folded into the adjacent `IsNull`/`Count` checks. **Notes for reviewers** - The diff is large (~1,500 files) but almost entirely mechanical: `?`, `!` and directive removal. The non-annotation changes are listed above. - Known follow-up, not done here: `IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay non-nullable even though the validator returns null before it loads a dictionary. Making them nullable would ripple through every generator call site. ## Related Issues - Fixes #4439 ## Checklist - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [ ] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. The "no new warnings" box covers net10.0 and net48: full non-incremental builds of `UA.slnx`, `tools/SourceGeneration.slnx`, `tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0 warnings and 0 errors under the repository's warnings-as-errors settings. net9.0 and net8.0 also had no nullable diagnostics, but they were not rebuilt after the final fixes. Tests have not been run locally yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1863 lines
88 KiB
C#
1863 lines
88 KiB
C#
/* ========================================================================
|
|
* 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.Linq;
|
|
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;
|
|
using Opc.Ua.Server.TestFramework;
|
|
using Opc.Ua.Tests;
|
|
using Quickstarts.ReferenceServer;
|
|
|
|
namespace Opc.Ua.Server.Tests.RuntimeNodeSet
|
|
{
|
|
/// <summary>
|
|
/// Live lifecycle integration tests for runtime NodeSet-backed NodeManagers, exercising
|
|
/// <see cref="RuntimeNodeSetLifecycleExtensions.AddRuntimeNodeSetAsync"/>,
|
|
/// <see cref="RuntimeNodeSetLifecycleExtensions.ReloadRuntimeNodeSetAsync"/>, and
|
|
/// <see cref="INodeManagerLifecycle.RemoveAsync"/> against a real, running
|
|
/// <see cref="ReferenceServer"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Each test starts a fresh <see cref="ServerFixture{ReferenceServer}"/> so that the
|
|
/// namespace-table and routing-table baselines are predictable and unaffected by other
|
|
/// tests' live add/reload/remove mutations.
|
|
/// </remarks>
|
|
[TestFixture]
|
|
[Category("NodeManagerLifecycle")]
|
|
[Category("RuntimeNodeSet")]
|
|
[Category("Server")]
|
|
[SetCulture("en-us")]
|
|
[SetUICulture("en-us")]
|
|
[NonParallelizable]
|
|
public sealed class RuntimeNodeSetLifecycleTests
|
|
{
|
|
private const double kMaxAge = 10000;
|
|
|
|
private const string kModelNamespaceUri =
|
|
"urn:opcfoundation.org:Tests:RuntimeNodeSetLifecycle";
|
|
|
|
private const uint kRootNodeId = 9000;
|
|
private const uint kValueNodeId = 9001;
|
|
private const uint kOriginalOnlyNodeId = 9002;
|
|
private const uint kReplacementOnlyNodeId = 9003;
|
|
private const string kRootBrowseName = "LifecycleRoot";
|
|
private const string kValueBrowseName = "LifecycleValue";
|
|
private const string kOriginalOnlyBrowseName = "OriginalOnly";
|
|
private const string kReplacementOnlyBrowseName = "ReplacementOnly";
|
|
private const int kGeneration1Value = 1;
|
|
private const int kGeneration2Value = 2;
|
|
private const int kOriginalOnlyValue = 100;
|
|
private const int kReplacementOnlyValue = 200;
|
|
|
|
private string m_pkiRoot;
|
|
private ServerFixture<ReferenceServer> m_fixture;
|
|
private ReferenceServer m_server;
|
|
private RequestHeader m_requestHeader;
|
|
private SecureChannelContext m_secureChannelContext;
|
|
private ILogger m_logger;
|
|
private HashSet<Guid> m_startupRegistrationIds;
|
|
|
|
/// <summary>
|
|
/// Starts a fresh <see cref="ReferenceServer"/> and activates a session for the test.
|
|
/// </summary>
|
|
[SetUp]
|
|
public async Task SetUpAsync()
|
|
{
|
|
m_pkiRoot = Path.Combine(
|
|
TestContext.CurrentContext.WorkDirectory,
|
|
nameof(RuntimeNodeSetLifecycleTests),
|
|
Guid.NewGuid().ToString("N"));
|
|
|
|
m_fixture = new ServerFixture<ReferenceServer>(t => new ReferenceServer(t))
|
|
{
|
|
UriScheme = Utils.UriSchemeOpcTcp,
|
|
SecurityNone = true,
|
|
AutoAccept = true
|
|
};
|
|
|
|
m_server = await m_fixture.StartAsync(m_pkiRoot).ConfigureAwait(false);
|
|
m_logger = NUnitTelemetryContext.Create().CreateLogger<RuntimeNodeSetLifecycleTests>();
|
|
m_startupRegistrationIds = [];
|
|
m_server.NodeManagerLifecycle.Registrations.ForEach(
|
|
registration => m_startupRegistrationIds.Add(registration.Id));
|
|
|
|
(m_requestHeader, m_secureChannelContext) = await m_server
|
|
.CreateAndActivateSessionAsync(TestContext.CurrentContext.Test.Name)
|
|
.ConfigureAwait(false);
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Closes the session, stops the server, and cleans up PKI artefacts.
|
|
/// </summary>
|
|
[TearDown]
|
|
public async Task TearDownAsync()
|
|
{
|
|
if (m_requestHeader is not null)
|
|
{
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
await m_server
|
|
.CloseSessionAsync(m_secureChannelContext, m_requestHeader, true, RequestLifetime.None)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
m_server?.Dispose();
|
|
|
|
if (m_fixture is not null)
|
|
{
|
|
await m_fixture.StopAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(m_pkiRoot) && Directory.Exists(m_pkiRoot))
|
|
{
|
|
Directory.Delete(m_pkiRoot, recursive: true);
|
|
}
|
|
}
|
|
|
|
[TestCase(true, false)]
|
|
[TestCase(false, false)]
|
|
[TestCase(true, true)]
|
|
[TestCase(false, true)]
|
|
public async Task RuntimeMethodCallsUseAuthoredArgumentsAsync(
|
|
bool includeParentHints,
|
|
bool useNamespaceUriTargets)
|
|
{
|
|
NodeManagerRegistration registration = await m_server.NodeManagerLifecycle
|
|
.AddRuntimeNodeSetAsync(
|
|
StartupRuntimeNodeSetServer.CreatePrimaryOptions(1, includeParentHints, useNamespaceUriTargets),
|
|
null)
|
|
.ConfigureAwait(false);
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(
|
|
StartupRuntimeNodeSetServer.PrimaryNamespaceUri);
|
|
var rootId = new NodeId(StartupRuntimeNodeSetServer.PrimaryRootNodeId, namespaceIndex);
|
|
var methodId = new NodeId(StartupRuntimeNodeSetServer.LoadMethodNodeId, namespaceIndex);
|
|
ArrayOf<CallMethodRequest> calls =
|
|
[
|
|
new CallMethodRequest
|
|
{
|
|
ObjectId = rootId,
|
|
MethodId = methodId,
|
|
InputArguments = [Variant.From("Rev1")]
|
|
},
|
|
new CallMethodRequest { ObjectId = rootId, MethodId = methodId },
|
|
new CallMethodRequest
|
|
{
|
|
ObjectId = rootId,
|
|
MethodId = methodId,
|
|
InputArguments = [Variant.From("Rev1"), Variant.From("Rev2")]
|
|
},
|
|
new CallMethodRequest
|
|
{
|
|
ObjectId = rootId,
|
|
MethodId = methodId,
|
|
InputArguments = [Variant.From(42)]
|
|
}
|
|
];
|
|
|
|
m_requestHeader.Timestamp = DateTimeUtc.Now;
|
|
CallResponse response = await m_server.CallAsync(
|
|
m_secureChannelContext,
|
|
m_requestHeader,
|
|
calls,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
Assert.That(response.ResponseHeader.ServiceResult, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(response.Results, Has.Count.EqualTo(4));
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(response.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(response.Results[0].OutputArguments, Has.Count.EqualTo(1));
|
|
Assert.That(response.Results[0].OutputArguments[0].GetBoolean(), Is.True);
|
|
Assert.That(response.Results[1].StatusCode, Is.EqualTo(StatusCodes.BadArgumentsMissing));
|
|
Assert.That(response.Results[2].StatusCode, Is.EqualTo(StatusCodes.BadTooManyArguments));
|
|
Assert.That(response.Results[3].StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
|
|
Assert.That(response.Results[3].InputArgumentResults, Has.Count.EqualTo(1));
|
|
Assert.That(response.Results[3].InputArgumentResults[0], Is.EqualTo(StatusCodes.BadTypeMismatch));
|
|
});
|
|
|
|
await m_server.NodeManagerLifecycle.RemoveAsync(registration, null).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adding a runtime NodeSet after startup must publish exactly one registration,
|
|
/// route it into the master node manager's live snapshots exactly once, append the
|
|
/// model namespace exactly once, and increment <c>UrisVersion</c> by one.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task AddRuntimeNodeSetAsyncPublishesRegistrationAndRoutingAsync()
|
|
{
|
|
IServerInternal server = m_server.CurrentInstance;
|
|
var master = (MasterNodeManager)server.NodeManager;
|
|
|
|
int namespaceCountBefore = server.NamespaceUris.Count;
|
|
IReadOnlyList<IAsyncNodeManager> managersBefore = master.AsyncNodeManagers;
|
|
IReadOnlyDictionary<int, IReadOnlyList<IAsyncNodeManager>> routesBefore =
|
|
master.NamespaceManagers;
|
|
uint urisVersionBefore = await ReadUrisVersionAsync().ConfigureAwait(false);
|
|
|
|
NodeManagerRegistration registration = await m_server.NodeManagerLifecycle
|
|
.AddRuntimeNodeSetAsync(CreateOptions(generation: 1), null)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(server.CurrentState, Is.EqualTo(ServerState.Running));
|
|
Assert.That(registration.Id, Is.Not.EqualTo(Guid.Empty));
|
|
Assert.That(registration.Generation, Is.EqualTo(1));
|
|
Assert.That(registration.NodeManager, Is.InstanceOf<RuntimeNodeSetNodeManager>());
|
|
Assert.That(registration.NamespaceUris.Count, Is.EqualTo(1));
|
|
Assert.That(registration.NamespaceUris[0], Is.EqualTo(kModelNamespaceUri));
|
|
|
|
ArrayOf<NodeManagerRegistration> registrations = m_server.NodeManagerLifecycle.Registrations;
|
|
Assert.That(CountMatches(registrations, r => r.Id == registration.Id), Is.EqualTo(1));
|
|
|
|
IReadOnlyList<IAsyncNodeManager> managersAfter = master.AsyncNodeManagers;
|
|
IReadOnlyDictionary<int, IReadOnlyList<IAsyncNodeManager>> routesAfter =
|
|
master.NamespaceManagers;
|
|
|
|
Assert.That(
|
|
managersBefore.Count(m => ReferenceEquals(m, registration.NodeManager)),
|
|
Is.Zero,
|
|
"The pre-add snapshot must be unaffected by the subsequent Add.");
|
|
Assert.That(
|
|
managersAfter.Count(m => ReferenceEquals(m, registration.NodeManager)),
|
|
Is.EqualTo(1));
|
|
Assert.That(managersAfter, Has.Count.EqualTo(managersBefore.Count + 1));
|
|
|
|
int namespaceIndex = server.NamespaceUris.GetIndex(kModelNamespaceUri);
|
|
Assert.That(namespaceIndex, Is.GreaterThan(0));
|
|
Assert.That(server.NamespaceUris.Count, Is.EqualTo(namespaceCountBefore + 1));
|
|
|
|
Assert.That(routesBefore.ContainsKey(namespaceIndex), Is.False);
|
|
Assert.That(routesAfter.ContainsKey(namespaceIndex), Is.True);
|
|
Assert.That(routesAfter[namespaceIndex], Has.Count.EqualTo(1));
|
|
Assert.That(
|
|
ReferenceEquals(routesAfter[namespaceIndex][0], registration.NodeManager),
|
|
Is.True);
|
|
|
|
uint urisVersionAfter = await ReadUrisVersionAsync().ConfigureAwait(false);
|
|
Assert.That(urisVersionAfter, Is.EqualTo(urisVersionBefore + 1));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The fluent base class dispatches monitored-item lifecycle callbacks
|
|
/// to every attached builder, so the manager must not dispatch a second
|
|
/// time on its own.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task RuntimeNodeSetMonitoredItemCallbacksRunOnceAsync()
|
|
{
|
|
int created = 0;
|
|
int deleted = 0;
|
|
RuntimeNodeSetOptions options = CreateOptions(generation: 1);
|
|
options.Configure += builder =>
|
|
builder.Node($"{kRootBrowseName}/{kValueBrowseName}")
|
|
.OnMonitoredItemCreated((_, _, _) => created++)
|
|
.OnMonitoredItemDeleted((_, _, _, _) =>
|
|
{
|
|
deleted++;
|
|
return default;
|
|
});
|
|
NodeManagerRegistration registration = await m_server.NodeManagerLifecycle
|
|
.AddRuntimeNodeSetAsync(options, null).ConfigureAwait(false);
|
|
try
|
|
{
|
|
var services = new ServerTestServices(m_server, m_secureChannelContext);
|
|
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris
|
|
.GetIndex(kModelNamespaceUri);
|
|
uint subscriptionId = await CreateSubscriptionWithMonitoredItemAsync(
|
|
services, new NodeId(kValueNodeId, namespaceIndex)).ConfigureAwait(false);
|
|
try
|
|
{
|
|
Assert.That(created, Is.EqualTo(1));
|
|
}
|
|
finally
|
|
{
|
|
await DeleteSubscriptionAsync(services, subscriptionId).ConfigureAwait(false);
|
|
}
|
|
Assert.That(deleted, Is.EqualTo(1));
|
|
}
|
|
finally
|
|
{
|
|
await m_server.NodeManagerLifecycle.RemoveAsync(
|
|
registration, callerContext: null).ConfigureAwait(false);
|
|
}
|
|
}
|
|
/// <summary>
|
|
/// Simulations must not start before the NotifyNodeAdded replay, so a
|
|
/// simulated value change can never precede the OnNodeAdded handler of
|
|
/// its own node.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task RuntimeNodeSetSimulationStartsAfterNodeAddedReplayAsync()
|
|
{
|
|
int nodeAddedCompleted = 0;
|
|
int ticks = 0;
|
|
bool? firstTickSawCompletedReplay = null;
|
|
RuntimeNodeSetOptions options = CreateOptions(generation: 1);
|
|
options.Configure += builder =>
|
|
{
|
|
builder.Node($"{kRootBrowseName}/{kValueBrowseName}")
|
|
.OnNodeAdded((_, _) =>
|
|
{
|
|
// Hold the replay open long enough that a simulation
|
|
// started too early is certain to tick meanwhile.
|
|
Thread.Sleep(100);
|
|
Volatile.Write(ref nodeAddedCompleted, 1);
|
|
});
|
|
builder.Simulation(TimeSpan.FromMilliseconds(5))
|
|
.OnTick((_, _) =>
|
|
{
|
|
if (Interlocked.Increment(ref ticks) == 1)
|
|
{
|
|
firstTickSawCompletedReplay =
|
|
Volatile.Read(ref nodeAddedCompleted) == 1;
|
|
}
|
|
});
|
|
};
|
|
|
|
NodeManagerRegistration registration = await m_server.NodeManagerLifecycle
|
|
.AddRuntimeNodeSetAsync(options, null).ConfigureAwait(false);
|
|
try
|
|
{
|
|
DateTime deadline = DateTime.UtcNow.AddSeconds(10);
|
|
while (Volatile.Read(ref ticks) == 0 && DateTime.UtcNow < deadline)
|
|
{
|
|
await Task.Delay(10).ConfigureAwait(false);
|
|
}
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(
|
|
Volatile.Read(ref ticks),
|
|
Is.GreaterThan(0),
|
|
"The configured simulation must run once the manager is active.");
|
|
Assert.That(
|
|
firstTickSawCompletedReplay,
|
|
Is.True,
|
|
"The OnNodeAdded replay must complete before a simulation ticks.");
|
|
});
|
|
}
|
|
finally
|
|
{
|
|
await m_server.NodeManagerLifecycle.RemoveAsync(
|
|
registration, callerContext: null).ConfigureAwait(false);
|
|
}
|
|
}
|
|
[Test]
|
|
public async Task PreparedRuntimeNodeSetRemainsHiddenUntilCommitAsync()
|
|
{
|
|
IServerInternal server = m_server.CurrentInstance;
|
|
var master = (MasterNodeManager)server.NodeManager;
|
|
var host = (IDynamicNodeManagerHost)master;
|
|
var factory = new RuntimeNodeSetNodeManagerFactory(
|
|
CreateOptions(generation: 1));
|
|
IAsyncNodeManager nodeManager = await factory
|
|
.CreateAsync(server, m_fixture.Config)
|
|
.ConfigureAwait(false);
|
|
PreparedNodeManager prepared = await host
|
|
.PrepareAsync(nodeManager)
|
|
.ConfigureAwait(false);
|
|
|
|
try
|
|
{
|
|
await host.PublishAsync(prepared).ConfigureAwait(false);
|
|
|
|
ushort ns = (ushort)server.NamespaceUris.GetIndex(
|
|
kModelNamespaceUri);
|
|
var rootNodeId = new NodeId(kRootNodeId, ns);
|
|
Assert.That(
|
|
master.AsyncNodeManagers.Any(manager =>
|
|
ReferenceEquals(manager, nodeManager)),
|
|
Is.False);
|
|
Assert.That(
|
|
await master.FindNodeInAddressSpaceAsync(rootNodeId)
|
|
.ConfigureAwait(false),
|
|
Is.Null);
|
|
|
|
await host.CommitAsync(prepared).ConfigureAwait(false);
|
|
|
|
Assert.That(
|
|
master.AsyncNodeManagers.Any(manager =>
|
|
ReferenceEquals(manager, nodeManager)),
|
|
Is.True);
|
|
Assert.That(
|
|
await master.FindNodeInAddressSpaceAsync(rootNodeId)
|
|
.ConfigureAwait(false),
|
|
Is.Not.Null);
|
|
}
|
|
finally
|
|
{
|
|
await host.RollbackAsync(prepared).ConfigureAwait(false);
|
|
(nodeManager as IDisposable)?.Dispose();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Nodes added through a live runtime NodeSet must be directly findable, browsable
|
|
/// from both the Objects folder and their parent, translatable via a relative browse
|
|
/// path, and readable through the Read service with a concrete <c>Good</c> value.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task AddedRuntimeNodesAreFindableBrowsableReadableAndTranslatableAsync()
|
|
{
|
|
IServerInternal server = m_server.CurrentInstance;
|
|
|
|
_ = await m_server.NodeManagerLifecycle
|
|
.AddRuntimeNodeSetAsync(CreateOptions(generation: 1), null)
|
|
.ConfigureAwait(false);
|
|
|
|
ushort ns = (ushort)server.NamespaceUris.GetIndex(kModelNamespaceUri);
|
|
var rootNodeId = new NodeId(kRootNodeId, ns);
|
|
var valueNodeId = new NodeId(kValueNodeId, ns);
|
|
|
|
// Direct find.
|
|
NodeState rootNode = (await server.NodeManager
|
|
.FindNodeInAddressSpaceAsync(rootNodeId)
|
|
.ConfigureAwait(false))!;
|
|
NodeState valueNode = (await server.NodeManager
|
|
.FindNodeInAddressSpaceAsync(valueNodeId)
|
|
.ConfigureAwait(false))!;
|
|
|
|
Assert.That(rootNode, Is.Not.Null);
|
|
Assert.That(rootNode.BrowseName.Name, Is.EqualTo(kRootBrowseName));
|
|
Assert.That(valueNode, Is.Not.Null);
|
|
Assert.That(valueNode.BrowseName.Name, Is.EqualTo(kValueBrowseName));
|
|
|
|
// Browse Objects -> root.
|
|
BrowseResponse objectsBrowse = await BrowseAsync(ObjectIds.ObjectsFolder).ConfigureAwait(false);
|
|
Assert.That(objectsBrowse.Results.Count, Is.EqualTo(1));
|
|
ArrayOf<ReferenceDescription> objectsReferences = objectsBrowse.Results[0].References;
|
|
Assert.That(
|
|
objectsReferences.Contains(r => r.BrowseName.Equals(new QualifiedName(kRootBrowseName, ns))),
|
|
Is.True);
|
|
|
|
// Browse root -> value.
|
|
BrowseResponse rootBrowse = await BrowseAsync(rootNodeId).ConfigureAwait(false);
|
|
Assert.That(rootBrowse.Results.Count, Is.EqualTo(1));
|
|
ArrayOf<ReferenceDescription> rootReferences = rootBrowse.Results[0].References;
|
|
Assert.That(
|
|
rootReferences.Contains(r => r.BrowseName.Equals(new QualifiedName(kValueBrowseName, ns))),
|
|
Is.True);
|
|
|
|
// Translate ObjectsFolder/LifecycleRoot/LifecycleValue.
|
|
var services = new ServerTestServices(m_server, m_secureChannelContext);
|
|
var relativePath = new RelativePath
|
|
{
|
|
Elements =
|
|
[
|
|
new RelativePathElement
|
|
{
|
|
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
|
IsInverse = false,
|
|
IncludeSubtypes = true,
|
|
TargetName = new QualifiedName(kRootBrowseName, ns)
|
|
},
|
|
new RelativePathElement
|
|
{
|
|
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
|
IsInverse = false,
|
|
IncludeSubtypes = true,
|
|
TargetName = new QualifiedName(kValueBrowseName, ns)
|
|
}
|
|
]
|
|
};
|
|
ArrayOf<BrowsePath> browsePaths =
|
|
[
|
|
new BrowsePath { StartingNode = ObjectIds.ObjectsFolder, RelativePath = relativePath }
|
|
];
|
|
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
TranslateBrowsePathsToNodeIdsResponse translateResponse = await services
|
|
.TranslateBrowsePathsToNodeIdsAsync(requestHeader, browsePaths)
|
|
.ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(
|
|
translateResponse.ResponseHeader,
|
|
translateResponse.Results,
|
|
browsePaths);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
translateResponse.DiagnosticInfos,
|
|
browsePaths,
|
|
translateResponse.ResponseHeader.StringTable,
|
|
m_logger);
|
|
|
|
Assert.That(translateResponse.Results.Count, Is.EqualTo(1));
|
|
BrowsePathResult translateResult = translateResponse.Results[0];
|
|
Assert.That(translateResult.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(translateResult.Targets.Count, Is.EqualTo(1));
|
|
Assert.That(
|
|
translateResult.Targets[0].TargetId,
|
|
Is.EqualTo(new ExpandedNodeId(valueNodeId)));
|
|
|
|
// Service read of the concrete Int32 value.
|
|
DataValue dataValue = await ReadValueAsync(valueNodeId).ConfigureAwait(false);
|
|
Assert.That(dataValue.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(dataValue.WrappedValue.GetInt32(), Is.EqualTo(kGeneration1Value));
|
|
}
|
|
|
|
[Test]
|
|
public async Task AddedReferencesAreTrackedDeduplicatedAndRemovedAsync()
|
|
{
|
|
IServerInternal server = m_server.CurrentInstance;
|
|
NodeManagerRegistration registration = await m_server.NodeManagerLifecycle
|
|
.AddRuntimeNodeSetAsync(CreateOptions(generation: 1), null)
|
|
.ConfigureAwait(false);
|
|
var runtimeManager =
|
|
(RuntimeNodeSetNodeManager)registration.NodeManager;
|
|
ushort namespaceIndex = (ushort)server.NamespaceUris.GetIndex(
|
|
kModelNamespaceUri);
|
|
var rootNodeId = new NodeId(kRootNodeId, namespaceIndex);
|
|
var reference = new NodeStateReference(
|
|
ReferenceTypeIds.HasComponent,
|
|
false,
|
|
ObjectIds.Server);
|
|
var references = new Dictionary<NodeId, IList<IReference>>
|
|
{
|
|
[rootNodeId] = [reference]
|
|
};
|
|
|
|
await runtimeManager
|
|
.AddReferencesAsync(references)
|
|
.ConfigureAwait(false);
|
|
await runtimeManager
|
|
.AddReferencesAsync(references)
|
|
.ConfigureAwait(false);
|
|
|
|
Dictionary<NodeId, IList<IReference>> trackedReferences =
|
|
runtimeManager.GetAddedReferences();
|
|
Assert.That(trackedReferences.ContainsKey(rootNodeId), Is.True);
|
|
Assert.That(trackedReferences[rootNodeId], Has.Count.EqualTo(1));
|
|
Assert.That(
|
|
trackedReferences[rootNodeId][0].TargetId,
|
|
Is.EqualTo(new ExpandedNodeId(ObjectIds.Server)));
|
|
|
|
object sourceHandle = await runtimeManager
|
|
.GetManagerHandleAsync(rootNodeId)
|
|
.ConfigureAwait(false);
|
|
Assert.That(sourceHandle, Is.Not.Null);
|
|
ServiceResult deleteResult = await runtimeManager
|
|
.DeleteReferenceAsync(
|
|
sourceHandle,
|
|
ReferenceTypeIds.HasComponent,
|
|
isInverse: false,
|
|
targetId: ObjectIds.Server,
|
|
deleteBidirectional: false)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(ServiceResult.IsGood(deleteResult), Is.True);
|
|
Assert.That(runtimeManager.GetAddedReferences(), Is.Empty);
|
|
}
|
|
|
|
[Test]
|
|
public async Task PrepareReloadAsyncReturnsDroppedReferencesForRemovedNodesAsync()
|
|
{
|
|
IServerInternal server = m_server.CurrentInstance;
|
|
NodeManagerRegistration registration = await m_server.NodeManagerLifecycle
|
|
.AddRuntimeNodeSetAsync(CreateOptions(generation: 1), null)
|
|
.ConfigureAwait(false);
|
|
var originalManager =
|
|
(RuntimeNodeSetNodeManager)registration.NodeManager;
|
|
ushort namespaceIndex = (ushort)server.NamespaceUris.GetIndex(
|
|
kModelNamespaceUri);
|
|
var originalOnlyNodeId = new NodeId(
|
|
kOriginalOnlyNodeId,
|
|
namespaceIndex);
|
|
var reference = new NodeStateReference(
|
|
ReferenceTypeIds.HasComponent,
|
|
false,
|
|
ObjectIds.Server);
|
|
await originalManager
|
|
.AddReferencesAsync(
|
|
new Dictionary<NodeId, IList<IReference>>
|
|
{
|
|
[originalOnlyNodeId] = [reference]
|
|
})
|
|
.ConfigureAwait(false);
|
|
|
|
var factory = new RuntimeNodeSetNodeManagerFactory(
|
|
CreateOptions(generation: 2));
|
|
IAsyncNodeManager replacement = await factory
|
|
.CreateAsync(server, m_fixture.Config)
|
|
.ConfigureAwait(false);
|
|
var host = (IDynamicNodeManagerHost)server.NodeManager;
|
|
PreparedNodeManager prepared = await host
|
|
.PrepareAsync(replacement)
|
|
.ConfigureAwait(false);
|
|
|
|
try
|
|
{
|
|
ArrayOf<LocalReference> droppedReferences =
|
|
await originalManager
|
|
.PrepareReloadAsync(replacement)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(droppedReferences, Has.Count.EqualTo(1));
|
|
Assert.That(
|
|
droppedReferences[0].SourceId,
|
|
Is.EqualTo(ObjectIds.Server));
|
|
Assert.That(
|
|
droppedReferences[0].ReferenceTypeId,
|
|
Is.EqualTo(ReferenceTypeIds.HasComponent));
|
|
Assert.That(droppedReferences[0].IsInverse, Is.True);
|
|
Assert.That(
|
|
droppedReferences[0].TargetId,
|
|
Is.EqualTo(originalOnlyNodeId));
|
|
}
|
|
finally
|
|
{
|
|
await host.RollbackAsync(prepared).ConfigureAwait(false);
|
|
(replacement as IDisposable)?.Dispose();
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public Task RuntimeNodeSetLifecycleExtensionsRejectNullArgumentsAsync()
|
|
{
|
|
INodeManagerLifecycle lifecycle = m_server.NodeManagerLifecycle;
|
|
var options = new RuntimeNodeSetOptions();
|
|
Mock<IAsyncNodeManager> nodeManager = new();
|
|
nodeManager
|
|
.Setup(manager => manager.NamespaceUris)
|
|
.Returns(["urn:opcfoundation.org:Tests:RuntimeNodeSet:NullGuards"]);
|
|
var registration = new NodeManagerRegistration(
|
|
Guid.NewGuid(),
|
|
1,
|
|
nodeManager.Object);
|
|
|
|
ArgumentNullException exception =
|
|
Assert.ThrowsAsync<ArgumentNullException>(
|
|
async () => await RuntimeNodeSetLifecycleExtensions
|
|
.AddRuntimeNodeSetAsync(
|
|
null!,
|
|
options,
|
|
null)
|
|
.ConfigureAwait(false));
|
|
Assert.That(exception.ParamName, Is.EqualTo("lifecycle"));
|
|
|
|
exception = Assert.ThrowsAsync<ArgumentNullException>(
|
|
async () => await lifecycle
|
|
.AddRuntimeNodeSetAsync(null!, null)
|
|
.ConfigureAwait(false));
|
|
Assert.That(exception.ParamName, Is.EqualTo("options"));
|
|
|
|
exception = Assert.ThrowsAsync<ArgumentNullException>(
|
|
async () => await RuntimeNodeSetLifecycleExtensions
|
|
.ReloadRuntimeNodeSetAsync(
|
|
null!,
|
|
registration,
|
|
options,
|
|
null)
|
|
.ConfigureAwait(false));
|
|
Assert.That(exception.ParamName, Is.EqualTo("lifecycle"));
|
|
|
|
exception = Assert.ThrowsAsync<ArgumentNullException>(
|
|
async () => await lifecycle
|
|
.ReloadRuntimeNodeSetAsync(
|
|
null!,
|
|
options,
|
|
null)
|
|
.ConfigureAwait(false));
|
|
Assert.That(exception.ParamName, Is.EqualTo("registration"));
|
|
|
|
exception = Assert.ThrowsAsync<ArgumentNullException>(
|
|
async () => await lifecycle
|
|
.ReloadRuntimeNodeSetAsync(
|
|
registration,
|
|
null!,
|
|
null)
|
|
.ConfigureAwait(false));
|
|
Assert.That(exception.ParamName, Is.EqualTo("replacement"));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The existing embedded complex-types NodeSet test resource can also be added to a
|
|
/// running server and its imported nodes can be found and read through the service.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ExistingRuntimeNodeSetResourceCanBeAddedAndReadAfterStartupAsync()
|
|
{
|
|
IServerInternal server = m_server.CurrentInstance;
|
|
var options = new RuntimeNodeSetOptions
|
|
{
|
|
Sources =
|
|
[
|
|
RuntimeNodeSetSource.FromStream(
|
|
"ServerComplexTypesTestModel",
|
|
_ => new ValueTask<Stream>(RuntimeNodeSetTestServer.OpenTestStream()),
|
|
[RuntimeNodeSetTestServer.NamespaceUri])
|
|
],
|
|
DefaultNamespaceUri = RuntimeNodeSetTestServer.NamespaceUri
|
|
};
|
|
|
|
NodeManagerRegistration registration = await m_server.NodeManagerLifecycle
|
|
.AddRuntimeNodeSetAsync(options, null)
|
|
.ConfigureAwait(false);
|
|
|
|
ushort ns = (ushort)server.NamespaceUris.GetIndex(RuntimeNodeSetTestServer.NamespaceUri);
|
|
var pointValueId = new NodeId(RuntimeNodeSetTestServer.PointValueVariable, ns);
|
|
|
|
Assert.That(registration.NamespaceUris.Count, Is.EqualTo(1));
|
|
Assert.That(
|
|
registration.NamespaceUris[0],
|
|
Is.EqualTo(RuntimeNodeSetTestServer.NamespaceUri));
|
|
NodeState pointValue = (await server.NodeManager
|
|
.FindNodeInAddressSpaceAsync(pointValueId)
|
|
.ConfigureAwait(false))!;
|
|
Assert.That(pointValue, Is.Not.Null);
|
|
|
|
DataValue browseName = await ReadValueAsync(pointValueId, Attributes.BrowseName)
|
|
.ConfigureAwait(false);
|
|
Assert.That(browseName.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(
|
|
browseName.WrappedValue.GetQualifiedName(),
|
|
Is.EqualTo(new QualifiedName("PointValue", ns)));
|
|
}
|
|
|
|
[Test]
|
|
public async Task AddRuntimeNodeSetAsyncWithDuplicateNodeIdsRollsBackWithoutPublishingAsync()
|
|
{
|
|
const uint DuplicateNodeId = 9100;
|
|
IServerInternal server = m_server.CurrentInstance;
|
|
var master = (MasterNodeManager)server.NodeManager;
|
|
int managerCountBefore = master.AsyncNodeManagers.Count;
|
|
string xml = BuildDuplicateNodeSetXml(DuplicateNodeId);
|
|
|
|
InvalidOperationException exception =
|
|
Assert.ThrowsAsync<InvalidOperationException>(
|
|
async () => await m_server.NodeManagerLifecycle
|
|
.AddRuntimeNodeSetAsync(CreateRawOptions(
|
|
"DuplicateNodeIds",
|
|
kModelNamespaceUri,
|
|
xml),
|
|
null)
|
|
.ConfigureAwait(false));
|
|
|
|
Assert.That(exception.Message, Does.Contain("Duplicate NodeId"));
|
|
Assert.That(GetNonStartupRegistrations(), Is.Empty);
|
|
Assert.That(master.AsyncNodeManagers, Has.Count.EqualTo(managerCountBefore));
|
|
|
|
int namespaceIndex = server.NamespaceUris.GetIndex(kModelNamespaceUri);
|
|
Assert.That(namespaceIndex, Is.GreaterThan(0));
|
|
NodeState duplicateNode = (await server.NodeManager
|
|
.FindNodeInAddressSpaceAsync(
|
|
new NodeId(DuplicateNodeId, (ushort)namespaceIndex))
|
|
.ConfigureAwait(false))!;
|
|
Assert.That(duplicateNode, Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
public async Task AddRuntimeNodeSetAsyncWithNodeInUnownedNamespaceRollsBackWithoutPublishingAsync()
|
|
{
|
|
const string ExternalNamespaceUri =
|
|
"urn:opcfoundation.org:Tests:RuntimeNodeSetLifecycle:External";
|
|
const uint ExternalNodeId = 9200;
|
|
IServerInternal server = m_server.CurrentInstance;
|
|
var master = (MasterNodeManager)server.NodeManager;
|
|
int managerCountBefore = master.AsyncNodeManagers.Count;
|
|
string xml = BuildUnownedNamespaceNodeSetXml(
|
|
ExternalNamespaceUri,
|
|
ExternalNodeId);
|
|
|
|
InvalidOperationException exception =
|
|
Assert.ThrowsAsync<InvalidOperationException>(
|
|
async () => await m_server.NodeManagerLifecycle
|
|
.AddRuntimeNodeSetAsync(CreateRawOptions(
|
|
"UnownedNamespace",
|
|
kModelNamespaceUri,
|
|
xml),
|
|
null)
|
|
.ConfigureAwait(false));
|
|
|
|
Assert.That(exception.Message, Does.Contain("not owned"));
|
|
Assert.That(GetNonStartupRegistrations(), Is.Empty);
|
|
Assert.That(master.AsyncNodeManagers, Has.Count.EqualTo(managerCountBefore));
|
|
|
|
int externalNamespaceIndex =
|
|
server.NamespaceUris.GetIndex(ExternalNamespaceUri);
|
|
Assert.That(externalNamespaceIndex, Is.GreaterThan(0));
|
|
NodeState externalNode = (await server.NodeManager
|
|
.FindNodeInAddressSpaceAsync(
|
|
new NodeId(ExternalNodeId, (ushort)externalNamespaceIndex))
|
|
.ConfigureAwait(false))!;
|
|
Assert.That(externalNode, Is.Null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// When complex-type factory loading is disabled, reload compatibility must fall
|
|
/// back to the published address-space definition and reject an incompatible
|
|
/// replacement without disturbing the live generation.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReloadRuntimeNodeSetAsyncRejectsIncompatibleDataTypeWhenComplexTypeLoadingDisabledAsync()
|
|
{
|
|
m_server.LoadComplexTypes = false;
|
|
|
|
IServerInternal server = m_server.CurrentInstance;
|
|
var master = (MasterNodeManager)server.NodeManager;
|
|
NodeManagerRegistration original = await m_server.NodeManagerLifecycle
|
|
.AddRuntimeNodeSetAsync(CreateComplexTypeOptions(incompatibleDefinition: false), null)
|
|
.ConfigureAwait(false);
|
|
Guid originalId = original.Id;
|
|
long originalGeneration = original.Generation;
|
|
IAsyncNodeManager originalNodeManager = original.NodeManager;
|
|
|
|
int namespaceIndex = server.NamespaceUris.GetIndex(
|
|
RuntimeNodeSetTestServer.NamespaceUri);
|
|
Assert.That(namespaceIndex, Is.GreaterThan(0));
|
|
ushort ns = (ushort)namespaceIndex;
|
|
var testPointId = new NodeId(RuntimeNodeSetTestServer.TestPointDataType, ns);
|
|
var binaryEncodingId = new NodeId(
|
|
RuntimeNodeSetTestServer.TestPointBinaryEncoding,
|
|
ns);
|
|
var pointValueId = new NodeId(RuntimeNodeSetTestServer.PointValueVariable, ns);
|
|
var expandedTestPointId = NodeId.ToExpandedNodeId(
|
|
testPointId,
|
|
server.NamespaceUris);
|
|
|
|
Assert.That(
|
|
server.Factory.TryGetEncodeableType(expandedTestPointId, out _),
|
|
Is.False,
|
|
"The compatibility check must use the published address-space definition.");
|
|
|
|
string expectedMessage =
|
|
$"DataType '{testPointId}' has an incompatible definition. " +
|
|
"Runtime DataType definitions are immutable for the server lifetime.";
|
|
// TestPoint3D inherits the TestPoint fields (Part 3 8.48), so its
|
|
// definition changes with them and may be the one reported first.
|
|
string expectedSubtypeMessage =
|
|
$"DataType '{new NodeId(15030, ns)}' has an incompatible definition. " +
|
|
"Runtime DataType definitions are immutable for the server lifetime.";
|
|
await Assert.ThatAsync(
|
|
() => m_server.NodeManagerLifecycle
|
|
.ReloadRuntimeNodeSetAsync(
|
|
original,
|
|
CreateComplexTypeOptions(incompatibleDefinition: true),
|
|
null)
|
|
.AsTask(),
|
|
Throws.TypeOf<InvalidOperationException>()
|
|
.With.Message.EqualTo(expectedMessage)
|
|
.Or.Message.EqualTo(expectedSubtypeMessage)).ConfigureAwait(false);
|
|
|
|
ArrayOf<NodeManagerRegistration> registrations =
|
|
GetNonStartupRegistrations();
|
|
Assert.That(registrations, Has.Count.EqualTo(1));
|
|
NodeManagerRegistration current = registrations[0];
|
|
Assert.That(current, Is.SameAs(original));
|
|
Assert.That(current.Id, Is.EqualTo(originalId));
|
|
Assert.That(current.Generation, Is.EqualTo(originalGeneration));
|
|
Assert.That(current.NodeManager, Is.SameAs(originalNodeManager));
|
|
|
|
IReadOnlyList<IAsyncNodeManager> managers = master.AsyncNodeManagers;
|
|
Assert.That(
|
|
managers.Count(manager => ReferenceEquals(manager, originalNodeManager)),
|
|
Is.EqualTo(1));
|
|
IReadOnlyDictionary<int, IReadOnlyList<IAsyncNodeManager>> routes =
|
|
master.NamespaceManagers;
|
|
Assert.That(routes.ContainsKey(namespaceIndex), Is.True);
|
|
Assert.That(routes[namespaceIndex], Has.Count.EqualTo(1));
|
|
Assert.That(routes[namespaceIndex][0], Is.SameAs(originalNodeManager));
|
|
|
|
NodeState testPoint = (await server.NodeManager
|
|
.FindNodeInAddressSpaceAsync(testPointId)
|
|
.ConfigureAwait(false))!;
|
|
NodeState binaryEncoding = (await server.NodeManager
|
|
.FindNodeInAddressSpaceAsync(binaryEncodingId)
|
|
.ConfigureAwait(false))!;
|
|
NodeState pointValue = (await server.NodeManager
|
|
.FindNodeInAddressSpaceAsync(pointValueId)
|
|
.ConfigureAwait(false))!;
|
|
Assert.That(testPoint, Is.Not.Null);
|
|
Assert.That(binaryEncoding, Is.Not.Null);
|
|
Assert.That(pointValue, Is.Not.Null);
|
|
|
|
DataValue testPointBrowseName = await ReadValueAsync(
|
|
testPointId,
|
|
Attributes.BrowseName).ConfigureAwait(false);
|
|
Assert.That(testPointBrowseName.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(
|
|
testPointBrowseName.WrappedValue.GetQualifiedName(),
|
|
Is.EqualTo(new QualifiedName("TestPoint", ns)));
|
|
|
|
DataValue pointValueBrowseName = await ReadValueAsync(
|
|
pointValueId,
|
|
Attributes.BrowseName).ConfigureAwait(false);
|
|
Assert.That(pointValueBrowseName.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(
|
|
pointValueBrowseName.WrappedValue.GetQualifiedName(),
|
|
Is.EqualTo(new QualifiedName("PointValue", ns)));
|
|
|
|
Assert.That(
|
|
server.Factory.TryGetEncodeableType(expandedTestPointId, out _),
|
|
Is.False);
|
|
}
|
|
|
|
[TestCase(false, false)]
|
|
[TestCase(false, true)]
|
|
[TestCase(true, false)]
|
|
[TestCase(true, true)]
|
|
public async Task ReloadRuntimeDefinitionChecksCompatibilityWithAndWithoutCodecsAsync(
|
|
bool loadComplexTypes,
|
|
bool incompatibleDefinition)
|
|
{
|
|
m_server.LoadComplexTypes = loadComplexTypes;
|
|
NodeManagerRegistration original = await m_server.NodeManagerLifecycle
|
|
.AddRuntimeNodeSetAsync(CreateComplexTypeOptions(incompatibleDefinition: false), null)
|
|
.ConfigureAwait(false);
|
|
|
|
if (incompatibleDefinition)
|
|
{
|
|
await Assert.ThatAsync(() => m_server.NodeManagerLifecycle.ReloadRuntimeNodeSetAsync(
|
|
original, CreateComplexTypeOptions(incompatibleDefinition: true), null).AsTask(),
|
|
Throws.TypeOf<InvalidOperationException>()).ConfigureAwait(false);
|
|
Assert.That(GetNonStartupRegistrations(), Has.Count.EqualTo(1));
|
|
Assert.That(GetNonStartupRegistrations()[0], Is.SameAs(original));
|
|
}
|
|
else
|
|
{
|
|
NodeManagerRegistration reloaded = await m_server.NodeManagerLifecycle.ReloadRuntimeNodeSetAsync(
|
|
original, CreateComplexTypeOptions(incompatibleDefinition: false), null).ConfigureAwait(false);
|
|
Assert.That(reloaded.Id, Is.EqualTo(original.Id));
|
|
Assert.That(reloaded.Generation, Is.EqualTo(original.Generation + 1));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reloading a compatible complex-type NodeSet must rebuild the active generation's
|
|
/// subtype and encoding indexes after the retired generation removes its types.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReloadRuntimeNodeSetAsyncRestoresTypeTreeAfterOldGenerationRetiresAsync()
|
|
{
|
|
IServerInternal server = m_server.CurrentInstance;
|
|
var master = (MasterNodeManager)server.NodeManager;
|
|
NodeManagerRegistration original = await m_server.NodeManagerLifecycle
|
|
.AddRuntimeNodeSetAsync(CreateComplexTypeOptions(incompatibleDefinition: false), null)
|
|
.ConfigureAwait(false);
|
|
|
|
int namespaceIndex = server.NamespaceUris.GetIndex(
|
|
RuntimeNodeSetTestServer.NamespaceUri);
|
|
Assert.That(namespaceIndex, Is.GreaterThan(0));
|
|
ushort ns = (ushort)namespaceIndex;
|
|
var testPointId = new NodeId(RuntimeNodeSetTestServer.TestPointDataType, ns);
|
|
var binaryEncodingId = new NodeId(
|
|
RuntimeNodeSetTestServer.TestPointBinaryEncoding,
|
|
ns);
|
|
var expandedTestPointId = NodeId.ToExpandedNodeId(
|
|
testPointId,
|
|
server.NamespaceUris);
|
|
var expandedBinaryEncodingId = NodeId.ToExpandedNodeId(
|
|
binaryEncodingId,
|
|
server.NamespaceUris);
|
|
IAsyncNodeManager originalNodeManager = original.NodeManager;
|
|
NodeState originalTestPoint = (await server.NodeManager
|
|
.FindNodeInAddressSpaceAsync(testPointId)
|
|
.ConfigureAwait(false))!;
|
|
Assert.That(originalTestPoint, Is.InstanceOf<DataTypeState>());
|
|
Assert.That(
|
|
server.TypeTree.FindSuperType(testPointId),
|
|
Is.EqualTo(DataTypeIds.Structure));
|
|
Assert.That(
|
|
server.TypeTree.FindSubTypes(DataTypeIds.Structure).ToList(),
|
|
Does.Contain(testPointId));
|
|
Assert.That(server.TypeTree.IsEncodingOf(binaryEncodingId, testPointId), Is.True);
|
|
Assert.That(
|
|
server.TypeTree.FindDataTypeId(binaryEncodingId),
|
|
Is.EqualTo(testPointId));
|
|
Assert.That(
|
|
server.Factory.TryGetEncodeableType(expandedTestPointId, out _),
|
|
Is.True);
|
|
Assert.That(
|
|
server.Factory.TryGetEncodeableType(expandedBinaryEncodingId, out _),
|
|
Is.True);
|
|
|
|
NodeManagerRegistration reloaded = await m_server.NodeManagerLifecycle
|
|
.ReloadRuntimeNodeSetAsync(
|
|
original,
|
|
CreateComplexTypeOptions(incompatibleDefinition: false),
|
|
null)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(reloaded.Id, Is.EqualTo(original.Id));
|
|
Assert.That(reloaded.Generation, Is.EqualTo(original.Generation + 1));
|
|
Assert.That(reloaded.NodeManager, Is.Not.SameAs(originalNodeManager));
|
|
|
|
IReadOnlyList<IAsyncNodeManager> managers = master.AsyncNodeManagers;
|
|
Assert.That(
|
|
managers.Count(manager => ReferenceEquals(manager, originalNodeManager)),
|
|
Is.Zero);
|
|
Assert.That(
|
|
managers.Count(manager => ReferenceEquals(manager, reloaded.NodeManager)),
|
|
Is.EqualTo(1));
|
|
|
|
IReadOnlyDictionary<int, IReadOnlyList<IAsyncNodeManager>> routes =
|
|
master.NamespaceManagers;
|
|
Assert.That(routes.ContainsKey(namespaceIndex), Is.True);
|
|
Assert.That(routes[namespaceIndex], Has.Count.EqualTo(1));
|
|
Assert.That(routes[namespaceIndex][0], Is.SameAs(reloaded.NodeManager));
|
|
|
|
NodeState reloadedTestPoint = (await server.NodeManager
|
|
.FindNodeInAddressSpaceAsync(testPointId)
|
|
.ConfigureAwait(false))!;
|
|
Assert.That(reloadedTestPoint, Is.InstanceOf<DataTypeState>());
|
|
Assert.That(reloadedTestPoint, Is.Not.SameAs(originalTestPoint));
|
|
|
|
Assert.That(
|
|
server.TypeTree.FindSuperType(testPointId),
|
|
Is.EqualTo(DataTypeIds.Structure));
|
|
Assert.That(
|
|
server.TypeTree.FindSubTypes(DataTypeIds.Structure).ToList(),
|
|
Does.Contain(testPointId));
|
|
Assert.That(server.TypeTree.IsEncodingOf(binaryEncodingId, testPointId), Is.True);
|
|
Assert.That(
|
|
server.TypeTree.FindDataTypeId(binaryEncodingId),
|
|
Is.EqualTo(testPointId));
|
|
|
|
Assert.That(
|
|
server.Factory.TryGetEncodeableType(expandedTestPointId, out _),
|
|
Is.True);
|
|
Assert.That(
|
|
server.Factory.TryGetEncodeableType(expandedBinaryEncodingId, out _),
|
|
Is.True);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reloading a live registration must replace the generation and the NodeManager
|
|
/// instance while preserving the registration <c>Id</c> and the model namespace index;
|
|
/// only the replacement's nodes must be reachable afterwards.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ReloadRuntimeNodeSetAsyncReplacesGenerationAndPreservesNamespaceIndexAsync()
|
|
{
|
|
IServerInternal server = m_server.CurrentInstance;
|
|
var master = (MasterNodeManager)server.NodeManager;
|
|
|
|
NodeManagerRegistration original = await m_server.NodeManagerLifecycle
|
|
.AddRuntimeNodeSetAsync(CreateOptions(generation: 1), null)
|
|
.ConfigureAwait(false);
|
|
|
|
int namespaceIndexBefore = server.NamespaceUris.GetIndex(kModelNamespaceUri);
|
|
int namespaceCountBefore = server.NamespaceUris.Count;
|
|
uint urisVersionBefore = await ReadUrisVersionAsync().ConfigureAwait(false);
|
|
string[] namespaceArrayBefore = await ReadNamespaceArrayAsync().ConfigureAwait(false);
|
|
|
|
NodeManagerRegistration reloaded = await m_server.NodeManagerLifecycle
|
|
.ReloadRuntimeNodeSetAsync(original, CreateOptions(generation: 2), null)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(reloaded.Id, Is.EqualTo(original.Id));
|
|
Assert.That(reloaded.Generation, Is.EqualTo(original.Generation + 1));
|
|
Assert.That(reloaded.Generation, Is.EqualTo(2));
|
|
Assert.That(reloaded.NodeManager, Is.InstanceOf<RuntimeNodeSetNodeManager>());
|
|
Assert.That(ReferenceEquals(reloaded.NodeManager, original.NodeManager), Is.False);
|
|
|
|
IReadOnlyList<IAsyncNodeManager> managersAfter = master.AsyncNodeManagers;
|
|
IReadOnlyDictionary<int, IReadOnlyList<IAsyncNodeManager>> routesAfter =
|
|
master.NamespaceManagers;
|
|
|
|
Assert.That(
|
|
managersAfter.Count(m => ReferenceEquals(m, original.NodeManager)),
|
|
Is.Zero,
|
|
"The original generation's NodeManager must no longer be registered.");
|
|
Assert.That(
|
|
managersAfter.Count(m => ReferenceEquals(m, reloaded.NodeManager)),
|
|
Is.EqualTo(1));
|
|
|
|
Assert.That(routesAfter.ContainsKey(namespaceIndexBefore), Is.True);
|
|
Assert.That(routesAfter[namespaceIndexBefore], Has.Count.EqualTo(1));
|
|
Assert.That(
|
|
ReferenceEquals(routesAfter[namespaceIndexBefore][0], reloaded.NodeManager),
|
|
Is.True);
|
|
|
|
// Same-URI reload must not change the namespace index, count, or UrisVersion.
|
|
int namespaceIndexAfter = server.NamespaceUris.GetIndex(kModelNamespaceUri);
|
|
Assert.That(namespaceIndexAfter, Is.EqualTo(namespaceIndexBefore));
|
|
Assert.That(server.NamespaceUris.Count, Is.EqualTo(namespaceCountBefore));
|
|
uint urisVersionAfter = await ReadUrisVersionAsync().ConfigureAwait(false);
|
|
Assert.That(urisVersionAfter, Is.EqualTo(urisVersionBefore));
|
|
string[] namespaceArrayAfter = await ReadNamespaceArrayAsync().ConfigureAwait(false);
|
|
Assert.That(namespaceArrayAfter, Is.EqualTo(namespaceArrayBefore));
|
|
|
|
ushort ns = (ushort)namespaceIndexAfter;
|
|
var valueNodeId = new NodeId(kValueNodeId, ns);
|
|
var replacementOnlyNodeId = new NodeId(kReplacementOnlyNodeId, ns);
|
|
var originalOnlyNodeId = new NodeId(kOriginalOnlyNodeId, ns);
|
|
|
|
DataValue valueAfterReload = await ReadValueAsync(valueNodeId).ConfigureAwait(false);
|
|
Assert.That(valueAfterReload.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(valueAfterReload.WrappedValue.GetInt32(), Is.EqualTo(kGeneration2Value));
|
|
|
|
NodeState replacementNode = (await server.NodeManager
|
|
.FindNodeInAddressSpaceAsync(replacementOnlyNodeId)
|
|
.ConfigureAwait(false))!;
|
|
Assert.That(replacementNode, Is.Not.Null);
|
|
Assert.That(replacementNode.BrowseName.Name, Is.EqualTo(kReplacementOnlyBrowseName));
|
|
DataValue replacementValue = await ReadValueAsync(replacementOnlyNodeId).ConfigureAwait(false);
|
|
Assert.That(replacementValue.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(replacementValue.WrappedValue.GetInt32(), Is.EqualTo(kReplacementOnlyValue));
|
|
|
|
NodeState originalOnlyNode = (await server.NodeManager
|
|
.FindNodeInAddressSpaceAsync(originalOnlyNodeId)
|
|
.ConfigureAwait(false))!;
|
|
Assert.That(originalOnlyNode, Is.Null);
|
|
DataValue originalOnlyValue = await ReadValueAsync(originalOnlyNodeId).ConfigureAwait(false);
|
|
Assert.That(originalOnlyValue.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdUnknown));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unlike Reload, ShadowReload must succeed while the current generation owns an
|
|
/// active reporting monitored item. New Read and Browse requests must be
|
|
/// atomically routed to the replacement generation as soon as the switch is
|
|
/// committed, while the monitored item created before the switch keeps being
|
|
/// serviced by the retired (but not yet destroyed) current generation, including
|
|
/// for a value pushed directly on that retired generation's own node after the
|
|
/// switch. Once the owning subscription is deleted, a later lifecycle operation
|
|
/// completes retired-generation cleanup and disposes the old generation's address
|
|
/// space.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ShadowReloadRuntimeNodeSetAsyncRoutesNewRequestsToReplacementWhileOldMonitoredItemStaysActiveAsync()
|
|
{
|
|
IServerInternal server = m_server.CurrentInstance;
|
|
var master = (MasterNodeManager)server.NodeManager;
|
|
|
|
NodeManagerRegistration original = await m_server.NodeManagerLifecycle
|
|
.AddRuntimeNodeSetAsync(CreateOptions(generation: 1), null)
|
|
.ConfigureAwait(false);
|
|
|
|
ushort ns = (ushort)server.NamespaceUris.GetIndex(kModelNamespaceUri);
|
|
var rootNodeId = new NodeId(kRootNodeId, ns);
|
|
var valueNodeId = new NodeId(kValueNodeId, ns);
|
|
var replacementOnlyNodeId = new NodeId(kReplacementOnlyNodeId, ns);
|
|
var originalManager = (AsyncCustomNodeManager)original.NodeManager;
|
|
const uint clientHandle = 1;
|
|
|
|
var services = new ServerTestServices(m_server, m_secureChannelContext);
|
|
uint subscriptionId = await CreateSubscriptionWithMonitoredItemAsync(services, valueNodeId)
|
|
.ConfigureAwait(false);
|
|
|
|
// Drain the initial data-change sample delivered on monitored-item creation so
|
|
// the later publish loop only observes the value pushed after the switch.
|
|
ArrayOf<SubscriptionAcknowledgement> acknowledgements = default;
|
|
(_, acknowledgements) = await PublishForDataChangeAsync(
|
|
services,
|
|
subscriptionId,
|
|
acknowledgements,
|
|
clientHandle).ConfigureAwait(false);
|
|
|
|
try
|
|
{
|
|
NodeManagerRegistration reloaded = await m_server.NodeManagerLifecycle
|
|
.ShadowReloadRuntimeNodeSetAsync(original, CreateOptions(generation: 2))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(reloaded.Id, Is.EqualTo(original.Id));
|
|
Assert.That(reloaded.Generation, Is.EqualTo(original.Generation + 1));
|
|
Assert.That(ReferenceEquals(reloaded.NodeManager, original.NodeManager), Is.False);
|
|
|
|
// New requests must be atomically routed to the replacement generation.
|
|
Assert.That(
|
|
master.NamespaceManagers[ns].Count(m => ReferenceEquals(m, reloaded.NodeManager)),
|
|
Is.EqualTo(1));
|
|
Assert.That(
|
|
master.NamespaceManagers[ns].Any(m => ReferenceEquals(m, original.NodeManager)),
|
|
Is.False);
|
|
|
|
DataValue valueAfterSwitch = await ReadValueAsync(valueNodeId).ConfigureAwait(false);
|
|
Assert.That(valueAfterSwitch.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(valueAfterSwitch.WrappedValue.GetInt32(), Is.EqualTo(kGeneration2Value));
|
|
|
|
BrowseResponse rootBrowse = await BrowseAsync(rootNodeId).ConfigureAwait(false);
|
|
Assert.That(rootBrowse.Results.Count, Is.EqualTo(1));
|
|
ArrayOf<ReferenceDescription> rootReferences = rootBrowse.Results[0].References;
|
|
Assert.That(
|
|
rootReferences.Contains(
|
|
r => r.BrowseName.Equals(new QualifiedName(kReplacementOnlyBrowseName, ns))),
|
|
Is.True);
|
|
Assert.That(
|
|
rootReferences.Contains(
|
|
r => r.BrowseName.Equals(new QualifiedName(kOriginalOnlyBrowseName, ns))),
|
|
Is.False);
|
|
NodeState replacementNode = (await server.NodeManager
|
|
.FindNodeInAddressSpaceAsync(replacementOnlyNodeId)
|
|
.ConfigureAwait(false))!;
|
|
Assert.That(replacementNode, Is.Not.Null);
|
|
|
|
// The retired generation's own node must still be present and unaffected.
|
|
var originalValueState = (BaseVariableState)originalManager.Find(valueNodeId)!;
|
|
Assert.That(originalValueState, Is.Not.Null);
|
|
Assert.That(originalValueState.Value, Is.EqualTo(kGeneration1Value));
|
|
|
|
// Simulate an internal (device-driven) value push directly on the retired
|
|
// generation's own node: it must still reach the existing monitored item.
|
|
const int pushedValue = 888;
|
|
originalValueState.Value = pushedValue;
|
|
originalValueState.Timestamp = DateTimeUtc.Now;
|
|
originalValueState.StatusCode = StatusCodes.Good;
|
|
originalValueState.UpdateChangeMasks(NodeStateChangeMasks.Value);
|
|
await originalValueState
|
|
.ClearChangeMasksAsync(server.DefaultSystemContext, includeChildren: false)
|
|
.ConfigureAwait(false);
|
|
|
|
DataValue? pushedNotification;
|
|
(pushedNotification, acknowledgements) = await PublishForDataChangeAsync(
|
|
services,
|
|
subscriptionId,
|
|
acknowledgements,
|
|
clientHandle).ConfigureAwait(false);
|
|
Assert.That(pushedNotification, Is.Not.Null);
|
|
Assert.That(pushedNotification!.Value.WrappedValue.GetInt32(), Is.EqualTo(pushedValue));
|
|
|
|
// The replacement generation's own value must remain unaffected by the
|
|
// push made directly on the retired generation.
|
|
DataValue valueAfterPush = await ReadValueAsync(valueNodeId).ConfigureAwait(false);
|
|
Assert.That(valueAfterPush.WrappedValue.GetInt32(), Is.EqualTo(kGeneration2Value));
|
|
}
|
|
finally
|
|
{
|
|
await DeleteSubscriptionAsync(services, subscriptionId).ConfigureAwait(false);
|
|
}
|
|
|
|
// With the owning subscription gone, prompt background cleanup or the later
|
|
// lifecycle operation finishes retired-generation cleanup. If the background
|
|
// pass already claimed the generation, Remove need not duplicate that work.
|
|
NodeManagerRegistration current = m_server.NodeManagerLifecycle.Registrations
|
|
.Find(r => r.Id == original.Id);
|
|
Assert.That(current, Is.Not.Null);
|
|
await m_server.NodeManagerLifecycle.RemoveAsync(current, null).ConfigureAwait(false);
|
|
|
|
for (int attempt = 0;
|
|
attempt < 400 && originalManager.Find(valueNodeId) is not null;
|
|
attempt++)
|
|
{
|
|
await Task.Delay(TimeSpan.FromMilliseconds(25)).ConfigureAwait(false);
|
|
}
|
|
Assert.That(originalManager.Find(valueNodeId), Is.Null);
|
|
Assert.That(
|
|
CountMatches(m_server.NodeManagerLifecycle.Registrations, r => r.Id == original.Id),
|
|
Is.Zero);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removing a live registration must unroute its NodeManager and unregister it,
|
|
/// leaving its nodes unreachable through direct lookup, browse, read, and translate,
|
|
/// while the model namespace URI stays permanently in the NamespaceArray.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task RemoveAsyncUnroutesNodesAndRetainsNamespaceUriAsync()
|
|
{
|
|
IServerInternal server = m_server.CurrentInstance;
|
|
var master = (MasterNodeManager)server.NodeManager;
|
|
|
|
NodeManagerRegistration registration = await m_server.NodeManagerLifecycle
|
|
.AddRuntimeNodeSetAsync(CreateOptions(generation: 1), null)
|
|
.ConfigureAwait(false);
|
|
|
|
int namespaceIndex = server.NamespaceUris.GetIndex(kModelNamespaceUri);
|
|
int namespaceCountAfterAdd = server.NamespaceUris.Count;
|
|
uint urisVersionAfterAdd = await ReadUrisVersionAsync().ConfigureAwait(false);
|
|
string[] namespaceArrayAfterAdd = await ReadNamespaceArrayAsync().ConfigureAwait(false);
|
|
|
|
ushort ns = (ushort)namespaceIndex;
|
|
var rootNodeId = new NodeId(kRootNodeId, ns);
|
|
var valueNodeId = new NodeId(kValueNodeId, ns);
|
|
|
|
await m_server.NodeManagerLifecycle.RemoveAsync(registration, null).ConfigureAwait(false);
|
|
|
|
// Registration and routing must be absent.
|
|
ArrayOf<NodeManagerRegistration> registrations = m_server.NodeManagerLifecycle.Registrations;
|
|
Assert.That(CountMatches(registrations, r => r.Id == registration.Id), Is.Zero);
|
|
|
|
IReadOnlyList<IAsyncNodeManager> managersAfterRemove = master.AsyncNodeManagers;
|
|
IReadOnlyDictionary<int, IReadOnlyList<IAsyncNodeManager>> routesAfterRemove =
|
|
master.NamespaceManagers;
|
|
Assert.That(
|
|
managersAfterRemove.Count(m => ReferenceEquals(m, registration.NodeManager)),
|
|
Is.Zero);
|
|
Assert.That(routesAfterRemove.ContainsKey(namespaceIndex), Is.False);
|
|
|
|
// Direct lookup must no longer find the removed nodes.
|
|
NodeState rootNode = (await server.NodeManager
|
|
.FindNodeInAddressSpaceAsync(rootNodeId)
|
|
.ConfigureAwait(false))!;
|
|
NodeState valueNode = (await server.NodeManager
|
|
.FindNodeInAddressSpaceAsync(valueNodeId)
|
|
.ConfigureAwait(false))!;
|
|
Assert.That(rootNode, Is.Null);
|
|
Assert.That(valueNode, Is.Null);
|
|
|
|
// Browse of the removed root NodeId itself must fail with BadNodeIdUnknown.
|
|
BrowseResponse rootBrowse = await BrowseAsync(rootNodeId).ConfigureAwait(false);
|
|
Assert.That(rootBrowse.Results.Count, Is.EqualTo(1));
|
|
Assert.That(rootBrowse.Results[0].StatusCode, Is.EqualTo(StatusCodes.BadNodeIdUnknown));
|
|
|
|
// The root must no longer be reachable from the Objects folder.
|
|
BrowseResponse objectsBrowse = await BrowseAsync(ObjectIds.ObjectsFolder).ConfigureAwait(false);
|
|
Assert.That(objectsBrowse.Results.Count, Is.EqualTo(1));
|
|
ArrayOf<ReferenceDescription> objectsReferences = objectsBrowse.Results[0].References;
|
|
Assert.That(
|
|
objectsReferences.Contains(r => r.BrowseName.Equals(new QualifiedName(kRootBrowseName, ns))),
|
|
Is.False);
|
|
|
|
// Read must fail with BadNodeIdUnknown.
|
|
DataValue valueAfterRemove = await ReadValueAsync(valueNodeId).ConfigureAwait(false);
|
|
Assert.That(valueAfterRemove.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdUnknown));
|
|
|
|
// Translate must no longer resolve the browse path.
|
|
var services = new ServerTestServices(m_server, m_secureChannelContext);
|
|
var relativePath = new RelativePath
|
|
{
|
|
Elements =
|
|
[
|
|
new RelativePathElement
|
|
{
|
|
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
|
IncludeSubtypes = true,
|
|
TargetName = new QualifiedName(kRootBrowseName, ns)
|
|
},
|
|
new RelativePathElement
|
|
{
|
|
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
|
IncludeSubtypes = true,
|
|
TargetName = new QualifiedName(kValueBrowseName, ns)
|
|
}
|
|
]
|
|
};
|
|
ArrayOf<BrowsePath> browsePaths =
|
|
[
|
|
new BrowsePath { StartingNode = ObjectIds.ObjectsFolder, RelativePath = relativePath }
|
|
];
|
|
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
TranslateBrowsePathsToNodeIdsResponse translateResponse = await services
|
|
.TranslateBrowsePathsToNodeIdsAsync(requestHeader, browsePaths)
|
|
.ConfigureAwait(false);
|
|
ServerFixtureUtils.ValidateResponse(
|
|
translateResponse.ResponseHeader,
|
|
translateResponse.Results,
|
|
browsePaths);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
translateResponse.DiagnosticInfos,
|
|
browsePaths,
|
|
translateResponse.ResponseHeader.StringTable,
|
|
m_logger);
|
|
Assert.That(translateResponse.Results.Count, Is.EqualTo(1));
|
|
Assert.That(StatusCode.IsBad(translateResponse.Results[0].StatusCode), Is.True);
|
|
|
|
// The model namespace URI must remain permanently in the NamespaceArray, and
|
|
// UrisVersion must remain at its post-add value: removal never rewrites the
|
|
// namespace table.
|
|
Assert.That(server.NamespaceUris.GetIndex(kModelNamespaceUri), Is.EqualTo(namespaceIndex));
|
|
Assert.That(server.NamespaceUris.Count, Is.EqualTo(namespaceCountAfterAdd));
|
|
uint urisVersionAfterRemove = await ReadUrisVersionAsync().ConfigureAwait(false);
|
|
Assert.That(urisVersionAfterRemove, Is.EqualTo(urisVersionAfterAdd));
|
|
string[] namespaceArrayAfterRemove = await ReadNamespaceArrayAsync().ConfigureAwait(false);
|
|
Assert.That(namespaceArrayAfterRemove, Is.EqualTo(namespaceArrayAfterAdd));
|
|
Assert.That(namespaceArrayAfterRemove, Does.Contain(kModelNamespaceUri));
|
|
}
|
|
|
|
private static RuntimeNodeSetOptions CreateRawOptions(
|
|
string sourceName,
|
|
string modelNamespaceUri,
|
|
string xml)
|
|
{
|
|
return new RuntimeNodeSetOptions
|
|
{
|
|
Sources =
|
|
[
|
|
RuntimeNodeSetSource.FromStream(
|
|
sourceName,
|
|
_ => new ValueTask<Stream>(
|
|
new MemoryStream(Encoding.UTF8.GetBytes(xml))),
|
|
[modelNamespaceUri])
|
|
],
|
|
DefaultNamespaceUri = modelNamespaceUri
|
|
};
|
|
}
|
|
|
|
private static string BuildDuplicateNodeSetXml(uint duplicateNodeId)
|
|
{
|
|
return $"""
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<UANodeSet xmlns="http://opcfoundation.org/UA/2011/03/UANodeSet.xsd">
|
|
<NamespaceUris>
|
|
<Uri>{kModelNamespaceUri}</Uri>
|
|
</NamespaceUris>
|
|
<Models>
|
|
<Model ModelUri="{kModelNamespaceUri}" />
|
|
</Models>
|
|
<UAObject NodeId="ns=1;i={duplicateNodeId}" BrowseName="1:DuplicateOne">
|
|
<DisplayName>DuplicateOne</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="i=40">i=58</Reference>
|
|
<Reference ReferenceType="i=35" IsForward="false">i=85</Reference>
|
|
</References>
|
|
</UAObject>
|
|
<UAObject NodeId="ns=1;i={duplicateNodeId}" BrowseName="1:DuplicateTwo">
|
|
<DisplayName>DuplicateTwo</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="i=40">i=58</Reference>
|
|
<Reference ReferenceType="i=35" IsForward="false">i=85</Reference>
|
|
</References>
|
|
</UAObject>
|
|
</UANodeSet>
|
|
""";
|
|
}
|
|
|
|
private static string BuildUnownedNamespaceNodeSetXml(
|
|
string externalNamespaceUri,
|
|
uint externalNodeId)
|
|
{
|
|
return $"""
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<UANodeSet xmlns="http://opcfoundation.org/UA/2011/03/UANodeSet.xsd">
|
|
<NamespaceUris>
|
|
<Uri>{kModelNamespaceUri}</Uri>
|
|
<Uri>{externalNamespaceUri}</Uri>
|
|
</NamespaceUris>
|
|
<Models>
|
|
<Model ModelUri="{kModelNamespaceUri}" />
|
|
</Models>
|
|
<UAObject NodeId="ns=2;i={externalNodeId}" BrowseName="2:ExternalNode">
|
|
<DisplayName>ExternalNode</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="i=40">i=58</Reference>
|
|
<Reference ReferenceType="i=35" IsForward="false">i=85</Reference>
|
|
</References>
|
|
</UAObject>
|
|
</UANodeSet>
|
|
""";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the <see cref="RuntimeNodeSetOptions"/> for a single in-memory source of
|
|
/// the given generation.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Independently confirmed production defect:</b> a standard single-level
|
|
/// NodeSet2 <c><Value><uax:Int32>N</uax:Int32></Value></c>
|
|
/// payload is imported as the CLR default (<c>0</c>) instead of <c>N</c> by
|
|
/// <c>UANodeSetHelpers.Import</c> / the underlying <c>XmlDecoder</c> path. The XML
|
|
/// below therefore carries no <c><Value></c> element for its scalar
|
|
/// variables (leaving one in place would misleadingly imply it is honored), and the
|
|
/// <see cref="RuntimeNodeSetOptions.Configure"/> callback seeds the concrete
|
|
/// generation-specific <c>Int32</c> values through the supported post-import fluent
|
|
/// <see cref="Server.Fluent.INodeManagerBuilder.Variable{TValue}(string)"/>
|
|
/// hook instead. This keeps the Read-service assertions proving real, concrete,
|
|
/// generation-specific values (1 then 2) end-to-end without weakening them, while
|
|
/// routing around the defect at the test-authoring level. The defect itself is a
|
|
/// pre-existing production issue and should be reported/fixed separately.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static RuntimeNodeSetOptions CreateOptions(int generation)
|
|
{
|
|
string xml = BuildNodeSetXml(generation);
|
|
|
|
(string childBrowseName, int childValue) = generation == 1
|
|
? (kOriginalOnlyBrowseName, kOriginalOnlyValue)
|
|
: (kReplacementOnlyBrowseName, kReplacementOnlyValue);
|
|
int lifecycleValue = generation == 1 ? kGeneration1Value : kGeneration2Value;
|
|
|
|
return new RuntimeNodeSetOptions
|
|
{
|
|
Sources =
|
|
[
|
|
RuntimeNodeSetSource.FromStream(
|
|
$"RuntimeNodeSetLifecycleTests-gen{generation}",
|
|
_ => new ValueTask<Stream>(new MemoryStream(Encoding.UTF8.GetBytes(xml))),
|
|
[kModelNamespaceUri])
|
|
],
|
|
Configure = builder =>
|
|
{
|
|
// See the defect note above: seed the concrete, generation-specific
|
|
// Int32 values here rather than relying on the NodeSet2 <Value> import,
|
|
// which is defective for this scalar shape. Assigning through the
|
|
// resolved node's Value property (rather than an OnRead override) also
|
|
// establishes the Good status code a freshly imported, value-less
|
|
// variable otherwise lacks (BadWaitingForInitialData).
|
|
builder.Variable<int>($"{kRootBrowseName}/{kValueBrowseName}").Node.Value =
|
|
lifecycleValue;
|
|
builder.Variable<int>($"{kRootBrowseName}/{childBrowseName}").Node.Value =
|
|
childValue;
|
|
}
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates runtime options backed by an in-memory copy of the embedded complex-type
|
|
/// NodeSet, optionally changing the TestPoint definition incompatibly.
|
|
/// </summary>
|
|
/// <exception cref="InvalidOperationException">
|
|
/// Thrown when the embedded NodeSet does not contain the expected compatible field.
|
|
/// </exception>
|
|
private static RuntimeNodeSetOptions CreateComplexTypeOptions(
|
|
bool incompatibleDefinition)
|
|
{
|
|
string xml;
|
|
using (Stream stream = RuntimeNodeSetTestServer.OpenTestStream())
|
|
using (var reader = new StreamReader(stream))
|
|
{
|
|
xml = reader.ReadToEnd();
|
|
}
|
|
|
|
if (incompatibleDefinition)
|
|
{
|
|
const string compatibleField =
|
|
"<Field Name=\"Name\" DataType=\"String\" />";
|
|
const string incompatibleField =
|
|
"<Field Name=\"Name\" DataType=\"Int32\" />";
|
|
int fieldIndex = xml.IndexOf(compatibleField, StringComparison.Ordinal);
|
|
if (fieldIndex < 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The embedded TestPoint Name field was not found.");
|
|
}
|
|
xml = xml
|
|
.Remove(fieldIndex, compatibleField.Length)
|
|
.Insert(fieldIndex, incompatibleField);
|
|
}
|
|
|
|
return new RuntimeNodeSetOptions
|
|
{
|
|
Sources =
|
|
[
|
|
RuntimeNodeSetSource.FromStream(
|
|
"ServerComplexTypesTestModel",
|
|
_ => new ValueTask<Stream>(
|
|
new MemoryStream(Encoding.UTF8.GetBytes(xml))),
|
|
[RuntimeNodeSetTestServer.NamespaceUri])
|
|
],
|
|
DefaultNamespaceUri = RuntimeNodeSetTestServer.NamespaceUri
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a synthetic NodeSet2 document with a root object organized under Objects,
|
|
/// a readable <c>LifecycleValue</c> Int32 variable, and a generation-specific extra
|
|
/// variable (<c>OriginalOnly</c> in generation 1, <c>ReplacementOnly</c> in
|
|
/// generation 2). Neither scalar variable carries a <c><Value></c> element;
|
|
/// see the defect note on <see cref="CreateOptions"/> for why their concrete values
|
|
/// are instead wired through the fluent <c>Configure</c> callback.
|
|
/// </summary>
|
|
private static string BuildNodeSetXml(int generation)
|
|
{
|
|
(uint childNodeId, string childBrowseName) = generation == 1
|
|
? (kOriginalOnlyNodeId, kOriginalOnlyBrowseName)
|
|
: (kReplacementOnlyNodeId, kReplacementOnlyBrowseName);
|
|
|
|
return $"""
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<UANodeSet xmlns="http://opcfoundation.org/UA/2011/03/UANodeSet.xsd"
|
|
xmlns:uax="http://opcfoundation.org/UA/2008/02/Types.xsd">
|
|
<NamespaceUris>
|
|
<Uri>{kModelNamespaceUri}</Uri>
|
|
</NamespaceUris>
|
|
<Models>
|
|
<Model ModelUri="{kModelNamespaceUri}" />
|
|
</Models>
|
|
<UAObject NodeId="ns=1;i={kRootNodeId}" BrowseName="1:{kRootBrowseName}">
|
|
<DisplayName>{kRootBrowseName}</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="i=40">i=58</Reference>
|
|
<Reference ReferenceType="i=35">ns=1;i={kValueNodeId}</Reference>
|
|
<Reference ReferenceType="i=35">ns=1;i={childNodeId}</Reference>
|
|
<Reference ReferenceType="i=35" IsForward="false">i=85</Reference>
|
|
</References>
|
|
</UAObject>
|
|
<UAVariable NodeId="ns=1;i={kValueNodeId}" BrowseName="1:{kValueBrowseName}"
|
|
ParentNodeId="ns=1;i={kRootNodeId}" DataType="i=6" ValueRank="-1"
|
|
AccessLevel="3" UserAccessLevel="3">
|
|
<DisplayName>{kValueBrowseName}</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="i=40">i=63</Reference>
|
|
<Reference ReferenceType="i=35" IsForward="false">ns=1;i={kRootNodeId}</Reference>
|
|
</References>
|
|
</UAVariable>
|
|
<UAVariable NodeId="ns=1;i={childNodeId}" BrowseName="1:{childBrowseName}"
|
|
ParentNodeId="ns=1;i={kRootNodeId}" DataType="i=6" ValueRank="-1"
|
|
AccessLevel="3" UserAccessLevel="3">
|
|
<DisplayName>{childBrowseName}</DisplayName>
|
|
<References>
|
|
<Reference ReferenceType="i=40">i=63</Reference>
|
|
<Reference ReferenceType="i=35" IsForward="false">ns=1;i={kRootNodeId}</Reference>
|
|
</References>
|
|
</UAVariable>
|
|
</UANodeSet>
|
|
""";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads a single node attribute through the Read service and validates the response
|
|
/// and diagnostic shape.
|
|
/// </summary>
|
|
private async Task<DataValue> ReadValueAsync(NodeId nodeId, uint attributeId = Attributes.Value)
|
|
{
|
|
ArrayOf<ReadValueId> readIds =
|
|
[new ReadValueId { NodeId = nodeId, AttributeId = attributeId }];
|
|
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ReadResponse response = await m_server.ReadAsync(
|
|
m_secureChannelContext,
|
|
requestHeader,
|
|
kMaxAge,
|
|
TimestampsToReturn.Neither,
|
|
readIds,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(response.ResponseHeader, response.Results, readIds);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
response.DiagnosticInfos,
|
|
readIds,
|
|
response.ResponseHeader.StringTable,
|
|
m_logger);
|
|
|
|
return response.Results[0];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the Server object's <c>UrisVersion</c> variable.
|
|
/// </summary>
|
|
private async Task<uint> ReadUrisVersionAsync()
|
|
{
|
|
DataValue value = await ReadValueAsync(VariableIds.Server_UrisVersion).ConfigureAwait(false);
|
|
Assert.That(value.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
return value.WrappedValue.GetUInt32();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the Server object's <c>NamespaceArray</c> variable.
|
|
/// </summary>
|
|
private async Task<string[]> ReadNamespaceArrayAsync()
|
|
{
|
|
DataValue value = await ReadValueAsync(VariableIds.Server_NamespaceArray).ConfigureAwait(false);
|
|
Assert.That(value.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
return value.WrappedValue.GetStringArray().ToArray()!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a subscription and a single reporting, data-change monitored item on the
|
|
/// given node's Value attribute.
|
|
/// </summary>
|
|
private async Task<uint> CreateSubscriptionWithMonitoredItemAsync(
|
|
ServerTestServices services,
|
|
NodeId nodeId)
|
|
{
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
CreateSubscriptionResponse subscriptionResponse = await services
|
|
.CreateSubscriptionAsync(requestHeader, 100, 100, 10, 0, true, 0)
|
|
.ConfigureAwait(false);
|
|
uint subscriptionId = subscriptionResponse.SubscriptionId;
|
|
|
|
ArrayOf<MonitoredItemCreateRequest> monitoredItems =
|
|
[
|
|
new MonitoredItemCreateRequest
|
|
{
|
|
ItemToMonitor = new ReadValueId
|
|
{
|
|
NodeId = nodeId,
|
|
AttributeId = Attributes.Value
|
|
},
|
|
MonitoringMode = MonitoringMode.Reporting,
|
|
RequestedParameters = new MonitoringParameters
|
|
{
|
|
ClientHandle = 1,
|
|
SamplingInterval = 0,
|
|
QueueSize = 1,
|
|
DiscardOldest = true
|
|
}
|
|
}
|
|
];
|
|
|
|
requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
CreateMonitoredItemsResponse createItemsResponse = await services
|
|
.CreateMonitoredItemsAsync(requestHeader, subscriptionId, TimestampsToReturn.Both, monitoredItems)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(createItemsResponse.Results.Count, Is.EqualTo(1));
|
|
Assert.That(createItemsResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
|
|
return subscriptionId;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes the given subscription so it no longer owns any monitored items.
|
|
/// </summary>
|
|
private async Task DeleteSubscriptionAsync(ServerTestServices services, uint subscriptionId)
|
|
{
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
ArrayOf<uint> subscriptionIds = [subscriptionId];
|
|
DeleteSubscriptionsResponse response = await services
|
|
.DeleteSubscriptionsAsync(requestHeader, subscriptionIds)
|
|
.ConfigureAwait(false);
|
|
Assert.That(response.Results.Count, Is.EqualTo(1));
|
|
Assert.That(response.Results[0], Is.EqualTo(StatusCodes.Good));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Publishes on <paramref name="subscriptionId"/> in a bounded loop, acknowledging
|
|
/// previously delivered sequence numbers on each call, until a
|
|
/// <see cref="DataChangeNotification"/> carrying <paramref name="clientHandle"/>
|
|
/// arrives. Used to prove that a monitored item keeps being serviced (by whichever
|
|
/// NodeManager generation owns it) after a live lifecycle switch.
|
|
/// </summary>
|
|
private async Task<(DataValue? Value, ArrayOf<SubscriptionAcknowledgement> Acknowledgements)>
|
|
PublishForDataChangeAsync(
|
|
ServerTestServices services,
|
|
uint subscriptionId,
|
|
ArrayOf<SubscriptionAcknowledgement> acknowledgements,
|
|
uint clientHandle)
|
|
{
|
|
const int MaxPublishAttempts = 20;
|
|
DataValue? value = null;
|
|
|
|
for (int attempt = 0; attempt < MaxPublishAttempts && value is null; attempt++)
|
|
{
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
|
|
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
PublishResponse response = await services
|
|
.PublishAsync(requestHeader, acknowledgements, timeoutCts.Token)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(response.SubscriptionId, Is.EqualTo(subscriptionId));
|
|
|
|
acknowledgements = response.AvailableSequenceNumbers.ToArrayOf(
|
|
sequenceNumber => new SubscriptionAcknowledgement
|
|
{
|
|
SubscriptionId = subscriptionId,
|
|
SequenceNumber = sequenceNumber
|
|
});
|
|
|
|
if (response.NotificationMessage is { } message)
|
|
{
|
|
foreach (ExtensionObject notificationData in message.NotificationData)
|
|
{
|
|
if (notificationData.TryGetValue(out DataChangeNotification? dcn))
|
|
{
|
|
foreach (MonitoredItemNotification item in dcn.MonitoredItems)
|
|
{
|
|
if (item.ClientHandle == clientHandle)
|
|
{
|
|
value = item.Value;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Assert.That(
|
|
value,
|
|
Is.Not.Null,
|
|
$"No data-change notification for client handle {clientHandle} on subscription " +
|
|
$"{subscriptionId} arrived within {MaxPublishAttempts} bounded publish attempts.");
|
|
return (value, acknowledgements);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Browses a single node with the standard hierarchical-references template used
|
|
/// throughout this fixture.
|
|
/// </summary>
|
|
private async Task<BrowseResponse> BrowseAsync(NodeId nodeId)
|
|
{
|
|
var services = new ServerTestServices(m_server, m_secureChannelContext);
|
|
var template = new BrowseDescription
|
|
{
|
|
BrowseDirection = BrowseDirection.Forward,
|
|
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
|
IncludeSubtypes = true,
|
|
NodeClassMask = 0,
|
|
ResultMask = (uint)BrowseResultMask.All
|
|
};
|
|
ArrayOf<BrowseDescription> nodesToBrowse =
|
|
ServerFixtureUtils.CreateBrowseDescriptionCollectionFromNodeId([nodeId], template);
|
|
|
|
RequestHeader requestHeader = m_requestHeader;
|
|
requestHeader.Timestamp = DateTimeUtc.Now;
|
|
BrowseResponse response = await services
|
|
.BrowseAsync(requestHeader, view: null!, requestedMaxReferencesPerNode: 0, nodesToBrowse)
|
|
.ConfigureAwait(false);
|
|
|
|
ServerFixtureUtils.ValidateResponse(response.ResponseHeader, response.Results, nodesToBrowse);
|
|
ServerFixtureUtils.ValidateDiagnosticInfos(
|
|
response.DiagnosticInfos,
|
|
nodesToBrowse,
|
|
response.ResponseHeader.StringTable,
|
|
m_logger);
|
|
|
|
return response;
|
|
}
|
|
|
|
private ArrayOf<NodeManagerRegistration> GetNonStartupRegistrations()
|
|
{
|
|
var registrations = new List<NodeManagerRegistration>();
|
|
m_server.NodeManagerLifecycle.Registrations.ForEach(registration =>
|
|
{
|
|
if (!m_startupRegistrationIds.Contains(registration.Id))
|
|
{
|
|
registrations.Add(registration);
|
|
}
|
|
});
|
|
return new ArrayOf<NodeManagerRegistration>(registrations.ToArray());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Counts the entries in an <see cref="ArrayOf{T}"/> that satisfy the predicate.
|
|
/// </summary>
|
|
/// <typeparam name="T">The type of item in the array.</typeparam>
|
|
private static int CountMatches<T>(ArrayOf<T> array, Predicate<T> predicate)
|
|
{
|
|
int count = 0;
|
|
array.ForEach(item =>
|
|
{
|
|
if (predicate(item))
|
|
{
|
|
count++;
|
|
}
|
|
});
|
|
return count;
|
|
}
|
|
}
|
|
}
|