diff --git a/src/App.xaml.cs b/src/App.xaml.cs index 8a26a165..1d23d17e 100644 --- a/src/App.xaml.cs +++ b/src/App.xaml.cs @@ -598,26 +598,43 @@ private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) /// public static void ReleaseMemory() { - // Garbage Collector - try - { - GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced); - GC.WaitForPendingFinalizers(); - GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced); - } - catch - { - // ignored - } + ReleaseMemory + ( + ReleaseMemoryMode.Combined, + () => + { + GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced); + GC.WaitForPendingFinalizers(); + GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced); + }, + () => NativeMethods.EmptyWorkingSet(Process.GetCurrentProcess().Handle) + ); + } - // Optimize App Working Set - try + internal static void ReleaseMemory(ReleaseMemoryMode mode, Action collectGarbage, Action trimWorkingSet) + { + if (mode == ReleaseMemoryMode.GCOnly || mode == ReleaseMemoryMode.Combined) { - NativeMethods.EmptyWorkingSet(Process.GetCurrentProcess().Handle); + try + { + collectGarbage(); + } + catch + { + // ignored + } } - catch (Exception) + + if (mode == ReleaseMemoryMode.TrimOnly || mode == ReleaseMemoryMode.Combined) { - // ignored + try + { + trimWorkingSet(); + } + catch + { + // ignored + } } } diff --git a/src/Core/OptimizationTiming.cs b/src/Core/OptimizationTiming.cs new file mode 100644 index 00000000..47c9d4fa --- /dev/null +++ b/src/Core/OptimizationTiming.cs @@ -0,0 +1,68 @@ +using System; + +namespace WinMemoryCleaner +{ + /// + /// Provides a deterministic seam for measuring optimization completion. + /// + internal sealed class OptimizationTiming + { + private readonly Func _clock; + private readonly TimeSpan _startedAt; + + internal OptimizationTiming(Func clock) + { + if (clock == null) + throw new ArgumentNullException("clock"); + + _clock = clock; + _startedAt = _clock(); + } + + internal TimeSpan FinalAppReleaseDuration { get; private set; } + + internal TimeSpan Elapsed + { + get { return _clock().Subtract(_startedAt); } + } + + internal TimeSpan Measure(Action stage) + { + if (stage == null) + throw new ArgumentNullException("stage"); + + var startedAt = _clock(); + + stage(); + + return _clock().Subtract(startedAt); + } + + internal TimeSpan Complete(Action finalAppRelease) + { + if (finalAppRelease == null) + throw new ArgumentNullException("finalAppRelease"); + + var finalAppReleaseStartedAt = _clock(); + + try + { + finalAppRelease(); + } + finally + { + FinalAppReleaseDuration = _clock().Subtract(finalAppReleaseStartedAt); + } + + return Elapsed; + } + } + + internal enum ReleaseMemoryMode + { + None, + GCOnly, + TrimOnly, + Combined + } +} diff --git a/src/Service/ComputerService.cs b/src/Service/ComputerService.cs index a4ca5319..ee2f6b5e 100644 --- a/src/Service/ComputerService.cs +++ b/src/Service/ComputerService.cs @@ -149,10 +149,10 @@ public void Optimize(Enums.Memory.Optimization.Reason reason, Enums.Memory.Areas if (areas == Enums.Memory.Areas.None) return; - var errorRuntime = new TimeSpan(); - var infoRuntime = new TimeSpan(); var optimizationReason = reason.GetString(); var stopwatch = new Stopwatch(); + var totalStopwatch = Stopwatch.StartNew(); + var optimizationTiming = new OptimizationTiming(() => totalStopwatch.Elapsed); var value = (byte)0; var error = new LogOptimizationData { Reason = optimizationReason }; @@ -178,8 +178,6 @@ public void Optimize(Enums.Memory.Optimization.Reason reason, Enums.Memory.Areas Name = Localizer.String.WorkingSet, Duration = string.Format(Localizer.Culture, "{0:0.0} {1}", stopwatch.Elapsed.TotalSeconds, Localizer.String.Seconds.ToLower(Localizer.Culture)) }); - - infoRuntime = infoRuntime.Add(stopwatch.Elapsed); } catch (Exception e) { @@ -189,8 +187,6 @@ public void Optimize(Enums.Memory.Optimization.Reason reason, Enums.Memory.Areas Duration = string.Format(Localizer.Culture, "{0:0.0} {1}", stopwatch.Elapsed.TotalSeconds, Localizer.String.Seconds.ToLower(Localizer.Culture)), Error = e.GetMessage() }); - - errorRuntime = errorRuntime.Add(stopwatch.Elapsed); } } @@ -214,8 +210,6 @@ public void Optimize(Enums.Memory.Optimization.Reason reason, Enums.Memory.Areas Name = Localizer.String.SystemFileCache, Duration = string.Format(Localizer.Culture, "{0:0.0} {1}", stopwatch.Elapsed.TotalSeconds, Localizer.String.Seconds.ToLower(Localizer.Culture)) }); - - infoRuntime = infoRuntime.Add(stopwatch.Elapsed); } catch (Exception e) { @@ -225,8 +219,6 @@ public void Optimize(Enums.Memory.Optimization.Reason reason, Enums.Memory.Areas Duration = string.Format(Localizer.Culture, "{0:0.0} {1}", stopwatch.Elapsed.TotalSeconds, Localizer.String.Seconds.ToLower(Localizer.Culture)), Error = e.GetMessage() }); - - errorRuntime = errorRuntime.Add(stopwatch.Elapsed); } } @@ -250,8 +242,6 @@ public void Optimize(Enums.Memory.Optimization.Reason reason, Enums.Memory.Areas Name = Localizer.String.ModifiedPageList, Duration = string.Format(Localizer.Culture, "{0:0.0} {1}", stopwatch.Elapsed.TotalSeconds, Localizer.String.Seconds.ToLower(Localizer.Culture)) }); - - infoRuntime = infoRuntime.Add(stopwatch.Elapsed); } catch (Exception e) { @@ -261,8 +251,6 @@ public void Optimize(Enums.Memory.Optimization.Reason reason, Enums.Memory.Areas Duration = string.Format(Localizer.Culture, "{0:0.0} {1}", stopwatch.Elapsed.TotalSeconds, Localizer.String.Seconds.ToLower(Localizer.Culture)), Error = e.GetMessage() }); - - errorRuntime = errorRuntime.Add(stopwatch.Elapsed); } } @@ -289,8 +277,6 @@ public void Optimize(Enums.Memory.Optimization.Reason reason, Enums.Memory.Areas Name = standbyList, Duration = string.Format(Localizer.Culture, "{0:0.0} {1}", stopwatch.Elapsed.TotalSeconds, Localizer.String.Seconds.ToLower(Localizer.Culture)) }); - - infoRuntime = infoRuntime.Add(stopwatch.Elapsed); } catch (Exception e) { @@ -300,8 +286,6 @@ public void Optimize(Enums.Memory.Optimization.Reason reason, Enums.Memory.Areas Duration = string.Format(Localizer.Culture, "{0:0.0} {1}", stopwatch.Elapsed.TotalSeconds, Localizer.String.Seconds.ToLower(Localizer.Culture)), Error = e.GetMessage() }); - - errorRuntime = errorRuntime.Add(stopwatch.Elapsed); } } @@ -325,8 +309,6 @@ public void Optimize(Enums.Memory.Optimization.Reason reason, Enums.Memory.Areas Name = Localizer.String.CombinedPageList, Duration = string.Format(Localizer.Culture, "{0:0.0} {1}", stopwatch.Elapsed.TotalSeconds, Localizer.String.Seconds.ToLower(Localizer.Culture)) }); - - infoRuntime = infoRuntime.Add(stopwatch.Elapsed); } catch (Exception e) { @@ -336,8 +318,6 @@ public void Optimize(Enums.Memory.Optimization.Reason reason, Enums.Memory.Areas Duration = string.Format(Localizer.Culture, "{0:0.0} {1}", stopwatch.Elapsed.TotalSeconds, Localizer.String.Seconds.ToLower(Localizer.Culture)), Error = e.GetMessage() }); - - errorRuntime = errorRuntime.Add(stopwatch.Elapsed); } } @@ -361,8 +341,6 @@ public void Optimize(Enums.Memory.Optimization.Reason reason, Enums.Memory.Areas Name = Localizer.String.RegistryCache, Duration = string.Format(Localizer.Culture, "{0:0.0} {1}", stopwatch.Elapsed.TotalSeconds, Localizer.String.Seconds.ToLower(Localizer.Culture)) }); - - infoRuntime = infoRuntime.Add(stopwatch.Elapsed); } catch (Exception e) { @@ -372,8 +350,6 @@ public void Optimize(Enums.Memory.Optimization.Reason reason, Enums.Memory.Areas Duration = string.Format(Localizer.Culture, "{0:0.0} {1}", stopwatch.Elapsed.TotalSeconds, Localizer.String.Seconds.ToLower(Localizer.Culture)), Error = e.GetMessage() }); - - errorRuntime = errorRuntime.Add(stopwatch.Elapsed); } } @@ -397,8 +373,6 @@ public void Optimize(Enums.Memory.Optimization.Reason reason, Enums.Memory.Areas Name = Localizer.String.ModifiedFileCache, Duration = string.Format(Localizer.Culture, "{0:0.0} {1}", stopwatch.Elapsed.TotalSeconds, Localizer.String.Seconds.ToLower(Localizer.Culture)) }); - - infoRuntime = infoRuntime.Add(stopwatch.Elapsed); } catch (Exception e) { @@ -408,8 +382,6 @@ public void Optimize(Enums.Memory.Optimization.Reason reason, Enums.Memory.Areas Duration = string.Format(Localizer.Culture, "{0:0.0} {1}", stopwatch.Elapsed.TotalSeconds, Localizer.String.Seconds.ToLower(Localizer.Culture)), Error = e.GetMessage() }); - - errorRuntime = errorRuntime.Add(stopwatch.Elapsed); } } @@ -422,27 +394,42 @@ public void Optimize(Enums.Memory.Optimization.Reason reason, Enums.Memory.Areas OnOptimizeProgressUpdate(value, Localizer.String.GarbageCollector); } - App.ReleaseMemory(); + optimizationTiming.Complete(App.ReleaseMemory); + + var appReleaseTiming = new LogOptimizationDataMemoryArea + { + Name = Localizer.String.GarbageCollector, + Duration = string.Format(Localizer.Culture, "{0:0.0} {1}", optimizationTiming.FinalAppReleaseDuration.TotalSeconds, Localizer.String.Seconds.ToLower(Localizer.Culture)) + }; + + // Include final-stage timing without turning failed native work into a success notification. + if (info.MemoryAreas.Any()) + info.MemoryAreas.Add(appReleaseTiming); + + if (error.MemoryAreas.Any()) + error.MemoryAreas.Add(appReleaseTiming); } catch { // ignored } + var totalRuntime = optimizationTiming.Elapsed; + // Log try { // Info if (info.MemoryAreas.Any()) { - info.Duration = string.Format(Localizer.Culture, "{0:0.0} {1}", infoRuntime.TotalSeconds, Localizer.String.Seconds.ToLower(Localizer.Culture)); + info.Duration = string.Format(Localizer.Culture, "{0:0.0} {1}", totalRuntime.TotalSeconds, Localizer.String.Seconds.ToLower(Localizer.Culture)); Logger.Log(new Log(Enums.Log.Levels.Information, Localizer.String.MemoryOptimized, info)); } // Error if (error.MemoryAreas.Any()) { - error.Duration = string.Format(Localizer.Culture, "{0:0.0} {1}", errorRuntime.TotalSeconds, Localizer.String.Seconds.ToLower(Localizer.Culture)); + error.Duration = string.Format(Localizer.Culture, "{0:0.0} {1}", totalRuntime.TotalSeconds, Localizer.String.Seconds.ToLower(Localizer.Culture)); Logger.Log(new Log(Enums.Log.Levels.Error, Localizer.String.Invalid, error)); } diff --git a/src/Test/ReleaseMemoryTests.cs b/src/Test/ReleaseMemoryTests.cs new file mode 100644 index 00000000..67b3eb63 --- /dev/null +++ b/src/Test/ReleaseMemoryTests.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using NUnit.Framework; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +namespace WinMemoryCleaner.MemoryReleaseTests +{ + [TestFixture] + public sealed class ReleaseMemoryTests + { + [Test] + public void ReleaseMemory_WhenModeIsNone_DoesNotRunCleanupStages() + { + var calls = new List(); + + App.ReleaseMemory( + ReleaseMemoryMode.None, + () => calls.Add("GC"), + () => calls.Add("Trim")); + + Assert.AreEqual(0, calls.Count); + } + + [Test] + public void ReleaseMemory_WhenModeIsGCOnly_RunsOnlyGarbageCollection() + { + var calls = new List(); + + App.ReleaseMemory( + ReleaseMemoryMode.GCOnly, + () => calls.Add("GC"), + () => calls.Add("Trim")); + + CollectionAssert.AreEqual(new[] { "GC" }, calls); + } + + [Test] + public void ReleaseMemory_WhenModeIsTrimOnly_RunsOnlyWorkingSetTrim() + { + var calls = new List(); + + App.ReleaseMemory( + ReleaseMemoryMode.TrimOnly, + () => calls.Add("GC"), + () => calls.Add("Trim")); + + CollectionAssert.AreEqual(new[] { "Trim" }, calls); + } + + [Test] + public void ReleaseMemory_WhenModeIsCombined_RunsBothCleanupStages() + { + var calls = new List(); + + App.ReleaseMemory( + ReleaseMemoryMode.Combined, + () => calls.Add("GC"), + () => calls.Add("Trim")); + + CollectionAssert.AreEqual(new[] { "GC", "Trim" }, calls); + } + + [Test] + public void ReleaseMemory_WhenCombined_RunsGarbageCollectionBeforeWorkingSetTrim() + { + var calls = new List(); + + App.ReleaseMemory( + ReleaseMemoryMode.Combined, + () => calls.Add("GC"), + () => calls.Add("Trim")); + + Assert.Less(calls.IndexOf("GC"), calls.IndexOf("Trim")); + } + + [Test] + public void ReleaseMemory_WhenGarbageCollectionThrows_StillRunsWorkingSetTrim() + { + var calls = new List(); + + App.ReleaseMemory( + ReleaseMemoryMode.Combined, + () => + { + calls.Add("GC"); + throw new InvalidOperationException(); + }, + () => calls.Add("Trim")); + + CollectionAssert.AreEqual(new[] { "GC", "Trim" }, calls); + } + + [Test] + public void ReleaseMemory_WhenWorkingSetTrimThrows_DoesNotPreventGarbageCollection() + { + var calls = new List(); + + App.ReleaseMemory( + ReleaseMemoryMode.Combined, + () => calls.Add("GC"), + () => + { + calls.Add("Trim"); + throw new InvalidOperationException(); + }); + + CollectionAssert.AreEqual(new[] { "GC", "Trim" }, calls); + } + + [Test] + public void OptimizeTiming_WhenFinalAppReleaseRuns_IncludesItInTotalDuration() + { + var calls = new List(); + var clock = new FakeClock(); + var timing = new OptimizationTiming(clock.Now); + + timing.Measure(() => + { + calls.Add("Native"); + clock.Advance(TimeSpan.FromSeconds(2)); + }); + + var totalRuntime = timing.Complete(() => + { + calls.Add("AppRelease"); + clock.Advance(TimeSpan.FromSeconds(3)); + }); + + CollectionAssert.AreEqual(new[] { "Native", "AppRelease" }, calls); + Assert.AreEqual(TimeSpan.FromSeconds(3), timing.FinalAppReleaseDuration); + Assert.AreEqual(TimeSpan.FromSeconds(5), totalRuntime); + } + + [Test] + public void OptimizeTiming_WhenNativeStageFailsAndOverheadRuns_IncludesAllElapsedTime() + { + var clock = new FakeClock(TimeSpan.FromSeconds(100)); + var timing = new OptimizationTiming(clock.Now); + + Assert.Throws(() => timing.Measure(() => + { + clock.Advance(TimeSpan.FromSeconds(2)); + throw new InvalidOperationException(); + })); + + clock.Advance(TimeSpan.FromSeconds(1)); + + var totalRuntime = timing.Complete(() => clock.Advance(TimeSpan.FromSeconds(3))); + + Assert.AreEqual(TimeSpan.FromSeconds(6), totalRuntime); + } + + [Test] + public void OptimizeTiming_WhenFinalAppReleaseThrows_CapturesItsDuration() + { + var clock = new FakeClock(); + var timing = new OptimizationTiming(clock.Now); + + clock.Advance(TimeSpan.FromSeconds(1)); + + Assert.Throws(() => timing.Complete(() => + { + clock.Advance(TimeSpan.FromSeconds(2)); + throw new InvalidOperationException(); + })); + + Assert.AreEqual(TimeSpan.FromSeconds(2), timing.FinalAppReleaseDuration); + Assert.AreEqual(TimeSpan.FromSeconds(3), timing.Elapsed); + } + + private sealed class FakeClock + { + private TimeSpan _now; + + public FakeClock(TimeSpan? initialTime = null) + { + _now = initialTime ?? TimeSpan.Zero; + } + + public void Advance(TimeSpan duration) + { + _now = _now.Add(duration); + } + + public TimeSpan Now() + { + return _now; + } + } + } +} + +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member diff --git a/src/WinMemoryCleaner.csproj b/src/WinMemoryCleaner.csproj index 6bd06c24..2e2b415c 100644 --- a/src/WinMemoryCleaner.csproj +++ b/src/WinMemoryCleaner.csproj @@ -128,6 +128,7 @@ + @@ -163,6 +164,7 @@ +