protected void ExecuteOnDispatcher(Func <Task> action) { if (!SingleThreadedTestSynchronizationContext.IsSingleThreadedSyncContext(SynchronizationContext.Current)) { SynchronizationContext.SetSynchronizationContext(SingleThreadedTestSynchronizationContext.New()); } SingleThreadedTestSynchronizationContext.IFrame?frame = SingleThreadedTestSynchronizationContext.NewFrame(); Exception?failure = null; SynchronizationContext.Current !.Post( async _ => { try { await action(); } catch (Exception ex) { failure = ex; } finally { frame.Continue = false; } }, null); SingleThreadedTestSynchronizationContext.PushFrame(SynchronizationContext.Current, frame); if (failure is object) { ExceptionDispatchInfo.Capture(failure).Throw(); } }
/// <summary> /// Runs a given scenario many times to observe memory characteristics and assert that they can satisfy given conditions. /// </summary> /// <param name="scenario">The delegate to invoke.</param> /// <param name="maxBytesAllocated">The maximum number of bytes allowed to be allocated by one run of the scenario. Use -1 to indicate no limit.</param> /// <param name="iterations">The number of times to invoke <paramref name="scenario"/> in a row before measuring average memory impact.</param> /// <param name="allowedAttempts">The number of times the (scenario * iterations) loop repeats with a failing result before ultimately giving up.</param> /// <param name="completeSynchronously"><c>true</c> to synchronously complete instead of yielding.</param> /// <returns>A task that captures the result of the operation.</returns> protected async Task CheckGCPressureAsync(Func <Task> scenario, int maxBytesAllocated, int iterations = 100, int allowedAttempts = GCAllocationAttempts, bool completeSynchronously = false) { const int quietPeriodMaxAttempts = 3; const int shortDelayDuration = 250; const int quietThreshold = 1200; // prime the pump for (int i = 0; i < 2; i++) { await MaybeShouldBeComplete(scenario(), completeSynchronously); } long waitForQuietMemory1, waitForQuietMemory2, waitPeriodAllocations, waitForQuietAttemptCount = 0; do { waitForQuietMemory1 = GC.GetTotalMemory(true); await MaybeShouldBlock(Task.Delay(shortDelayDuration), completeSynchronously); waitForQuietMemory2 = GC.GetTotalMemory(true); waitPeriodAllocations = Math.Abs(waitForQuietMemory2 - waitForQuietMemory1); this.Logger.WriteLine("Bytes allocated during quiet wait period: {0}", waitPeriodAllocations); }while (waitPeriodAllocations > quietThreshold || ++waitForQuietAttemptCount >= quietPeriodMaxAttempts); if (waitPeriodAllocations > quietThreshold) { this.Logger.WriteLine("WARNING: Unable to establish a quiet period."); } // This test is rather rough. So we're willing to try it a few times in order to observe the desired value. bool attemptWithNoLeakObserved = false; bool attemptWithinMemoryLimitsObserved = false; for (int attempt = 1; attempt <= allowedAttempts; attempt++) { this.Logger?.WriteLine("Iteration {0}", attempt); int[] gcCountBefore = new int[GC.MaxGeneration + 1]; int[] gcCountAfter = new int[GC.MaxGeneration + 1]; long initialMemory = GC.GetTotalMemory(true); GC.TryStartNoGCRegion(8 * 1024 * 1024); for (int i = 0; i <= GC.MaxGeneration; i++) { gcCountBefore[i] = GC.CollectionCount(i); } for (int i = 0; i < iterations; i++) { await MaybeShouldBeComplete(scenario(), completeSynchronously); } for (int i = 0; i < gcCountAfter.Length; i++) { gcCountAfter[i] = GC.CollectionCount(i); } long allocated = (GC.GetTotalMemory(false) - initialMemory) / iterations; GC.EndNoGCRegion(); attemptWithinMemoryLimitsObserved |= maxBytesAllocated == -1 || allocated <= maxBytesAllocated; long leaked = long.MaxValue; for (int leakCheck = 0; leakCheck < 3; leakCheck++) { // Allow the message queue to drain. if (completeSynchronously) { // If there is a dispatcher sync context, let it run for a bit. // This allows any posted messages that are now obsolete to be released. if (SingleThreadedTestSynchronizationContext.IsSingleThreadedSyncContext(SynchronizationContext.Current)) { SingleThreadedTestSynchronizationContext.IFrame?frame = SingleThreadedTestSynchronizationContext.NewFrame(); SynchronizationContext.Current.Post(state => frame.Continue = false, null); SingleThreadedTestSynchronizationContext.PushFrame(SynchronizationContext.Current, frame); } } else { await Task.Yield(); } leaked = (GC.GetTotalMemory(true) - initialMemory) / iterations; attemptWithNoLeakObserved |= leaked <= 3 * IntPtr.Size; // any real leak would be an object, which is at least this size. if (attemptWithNoLeakObserved) { break; } await MaybeShouldBlock(Task.Delay(shortDelayDuration), completeSynchronously); } this.Logger?.WriteLine("{0} bytes leaked per iteration.", leaked); this.Logger?.WriteLine("{0} bytes allocated per iteration ({1} allowed).", allocated, maxBytesAllocated); for (int i = 0; i <= GC.MaxGeneration; i++) { Assert.False(gcCountAfter[i] > gcCountBefore[i], $"WARNING: Gen {i} GC occurred {gcCountAfter[i] - gcCountBefore[i]} times during testing. Results are probably totally wrong."); } if (attemptWithNoLeakObserved && attemptWithinMemoryLimitsObserved) { // Don't keep looping. We got what we needed. break; } // give the system a bit of cool down time to increase the odds we'll pass next time. GC.Collect(); await MaybeShouldBlock(Task.Delay(shortDelayDuration), completeSynchronously); } Assert.True(attemptWithNoLeakObserved, "Leaks observed in every iteration."); Assert.True(attemptWithinMemoryLimitsObserved, "Excess memory allocations in every iteration."); }