From abeb6885ce554b79f9c767d9337e6c8118f85dd9 Mon Sep 17 00:00:00 2001 From: Darth Affe Date: Fri, 1 Sep 2023 01:20:54 +0200 Subject: [PATCH 1/2] Added more low level process-monitoring to reduce allocations and cpu-time --- src/Artemis.Core/Artemis.Core.csproj | 1 + .../Artemis.Core.csproj.DotSettings | 5 +- .../ProcessActivationRequirement.cs | 9 +- src/Artemis.Core/Services/CoreService.cs | 9 +- .../ProcessMonitor/Events/ProcessEventArgs.cs | 20 - .../Interfaces/IProcessMonitorService.cs | 26 -- .../ProcessMonitor/ProcessComparer.cs | 20 - .../ProcessMonitor/ProcessMonitorService.cs | 44 -- .../Events/ProcessEventArgs.cs | 27 ++ .../Services/ProcessMonitoring/ProcessInfo.cs | 25 ++ .../ProcessMonitoring/ProcessMonitor.cs | 377 ++++++++++++++++++ 11 files changed, 441 insertions(+), 122 deletions(-) delete mode 100644 src/Artemis.Core/Services/ProcessMonitor/Events/ProcessEventArgs.cs delete mode 100644 src/Artemis.Core/Services/ProcessMonitor/Interfaces/IProcessMonitorService.cs delete mode 100644 src/Artemis.Core/Services/ProcessMonitor/ProcessComparer.cs delete mode 100644 src/Artemis.Core/Services/ProcessMonitor/ProcessMonitorService.cs create mode 100644 src/Artemis.Core/Services/ProcessMonitoring/Events/ProcessEventArgs.cs create mode 100644 src/Artemis.Core/Services/ProcessMonitoring/ProcessInfo.cs create mode 100644 src/Artemis.Core/Services/ProcessMonitoring/ProcessMonitor.cs diff --git a/src/Artemis.Core/Artemis.Core.csproj b/src/Artemis.Core/Artemis.Core.csproj index 772adf7d2..43c8f65c9 100644 --- a/src/Artemis.Core/Artemis.Core.csproj +++ b/src/Artemis.Core/Artemis.Core.csproj @@ -14,6 +14,7 @@ ArtemisRGB.Core 1 enable + true diff --git a/src/Artemis.Core/Artemis.Core.csproj.DotSettings b/src/Artemis.Core/Artemis.Core.csproj.DotSettings index fe32d7dc1..32817c702 100644 --- a/src/Artemis.Core/Artemis.Core.csproj.DotSettings +++ b/src/Artemis.Core/Artemis.Core.csproj.DotSettings @@ -1,5 +1,4 @@ - + True True True @@ -80,6 +79,8 @@ True True True + True + True True True True diff --git a/src/Artemis.Core/Plugins/Modules/ActivationRequirements/ProcessActivationRequirement.cs b/src/Artemis.Core/Plugins/Modules/ActivationRequirements/ProcessActivationRequirement.cs index 0be74fe7b..b9b4329c0 100644 --- a/src/Artemis.Core/Plugins/Modules/ActivationRequirements/ProcessActivationRequirement.cs +++ b/src/Artemis.Core/Plugins/Modules/ActivationRequirements/ProcessActivationRequirement.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Linq; using Artemis.Core.Services; @@ -36,19 +35,17 @@ public class ProcessActivationRequirement : IModuleActivationRequirement /// public string? Location { get; set; } - internal static IProcessMonitorService? ProcessMonitorService { get; set; } - /// public bool Evaluate() { - if (ProcessMonitorService == null || (ProcessName == null && Location == null)) + if (!ProcessMonitor.IsStarted || (ProcessName == null && Location == null)) return false; - IEnumerable processes = ProcessMonitorService.GetRunningProcesses(); + IEnumerable processes = ProcessMonitor.Processes; if (ProcessName != null) processes = processes.Where(p => string.Equals(p.ProcessName, ProcessName, StringComparison.InvariantCultureIgnoreCase)); if (Location != null) - processes = processes.Where(p => string.Equals(Path.GetDirectoryName(p.GetProcessFilename()), Location, StringComparison.InvariantCultureIgnoreCase)); + processes = processes.Where(p => string.Equals(Path.GetDirectoryName(p.Executable), Location, StringComparison.InvariantCultureIgnoreCase)); return processes.Any(); } diff --git a/src/Artemis.Core/Services/CoreService.cs b/src/Artemis.Core/Services/CoreService.cs index ee1352c8d..137f339b8 100644 --- a/src/Artemis.Core/Services/CoreService.cs +++ b/src/Artemis.Core/Services/CoreService.cs @@ -43,8 +43,7 @@ internal class CoreService : ICoreService IRgbService rgbService, IProfileService profileService, IModuleService moduleService, - IScriptingService scriptingService, - IProcessMonitorService _2) + IScriptingService scriptingService) { Constants.CorePlugin.Container = container; @@ -82,8 +81,8 @@ internal class CoreService : ICoreService string[] parts = argument.Split('='); if (parts.Length == 2 && Enum.TryParse(typeof(LogEventLevel), parts[1], true, out object? logLevelArgument)) { - _logger.Information("Setting logging level to {loggingLevel} from startup argument", (LogEventLevel) logLevelArgument!); - LoggerFactory.LoggingLevelSwitch.MinimumLevel = (LogEventLevel) logLevelArgument; + _logger.Information("Setting logging level to {loggingLevel} from startup argument", (LogEventLevel)logLevelArgument!); + LoggerFactory.LoggingLevelSwitch.MinimumLevel = (LogEventLevel)logLevelArgument; } else { @@ -207,6 +206,8 @@ internal class CoreService : ICoreService ApplyLoggingLevel(); + ProcessMonitor.Start(); + // Don't remove even if it looks useless // Just this line should prevent a certain someone from removing HidSharp as an unused dependency as well Version? hidSharpVersion = Assembly.GetAssembly(typeof(HidDevice))!.GetName().Version; diff --git a/src/Artemis.Core/Services/ProcessMonitor/Events/ProcessEventArgs.cs b/src/Artemis.Core/Services/ProcessMonitor/Events/ProcessEventArgs.cs deleted file mode 100644 index d4c82443e..000000000 --- a/src/Artemis.Core/Services/ProcessMonitor/Events/ProcessEventArgs.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Diagnostics; - -namespace Artemis.Core.Services; - -/// -/// Contains data for the ProcessMonitor process events -/// -public class ProcessEventArgs : EventArgs -{ - internal ProcessEventArgs(Process process) - { - Process = process; - } - - /// - /// Gets the process related to the event - /// - public Process Process { get; } -} \ No newline at end of file diff --git a/src/Artemis.Core/Services/ProcessMonitor/Interfaces/IProcessMonitorService.cs b/src/Artemis.Core/Services/ProcessMonitor/Interfaces/IProcessMonitorService.cs deleted file mode 100644 index e3d7ccf00..000000000 --- a/src/Artemis.Core/Services/ProcessMonitor/Interfaces/IProcessMonitorService.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; - -namespace Artemis.Core.Services; - -/// -/// A service that provides events for started and stopped processes and a list of all running processes. -/// -public interface IProcessMonitorService : IArtemisService -{ - /// - /// Occurs when a process starts. - /// - event EventHandler ProcessStarted; - - /// - /// Occurs when a process stops. - /// - event EventHandler ProcessStopped; - - /// - /// Returns an enumerable with the processes running on the system. - /// - IEnumerable GetRunningProcesses(); -} \ No newline at end of file diff --git a/src/Artemis.Core/Services/ProcessMonitor/ProcessComparer.cs b/src/Artemis.Core/Services/ProcessMonitor/ProcessComparer.cs deleted file mode 100644 index 14ac5e765..000000000 --- a/src/Artemis.Core/Services/ProcessMonitor/ProcessComparer.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections.Generic; -using System.Diagnostics; - -namespace Artemis.Core.Services; - -internal class ProcessComparer : IEqualityComparer -{ - public bool Equals(Process? x, Process? y) - { - if (x == null && y == null) return true; - if (x == null || y == null) return false; - return x.Id == y.Id && x.ProcessName == y.ProcessName && x.SessionId == y.SessionId; - } - - public int GetHashCode(Process? obj) - { - if (obj == null) return 0; - return obj.Id; - } -} \ No newline at end of file diff --git a/src/Artemis.Core/Services/ProcessMonitor/ProcessMonitorService.cs b/src/Artemis.Core/Services/ProcessMonitor/ProcessMonitorService.cs deleted file mode 100644 index fffcc100d..000000000 --- a/src/Artemis.Core/Services/ProcessMonitor/ProcessMonitorService.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Timers; -using Artemis.Core.Modules; - -namespace Artemis.Core.Services; - -internal class ProcessMonitorService : IProcessMonitorService -{ - private readonly ProcessComparer _comparer; - private Process[] _lastScannedProcesses; - - public ProcessMonitorService() - { - _comparer = new ProcessComparer(); - _lastScannedProcesses = Process.GetProcesses(); - Timer processScanTimer = new(1000); - processScanTimer.Elapsed += OnTimerElapsed; - processScanTimer.Start(); - - ProcessActivationRequirement.ProcessMonitorService = this; - } - - private void OnTimerElapsed(object? sender, ElapsedEventArgs e) - { - Process[] newProcesses = Process.GetProcesses(); - foreach (Process startedProcess in newProcesses.Except(_lastScannedProcesses, _comparer)) - ProcessStarted?.Invoke(this, new ProcessEventArgs(startedProcess)); - foreach (Process stoppedProcess in _lastScannedProcesses.Except(newProcesses, _comparer)) - ProcessStopped?.Invoke(this, new ProcessEventArgs(stoppedProcess)); - - _lastScannedProcesses = newProcesses; - } - - public event EventHandler? ProcessStarted; - public event EventHandler? ProcessStopped; - - public IEnumerable GetRunningProcesses() - { - return _lastScannedProcesses; - } -} \ No newline at end of file diff --git a/src/Artemis.Core/Services/ProcessMonitoring/Events/ProcessEventArgs.cs b/src/Artemis.Core/Services/ProcessMonitoring/Events/ProcessEventArgs.cs new file mode 100644 index 000000000..fbacefbce --- /dev/null +++ b/src/Artemis.Core/Services/ProcessMonitoring/Events/ProcessEventArgs.cs @@ -0,0 +1,27 @@ +using System; + +namespace Artemis.Core.Services; + +/// +/// Contains data for the ProcessMonitor process events +/// +public class ProcessEventArgs : EventArgs +{ + #region Properties & Fields + + /// + /// Gets the process info related to the event + /// + public ProcessInfo ProcessInfo { get; } + + #endregion + + #region Constructors + + internal ProcessEventArgs(ProcessInfo processInfo) + { + this.ProcessInfo = processInfo; + } + + #endregion +} \ No newline at end of file diff --git a/src/Artemis.Core/Services/ProcessMonitoring/ProcessInfo.cs b/src/Artemis.Core/Services/ProcessMonitoring/ProcessInfo.cs new file mode 100644 index 000000000..c4cc6de1b --- /dev/null +++ b/src/Artemis.Core/Services/ProcessMonitoring/ProcessInfo.cs @@ -0,0 +1,25 @@ +namespace Artemis.Core.Services; + +public readonly struct ProcessInfo +{ + #region Properties & Fields + + public readonly int ProcessId; + public readonly string ProcessName; + public readonly string ImageName; //TODO DarthAffe 01.09.2023: Do we need this if we can't get it through Process.GetProcesses()? + public readonly string Executable; + + #endregion + + #region Constructors + + public ProcessInfo(int processId, string processName, string imageName, string executable) + { + this.ProcessId = processId; + this.ProcessName = processName; + this.ImageName = imageName; + this.Executable = executable; + } + + #endregion +} \ No newline at end of file diff --git a/src/Artemis.Core/Services/ProcessMonitoring/ProcessMonitor.cs b/src/Artemis.Core/Services/ProcessMonitoring/ProcessMonitor.cs new file mode 100644 index 000000000..b6129cd6e --- /dev/null +++ b/src/Artemis.Core/Services/ProcessMonitoring/ProcessMonitor.cs @@ -0,0 +1,377 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; + +namespace Artemis.Core.Services; + +// DarthAffe 31.08.2023: Based on how it's done in the framework: +// https://github.com/dotnet/runtime/blob/f0463a98d105f26037f9d3e63213421a3a7d4dff/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessManager.Win32.cs#L263 +public static unsafe partial class ProcessMonitor +{ + #region Native + + [LibraryImport("ntdll.dll", EntryPoint = "NtQuerySystemInformation")] + private static partial uint NtQuerySystemInformation(int systemInformationClass, void* systemInformation, uint systemInformationLength, uint* returnLength); + + [StructLayout(LayoutKind.Sequential)] + private struct SystemProcessInformation + { + // ReSharper disable MemberCanBePrivate.Local + public uint NextEntryOffset; + public uint NumberOfThreads; + private fixed byte Reserved1[48]; + public UnicodeString ImageName; + public int BasePriority; + public nint UniqueProcessId; + private readonly nuint Reserved2; + public uint HandleCount; + public uint SessionId; + private readonly nuint Reserved3; + public nuint PeakVirtualSize; // SIZE_T + public nuint VirtualSize; + private readonly uint Reserved4; + public nuint PeakWorkingSetSize; // SIZE_T + public nuint WorkingSetSize; // SIZE_T + private readonly nuint Reserved5; + public nuint QuotaPagedPoolUsage; // SIZE_T + private readonly nuint Reserved6; + public nuint QuotaNonPagedPoolUsage; // SIZE_T + public nuint PagefileUsage; // SIZE_T + public nuint PeakPagefileUsage; // SIZE_T + public nuint PrivatePageCount; // SIZE_T + private fixed long Reserved7[6]; + // ReSharper restore MemberCanBePrivate.Local + } + + [StructLayout(LayoutKind.Sequential)] + private struct UnicodeString + { + // ReSharper disable MemberCanBePrivate.Local + /// + /// Length in bytes, not including the null terminator, if any. + /// + public ushort Length; + + /// + /// Max size of the buffer in bytes + /// + public ushort MaximumLength; + public nint Buffer; + // ReSharper restore MemberCanBePrivate.Local + } + + [LibraryImport("kernel32.dll", EntryPoint = "QueryFullProcessImageNameW", StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool QueryFullProcessImageName(nint hProcess, int dwFlags, [Out] char[] lpExeName, ref int lpdwSize); + + [LibraryImport("kernel32.dll")] + private static partial nint OpenProcess(ProcessAccessFlags processAccess, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle, int processId); + + [Flags] + private enum ProcessAccessFlags : uint + { + QueryLimitedInformation = 0x00001000 + } + + #endregion + + #region Constants + + private const int SYSTEM_PROCESS_INFORMATION = 5; + private const uint STATUS_INFO_LENGTH_MISMATCH = 0xC0000004; + private const int SYSTEM_PROCESS_ID = 4; + private const int IDLE_PROCESS_ID = 0; + private const int DEFAULT_BUFFER_SIZE = 1024 * 1024; + + #endregion + + #region Properties & Fields + + private static readonly object LOCK = new(); + + private static Timer? _timer; + + private static void* _buffer; + private static uint _bufferSize; + + private static Dictionary _processes = new(); + + public static ImmutableArray Processes + { + get + { + lock (LOCK) + return _processes.Values.ToImmutableArray(); + } + } + public static DateTime LastUpdate { get; private set; } + + private static TimeSpan _updateInterval = TimeSpan.FromSeconds(1); + public static TimeSpan UpdateInterval + { + get => _updateInterval; + set + { + _updateInterval = value; + + lock (LOCK) + _timer?.Change(TimeSpan.Zero, _updateInterval); + } + } + + public static bool IsStarted + { + get + { + lock (LOCK) + return _timer != null; + } + } + + #endregion + + #region Events + + public static event EventHandler? ProcessStarted; + public static event EventHandler? ProcessStopped; + + #endregion + + #region Constructors + + static ProcessMonitor() + { + Utilities.ShutdownRequested += (_, _) => Stop(); + } + + #endregion + + #region Methods + + public static void Start() + { + lock (LOCK) + { + if (_timer != null) return; + + _bufferSize = DEFAULT_BUFFER_SIZE; + _buffer = NativeMemory.Alloc(_bufferSize); + + _timer = new Timer(Environment.OSVersion.Platform == PlatformID.Win32NT ? UpdateProcessInfosNative : UpdateProcessInfosSafe, null, TimeSpan.Zero, UpdateInterval); + } + } + + public static void Stop() + { + lock (LOCK) + { + if (_timer == null) return; + + _timer.Dispose(); + + NativeMemory.Free(_buffer); + _bufferSize = 0; + _timer = null; + } + } + + #region Native ProcessInfo Update + + private static void UpdateProcessInfosNative(object? o) + { + lock (LOCK) + { + try + { + if (_bufferSize == 0) return; + + while (true) + { + uint actualSize = 0; + uint status = NtQuerySystemInformation(SYSTEM_PROCESS_INFORMATION, _buffer, _bufferSize, &actualSize); + + if (status != STATUS_INFO_LENGTH_MISMATCH) + { + if ((int)status < 0) + throw new InvalidOperationException("Error", new Win32Exception((int)status)); + + UpdateProcessInfosNative(new ReadOnlySpan(_buffer, (int)actualSize)); + return; + } + + ResizeBuffer(GetEstimatedBufferSize(actualSize)); + } + } + catch { /* Should we throw here? I guess no ... */ } + } + } + + private static void UpdateProcessInfosNative(ReadOnlySpan data) + { + int processInformationOffset = 0; + + HashSet processIds = new(_processes.Count); + + while (true) + { + ref readonly SystemProcessInformation pi = ref MemoryMarshal.AsRef(data[processInformationOffset..]); + + int processId = pi.UniqueProcessId.ToInt32(); + processIds.Add(processId); + + if (!_processes.ContainsKey(processId)) + { + string imageName; + string processName; + if (pi.ImageName.Buffer != nint.Zero) + { + imageName = new ReadOnlySpan(pi.ImageName.Buffer.ToPointer(), pi.ImageName.Length / sizeof(char)).ToString(); + processName = GetProcessShortName(imageName).ToString(); + } + else + { + imageName = string.Empty; + processName = processId switch + { + SYSTEM_PROCESS_ID => "System", + IDLE_PROCESS_ID => "Idle", + _ => processId.ToString(CultureInfo.InvariantCulture) // use the process ID for a normal process without a name + }; + } + + string executable = GetProcessFilename(processId); + ProcessInfo processInfo = new(processId, processName, imageName, executable); + _processes.Add(processId, processInfo); + + OnProcessStarted(processInfo); + } + + if (pi.NextEntryOffset == 0) break; + processInformationOffset += (int)pi.NextEntryOffset; + } + + HandleStoppedProcesses(processIds); + + LastUpdate = DateTime.Now; + } + + private static string GetProcessFilename(int processId) + { + int capacity = byte.MaxValue; + char[] buffer = new char[capacity]; + nint ptr = OpenProcess(ProcessAccessFlags.QueryLimitedInformation, false, processId); + + return QueryFullProcessImageName(ptr, 0, buffer, ref capacity) + ? new string(buffer, 0, capacity) + : string.Empty; + } + + // This function generates the short form of process name. + // + // This is from GetProcessShortName in NT code base. + // Check base\screg\winreg\perfdlls\process\perfsprc.c for details. + private static ReadOnlySpan GetProcessShortName(ReadOnlySpan name) + { + // Trim off everything up to and including the last slash, if there is one. + // If there isn't, LastIndexOf will return -1 and this will end up as a nop. + name = name[(name.LastIndexOf('\\') + 1)..]; + + // If the name ends with the ".exe" extension, then drop it, otherwise include + // it in the name. + if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + name = name[..^4]; + + return name; + } + + private static void ResizeBuffer(uint size) + { + NativeMemory.Free(_buffer); + + _bufferSize = size; + _buffer = NativeMemory.Alloc(_bufferSize); + } + + // allocating a few more kilo bytes just in case there are some new process + // kicked in since new call to NtQuerySystemInformation + private static uint GetEstimatedBufferSize(uint actualSize) => actualSize + (1024 * 10); + + #endregion + + #region Safe ProcessInfo Update + + private static void UpdateProcessInfosSafe(object? o) + { + lock (LOCK) + { + try + { + HashSet processIds = new(_processes.Count); + + foreach (Process process in Process.GetProcesses()) + { + int processId = process.Id; + processIds.Add(processId); + + if (!_processes.ContainsKey(processId)) + { + string imageName = string.Empty; + string processName = process.ProcessName; + string executable = string.Empty; //TODO DarthAffe 01.09.2023: Is there a crossplatform way to do this? + + ProcessInfo processInfo = new(processId, processName, imageName, executable); + _processes.Add(processId, processInfo); + + OnProcessStarted(processInfo); + } + } + + HandleStoppedProcesses(processIds); + + LastUpdate = DateTime.Now; + } + catch { /* Should we throw here? I guess no ... */ } + } + } + + #endregion + + // ReSharper disable once SuggestBaseTypeForParameter + private static void HandleStoppedProcesses(HashSet currentProcessIds) + { + int[] oldProcessIds = _processes.Keys.ToArray(); + foreach (int id in oldProcessIds) + if (!currentProcessIds.Contains(id)) + { + ProcessInfo info = _processes[id]; + _processes.Remove(id); + OnProcessStopped(info); + } + } + + private static void OnProcessStarted(ProcessInfo processInfo) + { + try + { + ProcessStarted?.Invoke(null, new ProcessEventArgs(processInfo)); + } + catch { /* Subscribers are idiots! */ } + } + + private static void OnProcessStopped(ProcessInfo processInfo) + { + try + { + ProcessStopped?.Invoke(null, new ProcessEventArgs(processInfo)); + } + catch { /* Subscribers are idiots! */ } + } + + #endregion +} \ No newline at end of file From ef3e349da4bb937656f348f385df929c57e8b9ee Mon Sep 17 00:00:00 2001 From: Darth Affe Date: Fri, 1 Sep 2023 01:46:44 +0200 Subject: [PATCH 2/2] Splitted the ProcessMonitor to have a cleaner separation of windows-specific and corss-platform code --- .../ProcessMonitoring/ProcessMonitor.Win32.cs | 231 +++++++++++++++ .../ProcessMonitoring/ProcessMonitor.cs | 268 +----------------- .../ProcessMonitor.xPlatform.cs | 46 +++ 3 files changed, 290 insertions(+), 255 deletions(-) create mode 100644 src/Artemis.Core/Services/ProcessMonitoring/ProcessMonitor.Win32.cs create mode 100644 src/Artemis.Core/Services/ProcessMonitoring/ProcessMonitor.xPlatform.cs diff --git a/src/Artemis.Core/Services/ProcessMonitoring/ProcessMonitor.Win32.cs b/src/Artemis.Core/Services/ProcessMonitoring/ProcessMonitor.Win32.cs new file mode 100644 index 000000000..457970df8 --- /dev/null +++ b/src/Artemis.Core/Services/ProcessMonitoring/ProcessMonitor.Win32.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.Runtime.InteropServices; + +namespace Artemis.Core.Services; + +// DarthAffe 31.08.2023: Based on how it's done in the framework: +// https://github.com/dotnet/runtime/blob/f0463a98d105f26037f9d3e63213421a3a7d4dff/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessManager.Win32.cs#L263 +public static unsafe partial class ProcessMonitor +{ + #region Native + + [LibraryImport("ntdll.dll", EntryPoint = "NtQuerySystemInformation")] + private static partial uint NtQuerySystemInformation(int systemInformationClass, void* systemInformation, uint systemInformationLength, uint* returnLength); + + [StructLayout(LayoutKind.Sequential)] + private struct SystemProcessInformation + { + // ReSharper disable MemberCanBePrivate.Local + public uint NextEntryOffset; + public uint NumberOfThreads; + private fixed byte Reserved1[48]; + public UnicodeString ImageName; + public int BasePriority; + public nint UniqueProcessId; + private readonly nuint Reserved2; + public uint HandleCount; + public uint SessionId; + private readonly nuint Reserved3; + public nuint PeakVirtualSize; // SIZE_T + public nuint VirtualSize; + private readonly uint Reserved4; + public nuint PeakWorkingSetSize; // SIZE_T + public nuint WorkingSetSize; // SIZE_T + private readonly nuint Reserved5; + public nuint QuotaPagedPoolUsage; // SIZE_T + private readonly nuint Reserved6; + public nuint QuotaNonPagedPoolUsage; // SIZE_T + public nuint PagefileUsage; // SIZE_T + public nuint PeakPagefileUsage; // SIZE_T + public nuint PrivatePageCount; // SIZE_T + private fixed long Reserved7[6]; + // ReSharper restore MemberCanBePrivate.Local + } + + [StructLayout(LayoutKind.Sequential)] + private struct UnicodeString + { + // ReSharper disable MemberCanBePrivate.Local + /// + /// Length in bytes, not including the null terminator, if any. + /// + public ushort Length; + + /// + /// Max size of the buffer in bytes + /// + public ushort MaximumLength; + public nint Buffer; + // ReSharper restore MemberCanBePrivate.Local + } + + [LibraryImport("kernel32.dll", EntryPoint = "QueryFullProcessImageNameW", StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool QueryFullProcessImageName(nint hProcess, int dwFlags, [Out] char[] lpExeName, ref int lpdwSize); + + [LibraryImport("kernel32.dll")] + private static partial nint OpenProcess(ProcessAccessFlags processAccess, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle, int processId); + + [Flags] + private enum ProcessAccessFlags : uint + { + QueryLimitedInformation = 0x00001000 + } + + #endregion + + #region Constants + + private const int SYSTEM_PROCESS_INFORMATION = 5; + private const uint STATUS_INFO_LENGTH_MISMATCH = 0xC0000004; + private const int SYSTEM_PROCESS_ID = 4; + private const int IDLE_PROCESS_ID = 0; + private const int DEFAULT_BUFFER_SIZE = 1024 * 1024; + + #endregion + + #region Properties & Fields + + private static void* _buffer; + private static uint _bufferSize; + + #endregion + + #region Methods + + private static void InitializeBuffer() + { + _bufferSize = DEFAULT_BUFFER_SIZE; + _buffer = NativeMemory.Alloc(_bufferSize); + } + + private static void FreeBuffer() + { + NativeMemory.Free(_buffer); + _bufferSize = 0; + } + + private static void UpdateProcessInfosWin32(object? o) + { + lock (LOCK) + { + try + { + if (_bufferSize == 0) return; + + while (true) + { + uint actualSize = 0; + uint status = NtQuerySystemInformation(SYSTEM_PROCESS_INFORMATION, _buffer, _bufferSize, &actualSize); + + if (status != STATUS_INFO_LENGTH_MISMATCH) + { + if ((int)status < 0) + throw new InvalidOperationException("Error", new Win32Exception((int)status)); + + UpdateProcessInfosWin32(new ReadOnlySpan(_buffer, (int)actualSize)); + return; + } + + ResizeBuffer(GetEstimatedBufferSize(actualSize)); + } + } + catch { /* Should we throw here? I guess no ... */ } + } + } + + private static void UpdateProcessInfosWin32(ReadOnlySpan data) + { + int processInformationOffset = 0; + + HashSet processIds = new(_processes.Count); + + while (true) + { + ref readonly SystemProcessInformation pi = ref MemoryMarshal.AsRef(data[processInformationOffset..]); + + int processId = pi.UniqueProcessId.ToInt32(); + processIds.Add(processId); + + if (!_processes.ContainsKey(processId)) + { + string imageName; + string processName; + if (pi.ImageName.Buffer != nint.Zero) + { + imageName = new ReadOnlySpan(pi.ImageName.Buffer.ToPointer(), pi.ImageName.Length / sizeof(char)).ToString(); + processName = GetProcessShortName(imageName).ToString(); + } + else + { + imageName = string.Empty; + processName = processId switch + { + SYSTEM_PROCESS_ID => "System", + IDLE_PROCESS_ID => "Idle", + _ => processId.ToString(CultureInfo.InvariantCulture) // use the process ID for a normal process without a name + }; + } + + string executable = GetProcessFilename(processId); + ProcessInfo processInfo = new(processId, processName, imageName, executable); + _processes.Add(processId, processInfo); + + OnProcessStarted(processInfo); + } + + if (pi.NextEntryOffset == 0) break; + processInformationOffset += (int)pi.NextEntryOffset; + } + + HandleStoppedProcesses(processIds); + + LastUpdate = DateTime.Now; + } + + private static string GetProcessFilename(int processId) + { + int capacity = byte.MaxValue; + char[] buffer = new char[capacity]; + nint ptr = OpenProcess(ProcessAccessFlags.QueryLimitedInformation, false, processId); + + return QueryFullProcessImageName(ptr, 0, buffer, ref capacity) + ? new string(buffer, 0, capacity) + : string.Empty; + } + + // This function generates the short form of process name. + // + // This is from GetProcessShortName in NT code base. + // Check base\screg\winreg\perfdlls\process\perfsprc.c for details. + private static ReadOnlySpan GetProcessShortName(ReadOnlySpan name) + { + // Trim off everything up to and including the last slash, if there is one. + // If there isn't, LastIndexOf will return -1 and this will end up as a nop. + name = name[(name.LastIndexOf('\\') + 1)..]; + + // If the name ends with the ".exe" extension, then drop it, otherwise include + // it in the name. + if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + name = name[..^4]; + + return name; + } + + private static void ResizeBuffer(uint size) + { + NativeMemory.Free(_buffer); + + _bufferSize = size; + _buffer = NativeMemory.Alloc(_bufferSize); + } + + // allocating a few more kilo bytes just in case there are some new process + // kicked in since new call to NtQuerySystemInformation + private static uint GetEstimatedBufferSize(uint actualSize) => actualSize + (1024 * 10); + + #endregion +} \ No newline at end of file diff --git a/src/Artemis.Core/Services/ProcessMonitoring/ProcessMonitor.cs b/src/Artemis.Core/Services/ProcessMonitoring/ProcessMonitor.cs index b6129cd6e..ee265e90d 100644 --- a/src/Artemis.Core/Services/ProcessMonitoring/ProcessMonitor.cs +++ b/src/Artemis.Core/Services/ProcessMonitoring/ProcessMonitor.cs @@ -1,105 +1,19 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; -using System.ComponentModel; -using System.Diagnostics; -using System.Globalization; using System.Linq; -using System.Runtime.InteropServices; using System.Threading; namespace Artemis.Core.Services; -// DarthAffe 31.08.2023: Based on how it's done in the framework: -// https://github.com/dotnet/runtime/blob/f0463a98d105f26037f9d3e63213421a3a7d4dff/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessManager.Win32.cs#L263 -public static unsafe partial class ProcessMonitor +public static partial class ProcessMonitor { - #region Native - - [LibraryImport("ntdll.dll", EntryPoint = "NtQuerySystemInformation")] - private static partial uint NtQuerySystemInformation(int systemInformationClass, void* systemInformation, uint systemInformationLength, uint* returnLength); - - [StructLayout(LayoutKind.Sequential)] - private struct SystemProcessInformation - { - // ReSharper disable MemberCanBePrivate.Local - public uint NextEntryOffset; - public uint NumberOfThreads; - private fixed byte Reserved1[48]; - public UnicodeString ImageName; - public int BasePriority; - public nint UniqueProcessId; - private readonly nuint Reserved2; - public uint HandleCount; - public uint SessionId; - private readonly nuint Reserved3; - public nuint PeakVirtualSize; // SIZE_T - public nuint VirtualSize; - private readonly uint Reserved4; - public nuint PeakWorkingSetSize; // SIZE_T - public nuint WorkingSetSize; // SIZE_T - private readonly nuint Reserved5; - public nuint QuotaPagedPoolUsage; // SIZE_T - private readonly nuint Reserved6; - public nuint QuotaNonPagedPoolUsage; // SIZE_T - public nuint PagefileUsage; // SIZE_T - public nuint PeakPagefileUsage; // SIZE_T - public nuint PrivatePageCount; // SIZE_T - private fixed long Reserved7[6]; - // ReSharper restore MemberCanBePrivate.Local - } - - [StructLayout(LayoutKind.Sequential)] - private struct UnicodeString - { - // ReSharper disable MemberCanBePrivate.Local - /// - /// Length in bytes, not including the null terminator, if any. - /// - public ushort Length; - - /// - /// Max size of the buffer in bytes - /// - public ushort MaximumLength; - public nint Buffer; - // ReSharper restore MemberCanBePrivate.Local - } - - [LibraryImport("kernel32.dll", EntryPoint = "QueryFullProcessImageNameW", StringMarshalling = StringMarshalling.Utf16)] - [return: MarshalAs(UnmanagedType.Bool)] - private static partial bool QueryFullProcessImageName(nint hProcess, int dwFlags, [Out] char[] lpExeName, ref int lpdwSize); - - [LibraryImport("kernel32.dll")] - private static partial nint OpenProcess(ProcessAccessFlags processAccess, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle, int processId); - - [Flags] - private enum ProcessAccessFlags : uint - { - QueryLimitedInformation = 0x00001000 - } - - #endregion - - #region Constants - - private const int SYSTEM_PROCESS_INFORMATION = 5; - private const uint STATUS_INFO_LENGTH_MISMATCH = 0xC0000004; - private const int SYSTEM_PROCESS_ID = 4; - private const int IDLE_PROCESS_ID = 0; - private const int DEFAULT_BUFFER_SIZE = 1024 * 1024; - - #endregion - #region Properties & Fields private static readonly object LOCK = new(); private static Timer? _timer; - private static void* _buffer; - private static uint _bufferSize; - private static Dictionary _processes = new(); public static ImmutableArray Processes @@ -160,10 +74,15 @@ public static unsafe partial class ProcessMonitor { if (_timer != null) return; - _bufferSize = DEFAULT_BUFFER_SIZE; - _buffer = NativeMemory.Alloc(_bufferSize); - - _timer = new Timer(Environment.OSVersion.Platform == PlatformID.Win32NT ? UpdateProcessInfosNative : UpdateProcessInfosSafe, null, TimeSpan.Zero, UpdateInterval); + if (Environment.OSVersion.Platform == PlatformID.Win32NT) + { + InitializeBuffer(); + _timer = new Timer(UpdateProcessInfosWin32, null, TimeSpan.Zero, UpdateInterval); + } + else + { + _timer = new Timer(UpdateProcessInfosCrossPlatform, null, TimeSpan.Zero, UpdateInterval); + } } } @@ -174,174 +93,13 @@ public static unsafe partial class ProcessMonitor if (_timer == null) return; _timer.Dispose(); - - NativeMemory.Free(_buffer); - _bufferSize = 0; _timer = null; + + if (Environment.OSVersion.Platform == PlatformID.Win32NT) + FreeBuffer(); } } - #region Native ProcessInfo Update - - private static void UpdateProcessInfosNative(object? o) - { - lock (LOCK) - { - try - { - if (_bufferSize == 0) return; - - while (true) - { - uint actualSize = 0; - uint status = NtQuerySystemInformation(SYSTEM_PROCESS_INFORMATION, _buffer, _bufferSize, &actualSize); - - if (status != STATUS_INFO_LENGTH_MISMATCH) - { - if ((int)status < 0) - throw new InvalidOperationException("Error", new Win32Exception((int)status)); - - UpdateProcessInfosNative(new ReadOnlySpan(_buffer, (int)actualSize)); - return; - } - - ResizeBuffer(GetEstimatedBufferSize(actualSize)); - } - } - catch { /* Should we throw here? I guess no ... */ } - } - } - - private static void UpdateProcessInfosNative(ReadOnlySpan data) - { - int processInformationOffset = 0; - - HashSet processIds = new(_processes.Count); - - while (true) - { - ref readonly SystemProcessInformation pi = ref MemoryMarshal.AsRef(data[processInformationOffset..]); - - int processId = pi.UniqueProcessId.ToInt32(); - processIds.Add(processId); - - if (!_processes.ContainsKey(processId)) - { - string imageName; - string processName; - if (pi.ImageName.Buffer != nint.Zero) - { - imageName = new ReadOnlySpan(pi.ImageName.Buffer.ToPointer(), pi.ImageName.Length / sizeof(char)).ToString(); - processName = GetProcessShortName(imageName).ToString(); - } - else - { - imageName = string.Empty; - processName = processId switch - { - SYSTEM_PROCESS_ID => "System", - IDLE_PROCESS_ID => "Idle", - _ => processId.ToString(CultureInfo.InvariantCulture) // use the process ID for a normal process without a name - }; - } - - string executable = GetProcessFilename(processId); - ProcessInfo processInfo = new(processId, processName, imageName, executable); - _processes.Add(processId, processInfo); - - OnProcessStarted(processInfo); - } - - if (pi.NextEntryOffset == 0) break; - processInformationOffset += (int)pi.NextEntryOffset; - } - - HandleStoppedProcesses(processIds); - - LastUpdate = DateTime.Now; - } - - private static string GetProcessFilename(int processId) - { - int capacity = byte.MaxValue; - char[] buffer = new char[capacity]; - nint ptr = OpenProcess(ProcessAccessFlags.QueryLimitedInformation, false, processId); - - return QueryFullProcessImageName(ptr, 0, buffer, ref capacity) - ? new string(buffer, 0, capacity) - : string.Empty; - } - - // This function generates the short form of process name. - // - // This is from GetProcessShortName in NT code base. - // Check base\screg\winreg\perfdlls\process\perfsprc.c for details. - private static ReadOnlySpan GetProcessShortName(ReadOnlySpan name) - { - // Trim off everything up to and including the last slash, if there is one. - // If there isn't, LastIndexOf will return -1 and this will end up as a nop. - name = name[(name.LastIndexOf('\\') + 1)..]; - - // If the name ends with the ".exe" extension, then drop it, otherwise include - // it in the name. - if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) - name = name[..^4]; - - return name; - } - - private static void ResizeBuffer(uint size) - { - NativeMemory.Free(_buffer); - - _bufferSize = size; - _buffer = NativeMemory.Alloc(_bufferSize); - } - - // allocating a few more kilo bytes just in case there are some new process - // kicked in since new call to NtQuerySystemInformation - private static uint GetEstimatedBufferSize(uint actualSize) => actualSize + (1024 * 10); - - #endregion - - #region Safe ProcessInfo Update - - private static void UpdateProcessInfosSafe(object? o) - { - lock (LOCK) - { - try - { - HashSet processIds = new(_processes.Count); - - foreach (Process process in Process.GetProcesses()) - { - int processId = process.Id; - processIds.Add(processId); - - if (!_processes.ContainsKey(processId)) - { - string imageName = string.Empty; - string processName = process.ProcessName; - string executable = string.Empty; //TODO DarthAffe 01.09.2023: Is there a crossplatform way to do this? - - ProcessInfo processInfo = new(processId, processName, imageName, executable); - _processes.Add(processId, processInfo); - - OnProcessStarted(processInfo); - } - } - - HandleStoppedProcesses(processIds); - - LastUpdate = DateTime.Now; - } - catch { /* Should we throw here? I guess no ... */ } - } - } - - #endregion - // ReSharper disable once SuggestBaseTypeForParameter private static void HandleStoppedProcesses(HashSet currentProcessIds) { diff --git a/src/Artemis.Core/Services/ProcessMonitoring/ProcessMonitor.xPlatform.cs b/src/Artemis.Core/Services/ProcessMonitoring/ProcessMonitor.xPlatform.cs new file mode 100644 index 000000000..90f72bed6 --- /dev/null +++ b/src/Artemis.Core/Services/ProcessMonitoring/ProcessMonitor.xPlatform.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace Artemis.Core.Services; + +public static partial class ProcessMonitor +{ + #region Methods + + private static void UpdateProcessInfosCrossPlatform(object? o) + { + lock (LOCK) + { + try + { + HashSet processIds = new(_processes.Count); + + foreach (Process process in Process.GetProcesses()) + { + int processId = process.Id; + processIds.Add(processId); + + if (!_processes.ContainsKey(processId)) + { + string imageName = string.Empty; + string processName = process.ProcessName; + string executable = string.Empty; //TODO DarthAffe 01.09.2023: Is there a crossplatform way to do this? + + ProcessInfo processInfo = new(processId, processName, imageName, executable); + _processes.Add(processId, processInfo); + + OnProcessStarted(processInfo); + } + } + + HandleStoppedProcesses(processIds); + + LastUpdate = DateTime.Now; + } + catch { /* Should we throw here? I guess no ... */ } + } + } + + #endregion +} \ No newline at end of file