Files
romanett 9b034f8291 Enable nullable reference types centrally; migrate all unset and annotations-only projects (#4700)
# 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>
2026-10-08 08:48:13 +02:00

339 lines
12 KiB
C#

using System;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using Opc.Ua.Server.TestFramework;
using Opc.Ua.Tests;
using Quickstarts.ReferenceServer;
namespace Opc.Ua.Server.Tests
{
/// <summary>
/// Test <see cref="CustomNodeManager2"/>
/// </summary>
[TestFixture]
[Category("CustomNodeManager")]
[SetCulture("en-us")]
[SetUICulture("en-us")]
[Parallelizable]
public class CustomNodeManagerTests
{
/// <summary>
/// Tests the componentCache methods with multiple threads
/// </summary>
[Test]
public async Task TestComponentCacheAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var fixture = new ServerFixture<StandardServer>(t => new ReferenceServer(t));
try
{
// Arrange
const string ns = "http://test.org/UA/Data/";
StandardServer server = await fixture.StartAsync()
.ConfigureAwait(false);
using var nodeManager = new TestableCustomNodeManger2(server.CurrentInstance, ns);
var baseObject = new BaseObjectState(null);
var nodeHandle = new NodeHandle(
CommonTestWorkers.NodeIdTestSetStatic[0]
.WithNamespaceIndex(0)
.InnerNodeId,
baseObject);
//Act
await RunTaskInParallelAsync(
() => UseComponentCacheAsync(nodeManager, baseObject, nodeHandle),
100)
.ConfigureAwait(false);
//Assert, that entry was deleted from cache after parallel operations on the same node
NodeState handleFromCache = nodeManager.LookupNodeInComponentCache(
nodeManager.SystemContext,
nodeHandle);
Assert.That(handleFromCache, Is.Null);
}
finally
{
await fixture.StopAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Part 4 §5.12.2.2: a Call whose methodId is the Method declaration of the
/// ObjectType resolves to the Method of the Object, so the RolePermissions
/// of the Object's Method are verified and that Method is invoked.
/// </summary>
[Test]
public async Task FindMethodStateResolvesTypeDeclarationToObjectMethodAsync()
{
var fixture = new ServerFixture<StandardServer>(t => new ReferenceServer(t));
try
{
const string ns = "http://test.org/UA/MethodResolution/";
StandardServer server = await fixture.StartAsync()
.ConfigureAwait(false);
using var nodeManager = new TestableCustomNodeManger2(server.CurrentInstance, ns);
ushort index = (ushort)server.CurrentInstance.NamespaceUris.GetIndex(ns);
ServerSystemContext context = nodeManager.SystemContext;
var objectType = new BaseObjectTypeState
{
NodeId = new NodeId("ResolveType", index),
BrowseName = new QualifiedName("ResolveType", index),
SuperTypeId = ObjectTypeIds.BaseObjectType
};
var declaration = new MethodState(objectType)
{
NodeId = new NodeId("ResolveType.Reset", index),
BrowseName = new QualifiedName("Reset", index)
};
objectType.AddChild(declaration);
var instance = new BaseObjectState(null)
{
NodeId = new NodeId("ResolveInstance", index),
BrowseName = new QualifiedName("ResolveInstance", index),
TypeDefinitionId = objectType.NodeId
};
var instanceMethod = new MethodState(instance)
{
NodeId = new NodeId("ResolveInstance.Reset", index),
BrowseName = new QualifiedName("Reset", index)
};
instance.AddChild(instanceMethod);
nodeManager.AddPredefinedNode(context, objectType);
nodeManager.AddPredefinedNode(context, instance);
server.CurrentInstance.TypeTree.AddSubtype(
objectType.NodeId,
ObjectTypeIds.BaseObjectType);
var operationContext = new OperationContext(
new RequestHeader(),
null,
RequestType.Call,
RequestLifetime.None);
MethodState resolved = nodeManager.FindMethodState(
operationContext,
new CallMethodRequest
{
ObjectId = instance.NodeId,
MethodId = declaration.NodeId
});
Assert.That(resolved, Is.SameAs(instanceMethod));
}
finally
{
await fixture.StopAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Tests the Predefined Nodes methods with multiple threads
/// </summary>
[Test]
public async Task TestPredefinedNodesAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var fixture = new ServerFixture<StandardServer>(t => new ReferenceServer(t));
try
{
// Arrange
const string ns = "http://test.org/UA/Data/";
StandardServer server = await fixture.StartAsync()
.ConfigureAwait(false);
using var nodeManager = new TestableCustomNodeManger2(server.CurrentInstance, ns);
int index = server.CurrentInstance.NamespaceUris.GetIndex(ns);
var baseObject = new DataItemState(null);
NodeId nodeId =
CommonTestWorkers.NodeIdTestSetStatic[0]
.WithNamespaceIndex((ushort)index)
.InnerNodeId;
baseObject.NodeId = nodeId;
//single threaded test
nodeManager.AddPredefinedNode(nodeManager.SystemContext, baseObject);
Assert.That(nodeManager.PredefinedNodes.ContainsKey(nodeId), Is.True);
NodeState nodeState = nodeManager.Find(nodeId)!;
Assert.That(nodeState, Is.Not.Null);
var handle = nodeManager.GetManagerHandle(nodeId) as NodeHandle;
Assert.That(handle, Is.Not.Null);
nodeManager.DeleteNode(nodeManager.SystemContext, nodeId);
Assert.That(nodeManager.PredefinedNodes, Is.Empty);
nodeState = nodeManager.Find(nodeId)!;
Assert.That(nodeState, Is.Null);
handle = nodeManager.GetManagerHandle(nodeId) as NodeHandle;
Assert.That(handle, Is.Null);
nodeManager.AddPredefinedNode(nodeManager.SystemContext, baseObject);
nodeManager.DeleteAddressSpace();
Assert.That(nodeManager.PredefinedNodes, Is.Empty);
//Act
await RunTaskInParallelAsync(
() => UsePredefinedNodesAsync(nodeManager, baseObject, nodeId),
100)
.ConfigureAwait(false);
//last operation added the Node back into the dictionary
Assert.That(nodeManager.PredefinedNodes.ContainsKey(nodeId), Is.True);
//delete full adress space
nodeManager.DeleteAddressSpace();
Assert.That(nodeManager.PredefinedNodes, Is.Empty);
}
finally
{
await fixture.StopAsync().ConfigureAwait(false);
}
}
private static Task UsePredefinedNodesAsync(
TestableCustomNodeManger2 nodeManager,
DataItemState baseObject,
NodeId nodeId)
{
nodeManager.AddPredefinedNode(nodeManager.SystemContext, baseObject);
_ = nodeManager.Find(nodeId);
_ = nodeManager.GetManagerHandle(nodeId) as NodeHandle;
nodeManager.DeleteNode(nodeManager.SystemContext, nodeId);
_ = nodeManager.Find(nodeId);
_ = nodeManager.GetManagerHandle(nodeId) as NodeHandle;
nodeManager.AddPredefinedNode(nodeManager.SystemContext, baseObject);
return Task.CompletedTask;
}
/// <summary>
/// Test Methods AddNodeToComponentCache, RemoveNodeFromComponentCache & LookupNodeInComponentCache & verify the node is added to the cache
/// </summary>
private static async Task UseComponentCacheAsync(
TestableCustomNodeManger2 nodeManager,
BaseObjectState baseObject,
NodeHandle nodeHandle)
{
//-- Act
nodeManager.AddNodeToComponentCache(nodeManager.SystemContext, nodeHandle, baseObject);
nodeManager.AddNodeToComponentCache(nodeManager.SystemContext, nodeHandle, baseObject);
NodeState handleFromCache = nodeManager.LookupNodeInComponentCache(
nodeManager.SystemContext,
nodeHandle);
//-- Assert
Assert.That(handleFromCache, Is.Not.Null);
nodeManager.RemoveNodeFromComponentCache(nodeManager.SystemContext, nodeHandle);
handleFromCache = nodeManager.LookupNodeInComponentCache(
nodeManager.SystemContext,
nodeHandle);
Assert.That(handleFromCache, Is.Not.Null);
nodeManager.RemoveNodeFromComponentCache(nodeManager.SystemContext, nodeHandle);
await Task.CompletedTask.ConfigureAwait(false);
}
private static async Task<(bool IsSuccess, Exception? Error)> RunTaskInParallelAsync(
Func<Task> task,
int iterations)
{
using var cancellationTokenSource = new CancellationTokenSource();
Exception? error = null;
int tasksCompletedCount = 0;
ParallelLoopResult result = Parallel.For(
0,
iterations,
new ParallelOptions(),
async _ =>
{
try
{
await task().ConfigureAwait(false);
}
catch (Exception ex)
{
error = ex;
cancellationTokenSource.Cancel();
}
finally
{
tasksCompletedCount++;
}
});
int spinWaitCount = 0;
const int maxSpinWaitCount = 100;
while (iterations > tasksCompletedCount &&
error is null &&
spinWaitCount < maxSpinWaitCount)
{
await Task.Delay(TimeSpan.FromMilliseconds(100)).ConfigureAwait(false);
spinWaitCount++;
}
return (error == null, error);
}
}
public class TestableCustomNodeManger2 : CustomNodeManager2
{
public TestableCustomNodeManger2(IServerInternal server, params string[] namespaceUris)
: base(server, namespaceUris)
{
}
public new NodeState AddNodeToComponentCache(
ISystemContext context,
NodeHandle handle,
NodeState node)
{
return base.AddNodeToComponentCache(context, handle, node);
}
public new void RemoveNodeFromComponentCache(ISystemContext context, NodeHandle handle)
{
base.RemoveNodeFromComponentCache(context, handle);
}
public new NodeState LookupNodeInComponentCache(ISystemContext context, NodeHandle handle)
{
return base.LookupNodeInComponentCache(context, handle)!;
}
public new NodeIdDictionary<NodeState> PredefinedNodes => base.PredefinedNodes;
public new virtual void AddPredefinedNode(ISystemContext context, NodeState node)
{
base.AddPredefinedNode(context, node);
}
}
}