/* ========================================================================
* 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
{
///
/// Managed-test-only evidence writer shared by node and server population fixtures.
///
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();
node.GetChildren(context, children);
var references = new List();
node.GetReferences(context, references);
var notifiers = new List();
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?)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(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()?.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 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.");
}
///
/// Checks the shared CSV/source writer in each consuming test assembly and target framework.
///
[TestFixture]
[Category("NodeStateOccupancy")]
public sealed class NodeStateMemoryEvidenceTests
{
///
/// Output pins actual input bytes and sample units, and cannot overwrite a baseline.
///
[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(() =>
{
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(() => 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(() =>
{
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"];
}
}