Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2LifecycleWindowsDescriptors.cs
T
Joseph Doherty aa60b3e34e
v2-ci / unit-tests (push) Has been cancelled
test(soak): #799 place the soak's UNS folder after #808; Windows x64 port of the lifecycle soak harness
Acceptance runs for #799 (owner scope 2026-11-12: Windows x64 + Linux) on current master.

Fixture: since #808 an UNS folder the authority snapshot does not place is denied under enforcement (fail
closed). The canonical soak creates its own "SoakEquipment" folder but never placed it, so on master the very
first cycle's UNS monitored item came back bad (retained failed Linux launch on b61f3e05, cycle 1). The rig
gains AdditionalFolderScopes (merged into FolderScopes by Enforce) and the soak places its folder there, as a
deployed generation does - the same fixture gap #888 fixed for the SDK matrix. No server authorization code,
cap, baseline or canonical operation changed; the soak test bodies are otherwise byte-identical to b18f9723.

Windows port (test/harness only, opt-in):
- DescriptorCountsAsync gains a Windows branch: total = every handle in the process handle table, PKI = disk
  file/directory handles whose GetFinalPathNameByHandle path lies under a canonicalised PKI root; dedicated
  thread under the same 5 s bound as the macOS lsof probe.
- Descriptor controls enabled on Windows (symlink alias; timeout control via cmd.exe + ping).
- LateSlopeRun.ValidateRequest accepts Linux ARM64 or Windows x64; duration/runtime/leak tracking unchanged.
- late-slope/analyze.py: expected architecture is a module constant (Linux default Arm64 unchanged).
- windows/: prepare.py (win-x64 self-contained 10.0.14 bundle + manifest), launch.ps1 (container.sh
  equivalent: bundle re-verification, 2/2 controls first, PID/apphost/coreclr verification, external
  sampler, 7440 s outer deadline), analyze.py (Linux row/window logic, X64), README with every adaptation.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 12:42:16 -05:00

144 lines
7.1 KiB
C#

using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using Microsoft.Win32.SafeHandles;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// Windows port of the soak's descriptor observer (lmxopcua#799 acceptance). Windows has no fd table: the process
/// handle table is the equivalent kernel-resource table, so <c>Total</c> is every handle this process owns (the
/// whole-process figure, like Linux <c>/proc/self/fd</c>) and <c>Pki</c> is the open disk-file/directory handles whose
/// final path lies under a PKI root. Paths come from <c>GetFinalPathNameByHandle</c> (symlinks, junctions, 8.3 names
/// and casing resolved, the Windows analog of the macOS /var→/private/var canonicalisation), and the roots are
/// canonicalised the same way, compared case-insensitively as NTFS does. The probe is in-process (no child process
/// like macOS lsof) and runs on a dedicated thread under the same 5-second bound as the lsof probe: a name query that
/// blocks is a timeout, never a favourable zero.
/// </summary>
public sealed partial class Sdk2LifecycleSoakTests
{
[SupportedOSPlatform("windows")]
private static Task<(int Total, int Pki)> WindowsDescriptorCountsAsync(CancellationToken ct, IReadOnlyList<string> roots)
{
var result = new TaskCompletionSource<(int Total, int Pki)>(TaskCreationOptions.RunContinuationsAsynchronously);
var thread = new Thread(() =>
{
try { result.TrySetResult(WindowsHandleCounts(roots)); }
catch (Exception error) { result.TrySetException(error); }
}) { IsBackground = true, Name = "soak-descriptor-probe" };
thread.Start();
return BoundAsync();
async Task<(int Total, int Pki)> BoundAsync()
{
try { return await result.Task.WaitAsync(TimeSpan.FromSeconds(5), ct).ConfigureAwait(false); }
catch (TimeoutException) { throw new TimeoutException("Descriptor probe exceeded its handle-enumeration deadline."); }
}
}
[SupportedOSPlatform("windows")]
private static (int Total, int Pki) WindowsHandleCounts(IReadOnlyList<string> roots)
{
// Canonicalise the roots first and close those directory handles before the snapshot, so they are not counted.
var canonicalRoots = roots.Select(WindowsCanonicalRoot).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
var self = (nuint)Environment.ProcessId;
var length = 1 << 20;
while (true)
{
var buffer = Marshal.AllocHGlobal(length);
try
{
var status = NtQuerySystemInformation(SystemExtendedHandleInformation, buffer, length, out var needed);
if (status == StatusInfoLengthMismatch)
{
length = Math.Max(length * 2, needed + (1 << 16));
continue;
}
if (status != 0) throw new Win32Exception(RtlNtStatusToDosError(status), $"NtQuerySystemInformation failed (0x{status:X8}).");
var count = (long)Marshal.ReadIntPtr(buffer);
var entries = buffer + 2 * IntPtr.Size;
var entrySize = Marshal.SizeOf<HandleEntry>();
int total = 0, pki = 0;
var path = new char[32768];
for (long i = 0; i < count; i++)
{
var entry = Marshal.PtrToStructure<HandleEntry>(entries + (nint)(i * entrySize));
if (entry.UniqueProcessId != self) continue;
total++;
var handle = (nint)entry.HandleValue;
// GetFileType is answered by the I/O manager without the file-object lock; only disk files and
// directories go on to the name query (pipes, sockets and consoles cannot hold PKI material).
if (GetFileType(handle) != FileTypeDisk) continue;
var name = FinalPath(handle, path);
if (name is not null && WithinWindowsRoots(name, canonicalRoots)) pki++;
}
return (total, pki);
}
finally { Marshal.FreeHGlobal(buffer); }
}
}
[SupportedOSPlatform("windows")]
private static string WindowsCanonicalRoot(string root)
{
var full = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root));
if (!Directory.Exists(full)) return CanonicalPath(full);
using var directory = CreateFileW(full, 0, FileShareAll, 0, OpenExisting, FileFlagBackupSemantics, 0);
if (directory.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error(), "Cannot open a PKI root for canonicalisation.");
return FinalPath(directory.DangerousGetHandle(), new char[32768])
?? throw new IOException("A PKI root has no final path.");
}
private static string? FinalPath(nint handle, char[] buffer)
{
var written = GetFinalPathNameByHandleW(handle, buffer, buffer.Length, 0);
if (written == 0 || written >= buffer.Length) return null; // Closed/reused between snapshot and query, or not a named file.
var name = new string(buffer, 0, (int)written);
if (name.StartsWith(@"\\?\UNC\", StringComparison.Ordinal)) return @"\\" + name[8..];
return name.StartsWith(@"\\?\", StringComparison.Ordinal) ? name[4..] : name;
}
private static bool WithinWindowsRoots(string path, IReadOnlyList<string> roots)
{
path = Path.TrimEndingDirectorySeparator(path);
return roots.Any(root => path.Equals(root, StringComparison.OrdinalIgnoreCase)
|| path.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase));
}
private const int SystemExtendedHandleInformation = 64;
private const int StatusInfoLengthMismatch = unchecked((int)0xC0000004);
private const uint FileTypeDisk = 1;
private const uint FileShareAll = 7;
private const uint OpenExisting = 3;
private const uint FileFlagBackupSemantics = 0x02000000;
[StructLayout(LayoutKind.Sequential)]
private struct HandleEntry
{
public nint Object;
public nuint UniqueProcessId;
public nuint HandleValue;
public uint GrantedAccess;
public ushort CreatorBackTraceIndex;
public ushort ObjectTypeIndex;
public uint HandleAttributes;
public uint Reserved;
}
[DllImport("ntdll.dll")]
private static extern int NtQuerySystemInformation(int informationClass, nint information, int length, out int returnLength);
[DllImport("ntdll.dll")]
private static extern int RtlNtStatusToDosError(int status);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint GetFileType(nint handle);
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern uint GetFinalPathNameByHandleW(nint handle, [Out] char[] path, int length, uint flags);
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern SafeFileHandle CreateFileW(string name, uint access, uint share, nint security, uint disposition,
uint flags, nint template);
}