RenderAsync: IViewBufferScope Index was out of range when running RemoveAt

103 views Asked by At

I using IView to render some view's as string that is passed as email to someone. It runs without problem.99.9% of the time (dotnet 6)

Here is a simplistic example of what is going one there

using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Routing;

namespace MyRazor.Namespace
{
    public class MyRazor : IMyRazor
    {
        private readonly IRazorViewEngine _viewEngine;
        private readonly ITempDataProvider _tempDataProvider;
        private readonly IServiceProvider _serviceProvider;

        public MyRazor(
            IRazorViewEngine viewEngine,
            ITempDataProvider tempDataProvider,
            IServiceProvider serviceProvider)
        {
            _viewEngine = viewEngine;
            _tempDataProvider = tempDataProvider;
            _serviceProvider = serviceProvider;
        }

        public async Task<string> RenderViewToStringAsync<TModel>(string viewPath, TModel model,
            Action<dynamic> initializeViewBag = null)
        {
            ActionContext actionContext = new ActionContext(new DefaultHttpContext 
                { 
                    RequestServices = _serviceProvider 
                }, 
                new RouteData(), 
                new ActionDescriptor());

            IView view = _viewEngine.FindView(actionContext, viewPath, isMainPage: false).View;

            using (StringWriter writer = new StringWriter())
            {
                ViewContext viewContext = new ViewContext(
                    actionContext,
                    view,
                    new ViewDataDictionary<TModel>(
                        metadataProvider: new EmptyModelMetadataProvider(),
                        modelState: new ModelStateDictionary())
                    {
                        Model = model,
                    },
                    new TempDataDictionary(
                        actionContext.HttpContext,
                        _tempDataProvider),
                    writer,
                    new HtmlHelperOptions());

                initializeViewBag?.Invoke(viewContext.ViewBag);
                await view.RenderAsync(viewContext);

                return writer.ToString();
            }
        }
    }
}

IMyRazor is registered as transient. And sometime (3 or 4 times a day) I get

System.ArgumentOutOfRangeException: Index was out of range. Must be non-negative and less than the size of the collection. (Parameter 'index') at System.Collections.Generic.List`1.RemoveAt(Int32 index) at Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers.MemoryPoolViewBufferScope.GetPage(Int32 pageSize) at Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers.ViewBuffer.AppendNewPage()

Thing is that I cannot replicate this issue, at least not from the tests, my assumption is that IViewBufferScope is scoped and because IMyRazor is transient when I run some parallels tasks to IMyRazor.RenderViewToStringAsync it happens the interference between the two or more view contexts (transients) running on same scope.

Does someone have an idea how to force https://source.dot.net/#Microsoft.AspNetCore.Mvc.ViewFeatures/Buffers/MemoryPoolViewBufferScope.cs,fbb617c90a2880ec to fail as described above?

My test does the following (similar with what is on remote):

// xunit
        [Fact]
        public async Task TestFailure()
        {
            using (IServiceScope scope = testFixture.TestServer.Host.Services.CreateScope())
            {

                // Microsoft.AspNetCore.Mvc.ViewFeatures.Buffers.IViewBufferScope is scoped
                // IMyRazor is transient 
                IMyRazor builder = scope.ServiceProvider.GetRequiredService<IMyRazor>();

                List<Task<string>> tasks = new List<Task<string>>();
                tasks.Add(builder.RenderViewToStringAsync("test", new { }));
                tasks.Add(builder.RenderViewToStringAsync("test", new { }));
                tasks.Add(builder.RenderViewToStringAsync("test", new { }));
                tasks.Add(builder.RenderViewToStringAsync("test", new { }));
                tasks.Add(builder.RenderViewToStringAsync("test", new { }));
                tasks.Add(builder.RenderViewToStringAsync("test", new { }));
                tasks.Add(builder.RenderViewToStringAsync("test", new { }));
                tasks.Add(builder.RenderViewToStringAsync("test", new { }));
                tasks.Add(builder.RenderViewToStringAsync("test", new { }));
                tasks.Add(builder.RenderViewToStringAsync("test", new { }));
                tasks.Add(builder.RenderViewToStringAsync("test", new { }));
                tasks.Add(builder.RenderViewToStringAsync("test", new { }));
                tasks.Add(builder.RenderViewToStringAsync("test", new { }));
                tasks.Add(builder.RenderViewToStringAsync("test", new { }));
                tasks.Add(builder.RenderViewToStringAsync("test", new { }));
                tasks.Add(builder.RenderViewToStringAsync("test", new { }));
                tasks.Add(builder.RenderViewToStringAsync("test2", payload));

                await Task.WhenAll(tasks);

                foreach (var task in tasks)
                {
                    Assert.True(task.IsCompletedSuccessfully);
                }
            }
        }
1

There are 1 answers

0
SilentTremor On

I admit, I'm unable to write a test that causes the memory leak, caused by IViewBufferScope not being thread safe.

For who ever stumbles on same problem, it is explained here what is going on: https://github.com/aspnet/Mvc/issues/5106. Even if is for asp.net not dotnet (core), looks like the concept of one view build per scope remains unchanged even today.

Here are two solutions:

  1. don't do more than one render at the same time per scope

  2. get a scope for each each render async invocation, see above utility adapted to one view render per scope

     namespace MyRazor.Namespace
     {
         public class MyRazor : IMyRazor
         {
             private readonly IServiceProvider serviceProvider;
    
             public MyRazor(IServiceProvider serviceProvider)
             {
                 this.serviceProvider = serviceProvider;
             }
    
             public async Task<string> RenderViewToStringAsync<TModel>(string viewPath, TModel model,
                 Action<dynamic> initializeViewBag = null)
             {
                 IServiceScopeFactory serviceScopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
                 // new scope
                 using (IServiceScope scope = serviceScopeFactory.CreateScope())
                 {
    
                     IRazorViewEngine viewEngine = scope.ServiceProvider.GetRequiredService<IRazorViewEngine>();
                     ITempDataProvider tempDataProvider = scope.ServiceProvider.GetRequiredService<ITempDataProvider>();
                     ActionContext actionContext = new ActionContext(new DefaultHttpContext
                     {
                         RequestServices = serviceProvider
                     },
                     new RouteData(),
                     new ActionDescriptor());
    
    
                     IView view = viewEngine.FindView(actionContext, viewPath, isMainPage: false).View;
    
                     using (StringWriter writer = new StringWriter())
                     {
                         ViewContext viewContext = new ViewContext(
                             actionContext,
                             view,
                             new ViewDataDictionary<TModel>(
                                 metadataProvider: new EmptyModelMetadataProvider(),
                                 modelState: new ModelStateDictionary())
                             {
                                 Model = model,
                             },
                             new TempDataDictionary(
                                 actionContext.HttpContext,
                                 tempDataProvider),
                             writer,
                             new HtmlHelperOptions());
    
                         initializeViewBag?.Invoke(viewContext.ViewBag);
                         await view.RenderAsync(viewContext);
    
                         return writer.ToString();
                     }
                 }
             }
         }
     }