Files
UA-.NETStandard/tests/Common/NodeStateMemoryEvidence.cs
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

379 lines
18 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.
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
using NUnit.Framework;
namespace Opc.Ua.Tests
{
/// <summary>
/// Managed-test-only evidence writer shared by node and server population fixtures.
/// </summary>
internal sealed class NodeStateMemoryEvidence : IDisposable
{
internal NodeStateMemoryEvidence(
string output,
string sourceRoot,
string? sdk = null,
string? command = null,
string? revision = null)
{
sourceRoot = Path.GetFullPath(sourceRoot).TrimEnd(Path.DirectorySeparatorChar);
output = Path.GetFullPath(output);
if (output.Equals(sourceRoot, StringComparison.OrdinalIgnoreCase) ||
output.StartsWith(sourceRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException("Evidence must be outside the source tree.", nameof(output));
}
if (!File.Exists(Path.Combine(sourceRoot, "UA.slnx")))
{
throw new ArgumentException("Expected the source worktree used for this build.", nameof(sourceRoot));
}
Directory.CreateDirectory(output);
m_samples = Create(output, "samples.csv");
m_occupancy = Create(output, "occupancy.csv");
m_samples.WriteLine("Metric,Scenario,Sample,Count,Total,PerUnit");
m_occupancy.WriteLine(
"Phase,NodeId,ConcreteType,Groups,CallbackSlots,Description,Metadata,Security," +
"RoleCount,UserRoleCount,Children,DynamicChildren,ExplicitReferences,BrowseReferences," +
"SynthesizedReferences,Notifiers");
using StreamWriter definitions = Create(output, "definitions.csv");
definitions.WriteLine("Key,Value");
Pair(definitions, "Names",
"Cached: one global 16-character string. Unique: one X16 string per node.");
Pair(definitions, "NameSharing",
"NodeId when string, BrowseName, DisplayName, Description share that string.");
Pair(definitions, "Metadata",
"Both name modes share the same XML/list/URI payloads; bag cost is included.");
Pair(definitions, "Security",
"Cached: global permission entry/array. Unique: one entry/array per node.");
Pair(definitions, "Value",
"Double 42 except Payload cases: cached or newly allocated zero-filled 64 bytes.");
Pair(definitions, "ReferenceModes",
"0: cached NodeId converted; 1: new absolute wrapper; 2: preexpanded.");
Pair(definitions, "Allocation",
"Warm synchronous work on the calling thread only; includes discarded objects.");
Pair(definitions, "Live",
"Full-GC deltas; roots preallocated; shared services/payloads excluded; estimates only.");
Pair(definitions, "Index",
"Separate index component holds preexisting nodes; do not add to whole-manager.");
Pair(definitions, "Occupancy",
"Per-node rows allow group co-occurrence; no assumed deployment percentages.");
Pair(definitions, "Browse",
"Diagnostic browsing touches lazy state after memory measurement, never before.");
Pair(definitions, "WholeManager",
"Mock-backed test-manager graph includes context/mocks/index, not a server.");
Pair(definitions, "Telemetry",
"Field-free variable subclass invokes protected Initialize(ITelemetryContext); distinct from Create.");
using StreamWriter identity = Create(output, "identity.csv");
identity.WriteLine("Key,Value");
Pair(identity, "Schema", "NodeStateMemory-v1");
Pair(identity, "Utc", DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture));
Pair(identity, "Command", Environment.CommandLine);
Pair(identity, "Runtime", RuntimeInformation.FrameworkDescription);
Pair(identity, "Architecture", RuntimeInformation.ProcessArchitecture.ToString());
Pair(identity, "OS", RuntimeInformation.OSDescription);
Pair(identity, "ServerGC", GCSettings.IsServerGC.ToString());
Pair(identity, "LatencyMode", GCSettings.LatencyMode.ToString());
Pair(identity, "TieredCompilation",
Environment.GetEnvironmentVariable("DOTNET_TieredCompilation") ?? string.Empty);
Pair(identity, "SourceRoot", Path.GetFullPath(sourceRoot));
Pair(identity, "SDK", sdk ??
Environment.GetEnvironmentVariable("NODESTATE_MEMORY_SDK")
?? throw new InvalidOperationException("Set NODESTATE_MEMORY_SDK to dotnet --version."));
Pair(identity, "BuildCommand", command ??
Environment.GetEnvironmentVariable("NODESTATE_MEMORY_COMMAND")
?? throw new InvalidOperationException("Set NODESTATE_MEMORY_COMMAND to the fresh-build command."));
Pair(identity, "SourceRevision", revision ??
Environment.GetEnvironmentVariable("NODESTATE_MEMORY_REVISION")
?? throw new InvalidOperationException("Set NODESTATE_MEMORY_REVISION to git rev-parse HEAD."));
using StreamWriter assemblies = Create(output, "assemblies.csv");
assemblies.WriteLine("Name,Path,SHA256,MVID,TFM");
foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()
.Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
.OrderBy(a => a.FullName, StringComparer.Ordinal))
{
assemblies.WriteLine(AssemblyRow(assembly));
}
using StreamWriter sources = Create(output, "sources.csv");
sources.WriteLine("Path,SHA256");
foreach (string path in SourceFiles(sourceRoot).OrderBy(p => p, StringComparer.Ordinal))
{
string relative = path[(sourceRoot.TrimEnd(Path.DirectorySeparatorChar).Length + 1)..];
sources.WriteLine($"{Csv(relative)},{HashFile(path)}");
}
}
public void Dispose()
{
m_samples.Dispose();
m_occupancy.Dispose();
}
internal void Sample(string metric, string scenario, int sample, int count, long total)
{
if (count <= 0)
{
throw new ArgumentOutOfRangeException(nameof(count));
}
m_samples.WriteLine(FormattableString.Invariant(
$"{Csv(metric)},{Csv(scenario)},{sample},{count},{total},{(double)total / count:F4}"));
m_samples.Flush();
}
internal void Occupancy(string phase, NodeState node, ISystemContext context)
{
m_occupancy.WriteLine(OccupancyRow(phase, node, context));
m_occupancy.Flush();
}
internal static string OccupancyRow(string phase, NodeState node, ISystemContext context)
{
var children = new List<BaseInstanceState>();
node.GetChildren(context, children);
var references = new List<IReference>();
node.GetReferences(context, references);
var notifiers = new List<NodeState.Notifier>();
node.GetNotifiers(context, notifiers);
int browseCount = 0;
int synthesized = 0;
using (INodeBrowser browser = node.CreateBrowser(
context, null, default, true, BrowseDirection.Both, default, default, false))
{
IReference? reference;
while ((reference = browser.Next()) is not null)
{
browseCount++;
if (!references.Exists(r => r.ReferenceTypeId == reference.ReferenceTypeId &&
r.IsInverse == reference.IsInverse &&
r.TargetId == reference.TargetId))
{
synthesized++;
}
}
}
var dynamicChildren = (List<BaseInstanceState>?)s_children.GetValue(node);
(string groups, int slots) = CallbackGroups(node);
bool metadata = node.Extensions is not null ||
node.Categories is not null ||
node.ReleaseStatus != Export.ReleaseStatus.Released ||
node.Specification is not null ||
node.NodeSetDocumentation is not null ||
node.DesignToolOnly;
bool security = !node.RolePermissions.IsNull ||
!node.UserRolePermissions.IsNull ||
node.AccessRestrictions.HasValue;
return
$"{Csv(phase)},{Csv(node.NodeId.ToString())},{Csv(node.GetType().FullName ?? string.Empty)}," +
$"{Csv(groups)},{slots},{!node.Description.IsNull},{metadata},{security}," +
$"{(node.RolePermissions.IsNull ? -1 : node.RolePermissions.Count)}," +
$"{(node.UserRolePermissions.IsNull ? -1 : node.UserRolePermissions.Count)}," +
$"{children.Count},{dynamicChildren?.Count ?? 0},{references.Count},{browseCount}," +
$"{synthesized},{notifiers.Count}";
}
internal static (string Groups, int Slots) CallbackGroups(NodeState node)
{
var groups = new SortedSet<string>(StringComparer.Ordinal);
int slots = 0;
for (Type? type = node.GetType(); type is not null; type = type.BaseType)
{
foreach (FieldInfo field in type.GetFields(
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
{
if (!typeof(Delegate).IsAssignableFrom(field.FieldType) || field.GetValue(node) is not Delegate)
{
continue;
}
slots++;
string group = field.Name switch
{
"StateChanged" or "StateChangedAsync" or "EventReported" => "EventSubscription",
"OnSimpleReadValue" or "OnSimpleWriteValue" or "OnReadValue" or "OnWriteValue" or
"OnReadValueAsync" or "OnWriteValueAsync" or "OnSimpleReadValueAsync" or
"OnSimpleWriteValueAsync" => "Value",
_ when type == typeof(NodeState) =>
field.Name.StartsWith("OnRead", StringComparison.Ordinal) ||
field.Name.StartsWith("OnWrite", StringComparison.Ordinal) ? "BaseAttribute" : "Behavior",
_ when type == typeof(BaseVariableState) => "VariableAttribute",
_ when type == typeof(MethodState) => "Method",
_ => "Derived"
};
groups.Add(group);
}
}
return (string.Join("+", groups), slots);
}
internal static string AssemblyRow(Assembly assembly)
{
return $"{Csv(assembly.GetName().Name ?? string.Empty)},{Csv(assembly.Location)}," +
$"{HashFile(assembly.Location)}," +
$"{assembly.ManifestModule.ModuleVersionId:D}," +
$"{Csv(assembly.GetCustomAttribute<TargetFrameworkAttribute>()?.FrameworkName ?? string.Empty)}";
}
internal static string HashFile(string path)
{
using FileStream stream = File.OpenRead(path);
using var algorithm = SHA256.Create();
#if NET5_0_OR_GREATER
return Convert.ToHexString(algorithm.ComputeHash(stream));
#else
return BitConverter.ToString(algorithm.ComputeHash(stream)).Replace("-", string.Empty);
#endif
}
internal static string Csv(string value)
{
return "\"" + value.Replace("\"", "\"\"", StringComparison.Ordinal) + "\"";
}
internal static long FullHeap()
{
GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: true);
GC.WaitForPendingFinalizers();
GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: true);
return GC.GetTotalMemory(forceFullCollection: true);
}
internal static IEnumerable<string> SourceFiles(string root)
{
foreach (string file in Directory.EnumerateFiles(root))
{
string extension = Path.GetExtension(file);
if (extension is ".cs" or ".csproj" or ".props" or ".targets" or ".json" or ".slnx" or
".xml" or ".csv" or ".resx" or ".config" ||
Path.GetFileName(file) == ".editorconfig")
{
yield return file;
}
}
foreach (string directory in Directory.EnumerateDirectories(root))
{
string name = Path.GetFileName(directory);
if (name is "bin" or "obj" or "TestResults" or "BenchmarkDotNet.Artifacts" ||
name[0] == '.')
{
continue;
}
foreach (string file in SourceFiles(directory))
{
yield return file;
}
}
}
private static StreamWriter Create(string directory, string name)
{
return new StreamWriter(
new FileStream(Path.Combine(directory, name), FileMode.CreateNew, FileAccess.Write, FileShare.Read),
new UTF8Encoding(false));
}
private static void Pair(StreamWriter writer, string key, string value)
{
writer.WriteLine($"{Csv(key)},{Csv(value)}");
}
private readonly StreamWriter m_samples;
private readonly StreamWriter m_occupancy;
private static readonly FieldInfo s_children = typeof(NodeState).GetField(
"m_children", BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("Update the diagnostic dynamic-child probe for this node layout.");
}
/// <summary>
/// Checks the shared CSV/source writer in each consuming test assembly and target framework.
/// </summary>
[TestFixture]
[Category("NodeStateOccupancy")]
public sealed class NodeStateMemoryEvidenceTests
{
/// <summary>
/// Output pins actual input bytes and sample units, and cannot overwrite a baseline.
/// </summary>
[Test]
public void EvidencePreservesSourceIdentityAndSampleUnits()
{
string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
string output = root + "-evidence";
Directory.CreateDirectory(root);
string solution = Path.Combine(root, "UA.slnx");
File.WriteAllText(solution, "abc", new UTF8Encoding(false));
try
{
Assert.Throws<ArgumentException>(() =>
{
using var invalid = new NodeStateMemoryEvidence(Path.Combine(root, "output"), root);
});
using (var evidence = new NodeStateMemoryEvidence(
output, root, "test-sdk", "test-command", "test-revision"))
{
evidence.Sample("Bytes", "TwoNodes", 1, 2, 20);
Assert.Throws<ArgumentOutOfRangeException>(() => evidence.Sample("Bytes", "Invalid", 1, 0, 20));
}
string samples = File.ReadAllText(Path.Combine(output, "samples.csv"));
Assert.That(samples, Does.Contain("\"Bytes\",\"TwoNodes\",1,2,20,10.0000"));
string sources = File.ReadAllText(Path.Combine(output, "sources.csv"));
Assert.That(sources, Does.Contain(
"\"UA.slnx\",BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD"));
string identity = File.ReadAllText(Path.Combine(output, "identity.csv"));
Assert.That(identity, Does.Contain("\"SDK\",\"test-sdk\""));
Assert.That(identity, Does.Contain("\"BuildCommand\",\"test-command\""));
Assert.That(identity, Does.Contain("\"SourceRevision\",\"test-revision\""));
Assert.Throws<IOException>(() =>
{
using var duplicate = new NodeStateMemoryEvidence(output, root, "test-sdk", "command", "revision");
});
Assert.That(File.ReadAllText(Path.Combine(output, "samples.csv")), Is.EqualTo(samples));
}
finally
{
foreach (string name in s_evidenceNames)
{
File.Delete(Path.Combine(output, name));
}
File.Delete(solution);
Directory.Delete(output);
Directory.Delete(root);
}
}
private static readonly string[] s_evidenceNames =
["samples.csv", "occupancy.csv", "definitions.csv", "identity.csv", "assemblies.csv", "sources.csv"];
}
}