145 lines
7.9 KiB
C#
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);
|
|
}
|
|
|
|
}
|