diff --git a/Platforms/Windows/IProcessMemoryPriorityNativeApi.cs b/Platforms/Windows/IProcessMemoryPriorityNativeApi.cs index 1511d3c..e1ef5c5 100644 --- a/Platforms/Windows/IProcessMemoryPriorityNativeApi.cs +++ b/Platforms/Windows/IProcessMemoryPriorityNativeApi.cs @@ -34,6 +34,8 @@ int QueryIoPriority(SafeProcessHandle process, ref int priority, out uint return } int SetIoPriority(SafeProcessHandle process, ref int priority) => unchecked((int)0xC0000002); + + IDisposable? AcquirePrivilegeScope(string privilegeName) => null; } public sealed class ProcessMemoryPriorityNativeApi : IProcessMemoryPriorityNativeApi @@ -96,6 +98,9 @@ public int SetIoPriority(SafeProcessHandle process, ref int priority) => ProcessMemoryPriorityNativeMethods.ProcessIoPriority, ref priority, sizeof(int)); + + public IDisposable? AcquirePrivilegeScope(string privilegeName) => + ProcessMemoryPriorityNativeMethods.AcquirePrivilegeScope(privilegeName); } public enum ProcessInformationClass diff --git a/Platforms/Windows/ProcessMemoryPriorityNativeMethods.cs b/Platforms/Windows/ProcessMemoryPriorityNativeMethods.cs index d0ba944..6970f27 100644 --- a/Platforms/Windows/ProcessMemoryPriorityNativeMethods.cs +++ b/Platforms/Windows/ProcessMemoryPriorityNativeMethods.cs @@ -3,12 +3,42 @@ */ namespace ThreadPilot.Platforms.Windows { + using System; using System.Runtime.InteropServices; using Microsoft.Win32.SafeHandles; internal static partial class ProcessMemoryPriorityNativeMethods { internal const int ProcessIoPriority = 33; + internal const string SeIncreaseBasePriorityPrivilege = "SeIncreaseBasePriorityPrivilege"; + private const uint TokenAdjustPrivileges = 0x0020; + private const uint TokenQuery = 0x0008; + private const uint SePrivilegeEnabled = 0x00000002; + private const uint TokenPrivilegesInformationClass = 3; + private static readonly object PrivilegeLock = new(); + private static readonly System.Collections.Generic.Dictionary ActivePrivileges = new(StringComparer.OrdinalIgnoreCase); + + [StructLayout(LayoutKind.Sequential)] + internal struct Luid + { + public uint LowPart; + public int HighPart; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct LuidAndAttributes + { + public Luid Luid; + public uint Attributes; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct TokenPrivileges + { + public uint PrivilegeCount; + public Luid Luid; + public uint Attributes; + } [LibraryImport("kernel32.dll", SetLastError = true)] public static partial SafeProcessHandle OpenProcess( @@ -46,5 +76,264 @@ public static partial int NtSetInformationProcess( int processInformationClass, ref int processInformation, int processInformationLength); + + [LibraryImport("kernel32.dll")] + public static partial IntPtr GetCurrentProcess(); + + [LibraryImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool OpenProcessToken( + IntPtr processHandle, + uint desiredAccess, + out SafeAccessTokenHandle tokenHandle); + + [LibraryImport("advapi32.dll", EntryPoint = "LookupPrivilegeValueW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool LookupPrivilegeValue( + string? lpSystemName, + string lpName, + out Luid lpLuid); + + [LibraryImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool AdjustTokenPrivileges( + SafeAccessTokenHandle tokenHandle, + [MarshalAs(UnmanagedType.Bool)] bool disableAllPrivileges, + ref TokenPrivileges newState, + uint bufferLength, + out TokenPrivileges previousState, + out uint returnLength); + + [LibraryImport("advapi32.dll", EntryPoint = "AdjustTokenPrivileges", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool AdjustTokenPrivileges( + SafeAccessTokenHandle tokenHandle, + [MarshalAs(UnmanagedType.Bool)] bool disableAllPrivileges, + ref TokenPrivileges newState, + uint bufferLength, + IntPtr previousState, + IntPtr returnLength); + + [LibraryImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool GetTokenInformation( + SafeAccessTokenHandle tokenHandle, + uint tokenInformationClass, + IntPtr tokenInformation, + uint tokenInformationLength, + out uint returnLength); + + internal static IDisposable AcquirePrivilegeScope(string privilegeName) + { + if (string.IsNullOrWhiteSpace(privilegeName)) + { + return EmptyDisposable.Instance; + } + + lock (PrivilegeLock) + { + if (ActivePrivileges.TryGetValue(privilegeName, out var existingState)) + { + existingState.RefCount++; + return new ScopedPrivilege(existingState); + } + + if (!OpenProcessToken(GetCurrentProcess(), TokenAdjustPrivileges | TokenQuery, out var tokenHandle)) + { + return EmptyDisposable.Instance; + } + + using (tokenHandle) + { + if (tokenHandle.IsInvalid) + { + return EmptyDisposable.Instance; + } + + if (!LookupPrivilegeValue(null, privilegeName, out var luid)) + { + return EmptyDisposable.Instance; + } + + var tp = new TokenPrivileges + { + PrivilegeCount = 1, + Luid = luid, + Attributes = SePrivilegeEnabled, + }; + + bool adjusted = AdjustTokenPrivileges( + tokenHandle, + false, + ref tp, + (uint)Marshal.SizeOf(), + out var previousState, + out _); + + int lastError = Marshal.GetLastWin32Error(); + if (!adjusted || lastError != 0) + { + return EmptyDisposable.Instance; + } + + var newState = new PrivilegeState(privilegeName, previousState); + ActivePrivileges[privilegeName] = newState; + return new ScopedPrivilege(newState); + } + } + } + + internal static bool TryGetPrivilegeState(string privilegeName, out bool isPresent, out bool isEnabled) + { + isPresent = false; + isEnabled = false; + + if (string.IsNullOrWhiteSpace(privilegeName)) + { + return false; + } + + if (!OpenProcessToken(GetCurrentProcess(), TokenQuery, out var tokenHandle)) + { + return false; + } + + using (tokenHandle) + { + if (tokenHandle.IsInvalid) + { + return false; + } + + if (!LookupPrivilegeValue(null, privilegeName, out var targetLuid)) + { + return false; + } + + GetTokenInformation(tokenHandle, TokenPrivilegesInformationClass, IntPtr.Zero, 0, out uint requiredLength); + if (requiredLength == 0) + { + return false; + } + + IntPtr buffer = Marshal.AllocHGlobal((int)requiredLength); + try + { + if (!GetTokenInformation(tokenHandle, TokenPrivilegesInformationClass, buffer, requiredLength, out _)) + { + return false; + } + + int count = Marshal.ReadInt32(buffer); + int offset = 4; + for (int i = 0; i < count; i++) + { + var item = Marshal.PtrToStructure(IntPtr.Add(buffer, offset)); + if (item.Luid.LowPart == targetLuid.LowPart && item.Luid.HighPart == targetLuid.HighPart) + { + isPresent = true; + isEnabled = (item.Attributes & SePrivilegeEnabled) != 0; + return true; + } + + offset += Marshal.SizeOf(); + } + + return false; + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + } + + internal static bool IsPrivilegeEnabled(string privilegeName) + { + return TryGetPrivilegeState(privilegeName, out var isPresent, out var isEnabled) && isPresent && isEnabled; + } + + internal static bool IsPrivilegePresent(string privilegeName) + { + return TryGetPrivilegeState(privilegeName, out var isPresent, out _) && isPresent; + } + + private sealed class PrivilegeState + { + public PrivilegeState(string privilegeName, TokenPrivileges previousState) + { + this.PrivilegeName = privilegeName; + this.PreviousState = previousState; + this.RefCount = 1; + } + + public string PrivilegeName { get; } + + public TokenPrivileges PreviousState { get; } + + public int RefCount { get; set; } + + public void Restore() + { + if (this.PreviousState.PrivilegeCount > 0) + { + if (OpenProcessToken(GetCurrentProcess(), TokenAdjustPrivileges | TokenQuery, out var tokenHandle)) + { + using (tokenHandle) + { + if (!tokenHandle.IsInvalid) + { + var previous = this.PreviousState; + AdjustTokenPrivileges( + tokenHandle, + false, + ref previous, + 0, + IntPtr.Zero, + IntPtr.Zero); + } + } + } + } + } + } + + private sealed class ScopedPrivilege : IDisposable + { + private readonly PrivilegeState state; + private int disposed; + + public ScopedPrivilege(PrivilegeState state) + { + this.state = state; + } + + public void Dispose() + { + if (Interlocked.Exchange(ref this.disposed, 1) != 0) + { + return; + } + + lock (PrivilegeLock) + { + this.state.RefCount--; + if (this.state.RefCount <= 0) + { + ActivePrivileges.Remove(this.state.PrivilegeName); + this.state.Restore(); + } + } + } + } + + private sealed class EmptyDisposable : IDisposable + { + public static EmptyDisposable Instance { get; } = new(); + + public void Dispose() + { + } + } } } diff --git a/Services/ProcessMemoryPriorityService.cs b/Services/ProcessMemoryPriorityService.cs index 592d9bd..e712ec3 100644 --- a/Services/ProcessMemoryPriorityService.cs +++ b/Services/ProcessMemoryPriorityService.cs @@ -244,7 +244,22 @@ public Task SetIoPriorityAsync(ProcessModel process, Pro } var value = (int)priority; - var status = this.nativeApi.SetIoPriority(handle, ref value); + int status; + IDisposable? privilegeScope = null; + if (priority == ProcessIoPriority.High) + { + privilegeScope = this.nativeApi.AcquirePrivilegeScope(ProcessMemoryPriorityNativeMethods.SeIncreaseBasePriorityPrivilege); + } + + try + { + status = this.nativeApi.SetIoPriority(handle, ref value); + } + finally + { + privilegeScope?.Dispose(); + } + if (status == 0) { return Task.FromResult(ProcessOperationResult.Succeeded( @@ -254,7 +269,7 @@ public Task SetIoPriorityAsync(ProcessModel process, Pro return Task.FromResult(status switch { - unchecked((int)0xC0000022) => ProcessOperationResult.Failed( + unchecked((int)0xC0000022) or unchecked((int)0xC0000061) => ProcessOperationResult.Failed( AffinityApplyErrorCodes.AccessDenied, ProcessOperationUserMessages.AccessDenied, $"NtSetInformationProcess failed with NTSTATUS 0x{status:X8}.", diff --git a/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityNativeIntegrationTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityNativeIntegrationTests.cs new file mode 100644 index 0000000..2950ac4 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityNativeIntegrationTests.cs @@ -0,0 +1,243 @@ +/* + * ThreadPilot - Windows process memory priority native integration tests. + */ +namespace ThreadPilot.Core.Tests +{ + using System; + using System.Runtime.Versioning; + using System.Threading; + using ThreadPilot.Platforms.Windows; + + [SupportedOSPlatform("windows10.0.22000.0")] + public sealed class ProcessMemoryPriorityNativeIntegrationTests + { + [Fact] + public void AcquirePrivilegeScope_WithSeIncreaseBasePriorityPrivilege_RestoresOriginalState() + { + const string privilegeName = ProcessMemoryPriorityNativeMethods.SeIncreaseBasePriorityPrivilege; + bool originalEnabled = ProcessMemoryPriorityNativeMethods.IsPrivilegeEnabled(privilegeName); + + using (var scope = ProcessMemoryPriorityNativeMethods.AcquirePrivilegeScope(privilegeName)) + { + Assert.NotNull(scope); + if (ProcessMemoryPriorityNativeMethods.IsPrivilegePresent(privilegeName)) + { + Assert.True(ProcessMemoryPriorityNativeMethods.IsPrivilegeEnabled(privilegeName)); + } + } + + bool restoredEnabled = ProcessMemoryPriorityNativeMethods.IsPrivilegeEnabled(privilegeName); + Assert.Equal(originalEnabled, restoredEnabled); + } + + [Fact] + public void AcquirePrivilegeScope_WithPresentPrivilege_EnablesDuringScopeAndRestoresAfterDisposal() + { + const string privilegeName = "SeShutdownPrivilege"; + if (!ProcessMemoryPriorityNativeMethods.IsPrivilegePresent(privilegeName)) + { + return; + } + + bool originalEnabled = ProcessMemoryPriorityNativeMethods.IsPrivilegeEnabled(privilegeName); + + using (var scope = ProcessMemoryPriorityNativeMethods.AcquirePrivilegeScope(privilegeName)) + { + Assert.NotNull(scope); + Assert.True(ProcessMemoryPriorityNativeMethods.IsPrivilegeEnabled(privilegeName)); + } + + bool restoredEnabled = ProcessMemoryPriorityNativeMethods.IsPrivilegeEnabled(privilegeName); + Assert.Equal(originalEnabled, restoredEnabled); + } + + [Fact] + public void AcquirePrivilegeScope_WithOverlappingCallers_KeepsPrivilegeUntilLastScopeDisposes() + { + const string privilegeName = "SeShutdownPrivilege"; + if (!ProcessMemoryPriorityNativeMethods.IsPrivilegePresent(privilegeName)) + { + return; + } + + bool originalEnabled = ProcessMemoryPriorityNativeMethods.IsPrivilegeEnabled(privilegeName); + + using (var outerScope = ProcessMemoryPriorityNativeMethods.AcquirePrivilegeScope(privilegeName)) + { + Assert.NotNull(outerScope); + Assert.True(ProcessMemoryPriorityNativeMethods.IsPrivilegeEnabled(privilegeName)); + + using (var innerScope = ProcessMemoryPriorityNativeMethods.AcquirePrivilegeScope(privilegeName)) + { + Assert.NotNull(innerScope); + Assert.True(ProcessMemoryPriorityNativeMethods.IsPrivilegeEnabled(privilegeName)); + } + + // Inner scope disposed, but outer scope still active: + // Privilege must remain enabled for overlapping callers. + Assert.True(ProcessMemoryPriorityNativeMethods.IsPrivilegeEnabled(privilegeName)); + } + + // Both scopes disposed: privilege must be restored to original state. + bool finalEnabled = ProcessMemoryPriorityNativeMethods.IsPrivilegeEnabled(privilegeName); + Assert.Equal(originalEnabled, finalEnabled); + } + + [Fact] + public void AcquirePrivilegeScope_WithConcurrentThreads_MaintainsPrivilegeUntilFinalDisposal() + { + const string privilegeName = "SeShutdownPrivilege"; + if (!ProcessMemoryPriorityNativeMethods.IsPrivilegePresent(privilegeName)) + { + return; + } + + bool originalEnabled = ProcessMemoryPriorityNativeMethods.IsPrivilegeEnabled(privilegeName); + + using var bothActiveBarrier = new Barrier(2); + using var thread1DisposedSignal = new ManualResetEventSlim(false); + + Exception? thread1Error = null; + Exception? thread2Error = null; + + var thread1 = new Thread(() => + { + try + { + using (var scope1 = ProcessMemoryPriorityNativeMethods.AcquirePrivilegeScope(privilegeName)) + { + Assert.NotNull(scope1); + Assert.True(ProcessMemoryPriorityNativeMethods.IsPrivilegeEnabled(privilegeName)); + + // Wait until thread 2 also acquires its scope + bothActiveBarrier.SignalAndWait(); + } + + // Scope 1 disposed here + thread1DisposedSignal.Set(); + } + catch (Exception ex) + { + thread1Error = ex; + thread1DisposedSignal.Set(); + } + }); + + var thread2 = new Thread(() => + { + try + { + using (var scope2 = ProcessMemoryPriorityNativeMethods.AcquirePrivilegeScope(privilegeName)) + { + Assert.NotNull(scope2); + Assert.True(ProcessMemoryPriorityNativeMethods.IsPrivilegeEnabled(privilegeName)); + + // Wait until thread 1 also acquires its scope + bothActiveBarrier.SignalAndWait(); + + // Wait until thread 1 has disposed its scope + Assert.True(thread1DisposedSignal.Wait(TimeSpan.FromSeconds(5))); + + // Assert privilege remains enabled while thread 2's scope is still active + Assert.True(ProcessMemoryPriorityNativeMethods.IsPrivilegeEnabled(privilegeName)); + } + } + catch (Exception ex) + { + thread2Error = ex; + } + }); + + thread1.Start(); + thread2.Start(); + + Assert.True(thread1.Join(TimeSpan.FromSeconds(10))); + Assert.True(thread2.Join(TimeSpan.FromSeconds(10))); + + Assert.Null(thread1Error); + Assert.Null(thread2Error); + + // Both threads have disposed their scopes: confirm restored to original state + bool finalEnabled = ProcessMemoryPriorityNativeMethods.IsPrivilegeEnabled(privilegeName); + Assert.Equal(originalEnabled, finalEnabled); + } + + [Fact] + public void AcquirePrivilegeScope_WithNullOrWhitespace_ReturnsSafeDisposableWithoutThrowing() + { + using (var nullScope = ProcessMemoryPriorityNativeMethods.AcquirePrivilegeScope(null!)) + { + Assert.NotNull(nullScope); + } + + using (var emptyScope = ProcessMemoryPriorityNativeMethods.AcquirePrivilegeScope(string.Empty)) + { + Assert.NotNull(emptyScope); + } + + using (var whitespaceScope = ProcessMemoryPriorityNativeMethods.AcquirePrivilegeScope(" ")) + { + Assert.NotNull(whitespaceScope); + } + } + + [Fact] + public void AcquirePrivilegeScope_WithNonExistentPrivilege_ReturnsSafeDisposableWithoutThrowing() + { + using var scope = ProcessMemoryPriorityNativeMethods.AcquirePrivilegeScope("SeNonExistentPrivilegeThatDoesNotExist"); + Assert.NotNull(scope); + } + + [Fact] + public void ProcessMemoryPriorityNativeApi_Instance_ProvidesWorkingPrivilegeScopeAndMethods() + { + var api = ProcessMemoryPriorityNativeApi.Instance; + Assert.NotNull(api); + Assert.True(api.IsSupported); + + using var scope = api.AcquirePrivilegeScope(ProcessMemoryPriorityNativeMethods.SeIncreaseBasePriorityPrivilege); + Assert.NotNull(scope); + } + + [Fact] + public void ProcessMemoryPriorityNativeApi_DefaultInterfaceMethods_ReturnSafeDefaults() + { + IProcessMemoryPriorityNativeApi defaultApi = new MinimalTestNativeApi(); + + int priority = 0; + int queryResult = defaultApi.QueryIoPriority(null!, ref priority, out uint returnLength); + Assert.Equal(unchecked((int)0xC0000002), queryResult); + Assert.Equal(0u, returnLength); + + int setResult = defaultApi.SetIoPriority(null!, ref priority); + Assert.Equal(unchecked((int)0xC0000002), setResult); + + var scope = defaultApi.AcquirePrivilegeScope("TestPrivilege"); + Assert.Null(scope); + } + + private sealed class MinimalTestNativeApi : IProcessMemoryPriorityNativeApi + { + public bool IsSupported => true; + + public Microsoft.Win32.SafeHandles.SafeProcessHandle OpenProcess( + ProcessAccessFlags access, + bool inheritHandle, + uint processId) => new(IntPtr.Zero, ownsHandle: false); + + public bool GetProcessInformation( + Microsoft.Win32.SafeHandles.SafeProcessHandle process, + ProcessInformationClass processInformationClass, + ref MemoryPriorityInformation processInformation, + uint processInformationSize) => false; + + public bool SetProcessInformation( + Microsoft.Win32.SafeHandles.SafeProcessHandle process, + ProcessInformationClass processInformationClass, + ref MemoryPriorityInformation processInformation, + uint processInformationSize) => false; + + public int GetLastWin32Error() => 0; + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs index 13414cb..7f9af56 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs @@ -60,6 +60,59 @@ public async Task IoPriority_WithValidProcess_RoundTripsThroughNativeApi() Assert.Equal(ProcessIoPriority.High, nativeApi.LastSetIoPriority); } + [Fact] + public async Task SetIoPriorityAsync_WhenStatusPrivilegeNotHeld_ReturnsSafeAccessDeniedFailure() + { + var nativeApi = new FakeProcessMemoryPriorityNativeApi + { + SetIoPriorityStatus = unchecked((int)0xC0000061), + }; + var service = CreateService(nativeApi); + var process = CreateProcess(); + + var result = await service.SetIoPriorityAsync(process, ProcessIoPriority.High); + + Assert.False(result.Success); + Assert.True(result.IsAccessDenied); + Assert.Equal(AffinityApplyErrorCodes.AccessDenied, result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage); + Assert.Contains("0xC0000061", result.TechnicalMessage); + } + + [Fact] + public async Task SetIoPriorityAsync_WhenHighPriority_AcquiresAndDisposesPrivilegeScope() + { + var nativeApi = new FakeProcessMemoryPriorityNativeApi(); + var service = CreateService(nativeApi); + var process = CreateProcess(); + + var result = await service.SetIoPriorityAsync(process, ProcessIoPriority.High); + + Assert.True(result.Success); + Assert.Contains(ProcessMemoryPriorityNativeMethods.SeIncreaseBasePriorityPrivilege, nativeApi.RequestedPrivileges); + Assert.Equal(0, nativeApi.ActivePrivilegeScopesCount); + Assert.Equal(1, nativeApi.TotalDisposedPrivilegeScopesCount); + } + + [Fact] + public async Task SetIoPriorityAsync_WhenHighPriorityAndSetFails_StillDisposesPrivilegeScope() + { + var nativeApi = new FakeProcessMemoryPriorityNativeApi + { + SetIoPriorityStatus = unchecked((int)0xC0000061), + }; + var service = CreateService(nativeApi); + var process = CreateProcess(); + + var result = await service.SetIoPriorityAsync(process, ProcessIoPriority.High); + + Assert.False(result.Success); + Assert.True(result.IsAccessDenied); + Assert.Contains(ProcessMemoryPriorityNativeMethods.SeIncreaseBasePriorityPrivilege, nativeApi.RequestedPrivileges); + Assert.Equal(0, nativeApi.ActivePrivilegeScopesCount); + Assert.Equal(1, nativeApi.TotalDisposedPrivilegeScopesCount); + } + [Theory] [InlineData(1, ProcessMemoryPriority.VeryLow)] [InlineData(2, ProcessMemoryPriority.Low)] @@ -228,6 +281,14 @@ private sealed class FakeProcessMemoryPriorityNativeApi : IProcessMemoryPriority public int LastError { get; init; } + public int SetIoPriorityStatus { get; init; } + + public List RequestedPrivileges { get; } = new(); + + public int ActivePrivilegeScopesCount { get; private set; } + + public int TotalDisposedPrivilegeScopesCount { get; private set; } + public SafeProcessHandle OpenProcess(ProcessAccessFlags access, bool inheritHandle, uint processId) { this.LastOpenAccess = access; @@ -276,7 +337,38 @@ public int QueryIoPriority(SafeProcessHandle process, ref int priority, out uint public int SetIoPriority(SafeProcessHandle process, ref int priority) { this.LastSetIoPriority = (ProcessIoPriority)priority; - return 0; + return this.SetIoPriorityStatus; + } + + public IDisposable AcquirePrivilegeScope(string privilegeName) + { + this.RequestedPrivileges.Add(privilegeName); + this.ActivePrivilegeScopesCount++; + return new FakePrivilegeScope(() => + { + this.ActivePrivilegeScopesCount--; + this.TotalDisposedPrivilegeScopesCount++; + }); + } + + private sealed class FakePrivilegeScope : IDisposable + { + private readonly Action onDispose; + private bool disposed; + + public FakePrivilegeScope(Action onDispose) + { + this.onDispose = onDispose; + } + + public void Dispose() + { + if (!this.disposed) + { + this.disposed = true; + this.onDispose(); + } + } } } }