Files
lmxopcua/scripts/validation/opcua-lifecycle-memory/baseline-canonical/BaselineMetrics.cs
T

145 lines
7.9 KiB
C#

using System.Globalization;
using System.Collections;
using System.Diagnostics;
using System.Reflection;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
internal static class BaselineMetrics
{
private static FieldInfo Field(object owner, string name)
{
for (var type = owner.GetType(); type is not null; type = type.BaseType)
if (type.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly) is { } member) return member;
throw new InvalidOperationException("Missing exact-.182 diagnostic field: " + name);
}
private static T Required<T>(object owner, string name) => Field(owner, name).GetValue(owner) is T value ? value
: throw new InvalidOperationException("Unexpected exact-.182 diagnostic field type: " + name);
internal static (int Browse, int History) Points(ISession session)
{
using (Required<System.Threading.Lock>(session, "m_lock").EnterScope())
return (Count(session, "m_browseContinuationPoints"), Count(session, "m_historyContinuationPoints"));
}
private static int Count(object owner, string name) => Field(owner, name).GetValue(owner) switch
{ null => 0, ICollection values => values.Count, _ => throw new InvalidOperationException("Unexpected continuation collection type") };
internal static int CachedStoreReferences(CertificateValidator validator)
{
var gate = Required<SemaphoreSlim>(validator, "m_semaphore");
if (!gate.Wait(TimeSpan.FromSeconds(5))) throw new TimeoutException("Original validator diagnostic lock did not settle");
try
{
return new[] { "m_trustedCertificateStore", "m_issuerCertificateStore", "m_rejectedCertificateStore" }
.Select(name => Field(validator, name).GetValue(validator))
.Count(value => value switch
{
null => false,
CertificateStoreIdentifier store => Field(store, "m_store").GetValue(store) switch
{ null => false, ICertificateStore => true, _ => throw new InvalidOperationException("Original cached store type changed") },
_ => throw new InvalidOperationException("Original validator store identifier type changed")
});
}
finally { gate.Release(); }
}
internal static int Counter(object manager, string name)
{
var property = manager.GetType().GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
?? throw new InvalidOperationException("Missing original node-manager diagnostic property: " + name);
return property.PropertyType == typeof(int) && property.GetValue(manager) is int value ? value
: throw new InvalidOperationException("Unexpected original node-manager diagnostic property type: " + name);
}
internal static async Task<(int Total, int Pki)> DescriptorsAsync(CancellationToken ct, params string[] roots)
{
ct.ThrowIfCancellationRequested();
var canonicalRoots = roots.Select(root => CanonicalPath(root)).Distinct(StringComparer.Ordinal).ToArray();
if (OperatingSystem.IsLinux())
{
var targets = Directory.EnumerateFileSystemEntries("/proc/self/fd")
.Select(path => new FileInfo(path).LinkTarget).ToArray();
return (targets.Length, targets.Count(path => IsWithinRoots(path, canonicalRoots)));
}
if (OperatingSystem.IsMacOS())
{
using var process = new Process { StartInfo = new ProcessStartInfo("/usr/sbin/lsof")
{ RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false } };
foreach (var arg in new[] { "-a", "-p", Environment.ProcessId.ToString(CultureInfo.InvariantCulture), "-Fnf" })
process.StartInfo.ArgumentList.Add(arg);
var text = await ReadDescriptorProbeAsync(process, TimeSpan.FromSeconds(5), ct);
process.ExitCode.ShouldBe(0);
var total = 0;
var pki = 0;
var descriptor = false;
foreach (var line in text.Split('\n'))
{
if (line.StartsWith('f'))
{
descriptor = line.Length > 1 && char.IsDigit(line[1]);
if (descriptor) total++;
}
else if (descriptor && line.StartsWith('n') && IsWithinRoots(line[1..], canonicalRoots)) pki++;
}
return (total, pki);
}
throw new PlatformNotSupportedException("This soak requires Linux procfs or macOS lsof for actual descriptor evidence.");
}
private static async Task<string> ReadDescriptorProbeAsync(Process process, TimeSpan timeout, CancellationToken ct)
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
deadline.CancelAfter(timeout);
process.Start();
var stdout = process.StandardOutput.ReadToEndAsync(deadline.Token);
var stderr = process.StandardError.ReadToEndAsync(deadline.Token);
var exited = process.WaitForExitAsync(deadline.Token);
try
{
// Waiting for exit alone does not bound reads: both redirected pipes must also finish.
await Task.WhenAll(stdout, stderr, exited).WaitAsync(deadline.Token).ConfigureAwait(false);
return await stdout.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
try { if (!process.HasExited) process.Kill(entireProcessTree: true); }
catch (InvalidOperationException) when (process.HasExited) { /* It exited between the check and kill. */ }
await process.WaitForExitAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
// Observe both cancelled drains; never leave a pipe read running after returning.
try { await Task.WhenAll(stdout, stderr).WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); }
catch (OperationCanceledException) { }
if (ct.IsCancellationRequested) throw;
throw new TimeoutException("Descriptor probe exceeded its process and redirected-stream deadline.");
}
}
private static bool IsWithinRoots(string? path, IReadOnlyList<string> roots)
{
if (path is null || !Path.IsPathFullyQualified(path)) return false;
// procfs marks an unlinked but still open file; its containing physical directory still counts.
const string deleted = " (deleted)";
if (path.EndsWith(deleted, StringComparison.Ordinal)) path = path[..^deleted.Length];
var canonical = CanonicalPath(path);
return roots.Any(root => canonical.Equals(root, StringComparison.Ordinal)
|| canonical.StartsWith(Path.EndsInDirectorySeparator(root) ? root : root + Path.DirectorySeparatorChar,
StringComparison.Ordinal));
}
private static string CanonicalPath(string path, int depth = 0)
{
if (depth > 32) throw new IOException("Descriptor path link resolution exceeded its bound.");
var full = Path.GetFullPath(path);
var current = Path.GetPathRoot(full)!;
foreach (var component in full[current.Length..].Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries))
{
current = Path.Combine(current, component);
FileSystemInfo entry = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current);
if (entry.LinkTarget is not null)
{
var target = entry.ResolveLinkTarget(returnFinalTarget: true)
?? throw new IOException("Descriptor path link resolution returned no target.");
// Resolve parent links too: an alias target can itself begin with /var or /tmp.
current = CanonicalPath(target.FullName, depth + 1);
}
}
return Path.TrimEndingDirectorySeparator(current);
}
}