using System.Collections.Generic; using System.Diagnostics; using System.Runtime.InteropServices; using System.Text; namespace RemSound.App; /// /// Counts THIS process's open OS handles grouped by type (Event, Section, File, Key, Thread, …). /// Added 2026-06-07 to pin down the receiver handle leak: the diag line's handles= column /// proved the leak is OS handles, but not WHICH kind — and the kind names the culprit (Event ⇒ /// a waitable-object leak, Section ⇒ a WASAPI buffer/audio-client leak, Key ⇒ a CNG/AesGcm leak, /// Thread ⇒ thread-handle leak, etc.). /// /// Primary mechanism: NtQueryInformationProcess(ProcessHandleInformation) returns ONLY the calling /// process's handle table — small, fast, and it never touches the system-wide handle table. The /// original system-wide walk via NtQuerySystemInformation(SystemExtendedHandleInformation) returned /// STATUS_ACCESS_VIOLATION (0xC0000005) on Andre's ASUS-Realtek machine, so it is now only a /// fallback for any box where the per-process class is unavailable. Type names are resolved per /// distinct ObjectTypeIndex ONCE via NtQueryObject(ObjectTypeInformation) on a sample handle — /// class 2 is safe on our own handles (the known NtQueryObject hang only affects ObjectName- /// Information, class 1, on synchronous pipes, which we never request). Everything is wrapped in /// try/catch and unmanaged buffers are always freed, so a failure degrades to a probe-error string /// rather than disturbing the very memory we're measuring. x64 only (the shipped runtime). /// internal static class HandleTypeProbe { private const int ProcessHandleInformation = 51; // PROCESSINFOCLASS private const int SystemExtendedHandleInformation = 0x40; // SYSTEM_INFORMATION_CLASS private const int ObjectTypeInformation = 2; private const uint STATUS_INFO_LENGTH_MISMATCH = 0xC0000004; // PROCESS_HANDLE_SNAPSHOT_INFORMATION (x64): NumberOfHandles (ULONG_PTR) +0, Reserved +8, // then PROCESS_HANDLE_TABLE_ENTRY_INFO[] at +16. Each entry is 40 bytes: // HandleValue +0, HandleCount +8, PointerCount +16, GrantedAccess +24, // ObjectTypeIndex (ULONG) +28, HandleAttributes +32, Reserved +36. private const int PhHeaderSize = 16; private const int PhEntrySize = 40; private const int PhOffHandleValue = 0; private const int PhOffObjectTypeIndex = 28; // 64-bit SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX is 40 bytes; UniqueProcessId at +8, HandleValue at // +16, ObjectTypeIndex (USHORT) at +30. Header is 16 bytes (NumberOfHandles + Reserved). private const int SysHeaderSize = 16; private const int SysEntrySize = 40; private const int SysOffUniqueProcessId = 8; private const int SysOffHandleValue = 16; private const int SysOffObjectTypeIndex = 30; // Cap any snapshot so a pathological system can't make us allocate without bound (we're // hunting a leak — don't become one). 128 MB covers well over a million handles. private const int MaxBufferBytes = 128 * 1024 * 1024; private static readonly nint CurrentProcessPseudoHandle = (nint)(-1); private static readonly Dictionary TypeNameByIndex = new(); private static readonly int OwnPid = Process.GetCurrentProcess().Id; /// /// Returns "Event=120345 Section=15234 File=210 … total=N" — the most /// common handle types owned by this process. Tries the per-process query first, falls back to /// the system-wide walk, and returns a probe-error string (never throws) if both fail. /// Heavier than the per-tick meter, so call it on a slow cadence, not every diag line. /// public static string Summarize(int topN = 10) { var own = TryOwnProcess(topN, out var ownStatus); if (own != null) return own; var sys = TrySystemWide(topN, out var sysStatus); if (sys != null) return sys; return $"probe-error proc-status=0x{ownStatus:X8} sys-status=0x{sysStatus:X8}"; } /// /// Primary path: query only THIS process's handle table. Returns the formatted summary, or null /// on any failure (with set to the NTSTATUS for diagnostics). /// private static string? TryOwnProcess(int topN, out uint status) { nint buffer = 0; status = 0; try { var size = 1 << 18; // 256 KB — our own table is small even when leaking. while (true) { buffer = buffer == 0 ? Marshal.AllocHGlobal(size) : Marshal.ReAllocHGlobal(buffer, (nint)size); status = NtQueryInformationProcess(CurrentProcessPseudoHandle, ProcessHandleInformation, buffer, (uint)size, out var needed); if (status != STATUS_INFO_LENGTH_MISMATCH) break; size = (int)System.Math.Min((long)System.Math.Max(needed, (uint)size) * 2, MaxBufferBytes); if (size >= MaxBufferBytes) { status = NtQueryInformationProcess(CurrentProcessPseudoHandle, ProcessHandleInformation, buffer, (uint)size, out _); break; } } if (status != 0) return null; var count = Marshal.ReadInt64(buffer); // NumberOfHandles var counts = new Dictionary(); var entryBase = buffer + PhHeaderSize; for (long i = 0; i < count; i++) { var entry = entryBase + (nint)(i * PhEntrySize); var typeIndex = (ushort)Marshal.ReadInt32(entry + PhOffObjectTypeIndex); counts.TryGetValue(typeIndex, out var c); counts[typeIndex] = c + 1; if (!TypeNameByIndex.ContainsKey(typeIndex)) { var handle = Marshal.ReadIntPtr(entry + PhOffHandleValue); TypeNameByIndex[typeIndex] = ResolveTypeName(handle, typeIndex); } } return counts.Count == 0 ? null : Format(counts, topN); } catch { return null; } finally { if (buffer != 0) Marshal.FreeHGlobal(buffer); } } /// /// Fallback path: walk the whole system handle table and filter to our PID. Returns null on any /// failure (with set). Kept for machines where the per-process class /// is unavailable; on Andre's box this path returns STATUS_ACCESS_VIOLATION, which is exactly /// why is tried first. /// private static string? TrySystemWide(int topN, out uint status) { nint buffer = 0; status = 0; try { var size = 1 << 20; // 1 MB to start; grow on mismatch. while (true) { buffer = buffer == 0 ? Marshal.AllocHGlobal(size) : Marshal.ReAllocHGlobal(buffer, (nint)size); status = NtQuerySystemInformation(SystemExtendedHandleInformation, buffer, (uint)size, out var needed); if (status != STATUS_INFO_LENGTH_MISMATCH) break; size = (int)System.Math.Min((long)System.Math.Max(needed, (uint)size) * 2, MaxBufferBytes); if (size >= MaxBufferBytes) { status = NtQuerySystemInformation(SystemExtendedHandleInformation, buffer, (uint)size, out _); break; } } if (status != 0) return null; var count = Marshal.ReadInt64(buffer); // NumberOfHandles var counts = new Dictionary(); var entryBase = buffer + SysHeaderSize; for (long i = 0; i < count; i++) { var entry = entryBase + (nint)(i * SysEntrySize); var pid = (int)Marshal.ReadInt64(entry + SysOffUniqueProcessId); if (pid != OwnPid) continue; var typeIndex = (ushort)Marshal.ReadInt16(entry + SysOffObjectTypeIndex); counts.TryGetValue(typeIndex, out var c); counts[typeIndex] = c + 1; if (!TypeNameByIndex.ContainsKey(typeIndex)) { var handle = Marshal.ReadIntPtr(entry + SysOffHandleValue); TypeNameByIndex[typeIndex] = ResolveTypeName(handle, typeIndex); } } return counts.Count == 0 ? null : Format(counts, topN); } catch { return null; } finally { if (buffer != 0) Marshal.FreeHGlobal(buffer); } } private static string Format(Dictionary counts, int topN) { var ordered = new List>(counts); ordered.Sort((a, b) => b.Value.CompareTo(a.Value)); var sb = new StringBuilder(); var total = 0; var shown = 0; foreach (var kv in ordered) { total += kv.Value; if (shown < topN) { if (sb.Length > 0) sb.Append(' '); sb.Append(TypeNameByIndex.TryGetValue(kv.Key, out var n) ? n : $"Type#{kv.Key}").Append('=').Append(kv.Value); shown++; } } sb.Append(" total=").Append(total); return sb.ToString(); } private static string ResolveTypeName(nint handle, ushort index) { nint info = 0; try { const int len = 4096; info = Marshal.AllocHGlobal(len); var status = NtQueryObject(handle, ObjectTypeInformation, info, len, out _); if (status != 0) return $"Type#{index}"; // OBJECT_TYPE_INFORMATION starts with UNICODE_STRING TypeName { USHORT Length; // USHORT MaximumLength; PWSTR Buffer; } — Length at +0, Buffer (ptr) at +8 on x64. var nameLen = (ushort)Marshal.ReadInt16(info); var namePtr = Marshal.ReadIntPtr(info + 8); if (namePtr == 0 || nameLen == 0) return $"Type#{index}"; var name = Marshal.PtrToStringUni(namePtr, nameLen / 2); return string.IsNullOrEmpty(name) ? $"Type#{index}" : name; } catch { return $"Type#{index}"; } finally { if (info != 0) Marshal.FreeHGlobal(info); } } [DllImport("ntdll.dll")] private static extern uint NtQueryInformationProcess(nint processHandle, int processInformationClass, nint processInformation, uint processInformationLength, out uint returnLength); [DllImport("ntdll.dll")] private static extern uint NtQuerySystemInformation(int systemInformationClass, nint systemInformation, uint systemInformationLength, out uint returnLength); [DllImport("ntdll.dll")] private static extern uint NtQueryObject(nint handle, int objectInformationClass, nint objectInformation, int objectInformationLength, out int returnLength); }