Skip to content

Commit b10c8a6

Browse files
authored
Eagerly read IAsyncEnumerable{object} instances before formatting (#11118)
* Eagerly read IAsyncEnumerable{object} instances before formatting Fixes #4833
1 parent 550710f commit b10c8a6

32 files changed

+727
-74
lines changed

src/Mvc/Mvc.Core/ref/Microsoft.AspNetCore.Mvc.Core.netcoreapp3.0.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,7 @@ public MvcOptions() { }
875875
public Microsoft.AspNetCore.Mvc.Filters.FilterCollection Filters { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
876876
public Microsoft.AspNetCore.Mvc.Formatters.FormatterMappings FormatterMappings { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
877877
public Microsoft.AspNetCore.Mvc.Formatters.FormatterCollection<Microsoft.AspNetCore.Mvc.Formatters.IInputFormatter> InputFormatters { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
878+
public int MaxIAsyncEnumerableBufferLimit { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
878879
public int MaxModelBindingCollectionSize { get { throw null; } set { } }
879880
public int MaxModelBindingRecursionDepth { get { throw null; } set { } }
880881
public int MaxModelValidationErrors { get { throw null; } set { } }
@@ -2086,7 +2087,9 @@ public MvcCompatibilityOptions() { }
20862087
}
20872088
public partial class ObjectResultExecutor : Microsoft.AspNetCore.Mvc.Infrastructure.IActionResultExecutor<Microsoft.AspNetCore.Mvc.ObjectResult>
20882089
{
2090+
[System.ObsoleteAttribute("This constructor is obsolete and will be removed in a future release.")]
20892091
public ObjectResultExecutor(Microsoft.AspNetCore.Mvc.Infrastructure.OutputFormatterSelector formatterSelector, Microsoft.AspNetCore.Mvc.Infrastructure.IHttpResponseStreamWriterFactory writerFactory, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory) { }
2092+
public ObjectResultExecutor(Microsoft.AspNetCore.Mvc.Infrastructure.OutputFormatterSelector formatterSelector, Microsoft.AspNetCore.Mvc.Infrastructure.IHttpResponseStreamWriterFactory writerFactory, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory, Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Mvc.MvcOptions> mvcOptions) { }
20902093
protected Microsoft.AspNetCore.Mvc.Infrastructure.OutputFormatterSelector FormatterSelector { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
20912094
protected Microsoft.Extensions.Logging.ILogger Logger { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
20922095
protected System.Func<System.IO.Stream, System.Text.Encoding, System.IO.TextWriter> WriterFactory { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections;
6+
using System.Collections.Concurrent;
7+
using System.Collections.Generic;
8+
using System.Diagnostics;
9+
using System.Reflection;
10+
using System.Threading.Tasks;
11+
using Microsoft.AspNetCore.Mvc.Core;
12+
using Microsoft.Extensions.Internal;
13+
14+
#if JSONNET
15+
namespace Microsoft.AspNetCore.Mvc.NewtonsoftJson
16+
#else
17+
namespace Microsoft.AspNetCore.Mvc.Infrastructure
18+
#endif
19+
{
20+
using ReaderFunc = Func<IAsyncEnumerable<object>, Task<ICollection>>;
21+
22+
/// <summary>
23+
/// Type that reads an <see cref="IAsyncEnumerable{T}"/> instance into a
24+
/// generic collection instance.
25+
/// </summary>
26+
/// <remarks>
27+
/// This type is used to create a strongly typed synchronous <see cref="ICollection{T}"/> instance from
28+
/// an <see cref="IAsyncEnumerable{T}"/>. An accurate <see cref="ICollection{T}"/> is required for XML formatters to
29+
/// correctly serialize.
30+
/// </remarks>
31+
internal sealed class AsyncEnumerableReader
32+
{
33+
private readonly MethodInfo Converter = typeof(AsyncEnumerableReader).GetMethod(
34+
nameof(ReadInternal),
35+
BindingFlags.NonPublic | BindingFlags.Instance);
36+
37+
private readonly ConcurrentDictionary<Type, ReaderFunc> _asyncEnumerableConverters =
38+
new ConcurrentDictionary<Type, ReaderFunc>();
39+
private readonly MvcOptions _mvcOptions;
40+
41+
/// <summary>
42+
/// Initializes a new instance of <see cref="AsyncEnumerableReader"/>.
43+
/// </summary>
44+
/// <param name="mvcOptions">Accessor to <see cref="MvcOptions"/>.</param>
45+
public AsyncEnumerableReader(MvcOptions mvcOptions)
46+
{
47+
_mvcOptions = mvcOptions;
48+
}
49+
50+
/// <summary>
51+
/// Reads a <see cref="IAsyncEnumerable{T}"/> into an <see cref="ICollection{T}"/>.
52+
/// </summary>
53+
/// <param name="value">The <see cref="IAsyncEnumerable{T}"/> to read.</param>
54+
/// <returns>The <see cref="ICollection"/>.</returns>
55+
public Task<ICollection> ReadAsync(IAsyncEnumerable<object> value)
56+
{
57+
if (value == null)
58+
{
59+
throw new ArgumentNullException(nameof(value));
60+
}
61+
62+
var type = value.GetType();
63+
if (!_asyncEnumerableConverters.TryGetValue(type, out var result))
64+
{
65+
var enumerableType = ClosedGenericMatcher.ExtractGenericInterface(type, typeof(IAsyncEnumerable<>));
66+
Debug.Assert(enumerableType != null);
67+
68+
var enumeratedObjectType = enumerableType.GetGenericArguments()[0];
69+
70+
var converter = (ReaderFunc)Converter
71+
.MakeGenericMethod(enumeratedObjectType)
72+
.CreateDelegate(typeof(ReaderFunc), this);
73+
74+
_asyncEnumerableConverters.TryAdd(type, converter);
75+
result = converter;
76+
}
77+
78+
return result(value);
79+
}
80+
81+
private async Task<ICollection> ReadInternal<T>(IAsyncEnumerable<object> value)
82+
{
83+
var asyncEnumerable = (IAsyncEnumerable<T>)value;
84+
var result = new List<T>();
85+
var count = 0;
86+
87+
await foreach (var item in asyncEnumerable)
88+
{
89+
if (count++ >= _mvcOptions.MaxIAsyncEnumerableBufferLimit)
90+
{
91+
throw new InvalidOperationException(Resources.FormatObjectResultExecutor_MaxEnumerationExceeded(
92+
nameof(AsyncEnumerableReader),
93+
value.GetType()));
94+
}
95+
96+
result.Add(item);
97+
}
98+
99+
return result;
100+
}
101+
}
102+
}

src/Mvc/Mvc.Core/src/Infrastructure/ObjectResultExecutor.cs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using Microsoft.AspNetCore.Http;
1111
using Microsoft.AspNetCore.Mvc.Formatters;
1212
using Microsoft.Extensions.Logging;
13+
using Microsoft.Extensions.Options;
1314

1415
namespace Microsoft.AspNetCore.Mvc.Infrastructure
1516
{
@@ -18,16 +19,35 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
1819
/// </summary>
1920
public class ObjectResultExecutor : IActionResultExecutor<ObjectResult>
2021
{
22+
private readonly AsyncEnumerableReader _asyncEnumerableReader;
23+
2124
/// <summary>
2225
/// Creates a new <see cref="ObjectResultExecutor"/>.
2326
/// </summary>
2427
/// <param name="formatterSelector">The <see cref="OutputFormatterSelector"/>.</param>
2528
/// <param name="writerFactory">The <see cref="IHttpResponseStreamWriterFactory"/>.</param>
2629
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
30+
[Obsolete("This constructor is obsolete and will be removed in a future release.")]
2731
public ObjectResultExecutor(
2832
OutputFormatterSelector formatterSelector,
2933
IHttpResponseStreamWriterFactory writerFactory,
3034
ILoggerFactory loggerFactory)
35+
: this(formatterSelector, writerFactory, loggerFactory, mvcOptions: null)
36+
{
37+
}
38+
39+
/// <summary>
40+
/// Creates a new <see cref="ObjectResultExecutor"/>.
41+
/// </summary>
42+
/// <param name="formatterSelector">The <see cref="OutputFormatterSelector"/>.</param>
43+
/// <param name="writerFactory">The <see cref="IHttpResponseStreamWriterFactory"/>.</param>
44+
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
45+
/// <param name="mvcOptions">Accessor to <see cref="MvcOptions"/>.</param>
46+
public ObjectResultExecutor(
47+
OutputFormatterSelector formatterSelector,
48+
IHttpResponseStreamWriterFactory writerFactory,
49+
ILoggerFactory loggerFactory,
50+
IOptions<MvcOptions> mvcOptions)
3151
{
3252
if (formatterSelector == null)
3353
{
@@ -47,6 +67,8 @@ public ObjectResultExecutor(
4767
FormatterSelector = formatterSelector;
4868
WriterFactory = writerFactory.CreateWriter;
4969
Logger = loggerFactory.CreateLogger<ObjectResultExecutor>();
70+
var options = mvcOptions?.Value ?? throw new ArgumentNullException(nameof(mvcOptions));
71+
_asyncEnumerableReader = new AsyncEnumerableReader(options);
5072
}
5173

5274
/// <summary>
@@ -87,16 +109,37 @@ public virtual Task ExecuteAsync(ActionContext context, ObjectResult result)
87109
InferContentTypes(context, result);
88110

89111
var objectType = result.DeclaredType;
112+
90113
if (objectType == null || objectType == typeof(object))
91114
{
92115
objectType = result.Value?.GetType();
93116
}
94117

118+
var value = result.Value;
119+
120+
if (value is IAsyncEnumerable<object> asyncEnumerable)
121+
{
122+
return ExecuteAsyncEnumerable(context, result, asyncEnumerable);
123+
}
124+
125+
return ExecuteAsyncCore(context, result, objectType, value);
126+
}
127+
128+
private async Task ExecuteAsyncEnumerable(ActionContext context, ObjectResult result, IAsyncEnumerable<object> asyncEnumerable)
129+
{
130+
Log.BufferingAsyncEnumerable(Logger, asyncEnumerable);
131+
132+
var enumerated = await _asyncEnumerableReader.ReadAsync(asyncEnumerable);
133+
await ExecuteAsyncCore(context, result, enumerated.GetType(), enumerated);
134+
}
135+
136+
private Task ExecuteAsyncCore(ActionContext context, ObjectResult result, Type objectType, object value)
137+
{
95138
var formatterContext = new OutputFormatterWriteContext(
96139
context.HttpContext,
97140
WriterFactory,
98141
objectType,
99-
result.Value);
142+
value);
100143

101144
var selectedFormatter = FormatterSelector.SelectFormatter(
102145
formatterContext,
@@ -138,5 +181,21 @@ private static void InferContentTypes(ActionContext context, ObjectResult result
138181
result.ContentTypes.Add("application/problem+xml");
139182
}
140183
}
184+
185+
private static class Log
186+
{
187+
private static readonly Action<ILogger, string, Exception> _bufferingAsyncEnumerable;
188+
189+
static Log()
190+
{
191+
_bufferingAsyncEnumerable = LoggerMessage.Define<string>(
192+
LogLevel.Debug,
193+
new EventId(1, "BufferingAsyncEnumerable"),
194+
"Buffering IAsyncEnumerable instance of type '{Type}'.");
195+
}
196+
197+
public static void BufferingAsyncEnumerable(ILogger logger, IAsyncEnumerable<object> asyncEnumerable)
198+
=> _bufferingAsyncEnumerable(logger, asyncEnumerable.GetType().FullName, null);
199+
}
141200
}
142201
}

src/Mvc/Mvc.Core/src/Infrastructure/SystemTextJsonResultExecutor.cs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5+
using System.Collections.Generic;
56
using System.IO;
67
using System.Text;
78
using System.Text.Json;
@@ -25,13 +26,16 @@ internal sealed class SystemTextJsonResultExecutor : IActionResultExecutor<JsonR
2526

2627
private readonly JsonOptions _options;
2728
private readonly ILogger<SystemTextJsonResultExecutor> _logger;
29+
private readonly AsyncEnumerableReader _asyncEnumerableReader;
2830

2931
public SystemTextJsonResultExecutor(
3032
IOptions<JsonOptions> options,
31-
ILogger<SystemTextJsonResultExecutor> logger)
33+
ILogger<SystemTextJsonResultExecutor> logger,
34+
IOptions<MvcOptions> mvcOptions)
3235
{
3336
_options = options.Value;
3437
_logger = logger;
38+
_asyncEnumerableReader = new AsyncEnumerableReader(mvcOptions.Value);
3539
}
3640

3741
public async Task ExecuteAsync(ActionContext context, JsonResult result)
@@ -70,8 +74,15 @@ public async Task ExecuteAsync(ActionContext context, JsonResult result)
7074
var writeStream = GetWriteStream(context.HttpContext, resolvedContentTypeEncoding);
7175
try
7276
{
73-
var type = result.Value?.GetType() ?? typeof(object);
74-
await JsonSerializer.WriteAsync(writeStream, result.Value, type, jsonSerializerOptions);
77+
var value = result.Value;
78+
if (value is IAsyncEnumerable<object> asyncEnumerable)
79+
{
80+
Log.BufferingAsyncEnumerable(_logger, asyncEnumerable);
81+
value = await _asyncEnumerableReader.ReadAsync(asyncEnumerable);
82+
}
83+
84+
var type = value?.GetType() ?? typeof(object);
85+
await JsonSerializer.WriteAsync(writeStream, value, type, jsonSerializerOptions);
7586
await writeStream.FlushAsync();
7687
}
7788
finally
@@ -123,11 +134,19 @@ private static class Log
123134
new EventId(1, "JsonResultExecuting"),
124135
"Executing JsonResult, writing value of type '{Type}'.");
125136

137+
private static readonly Action<ILogger, string, Exception> _bufferingAsyncEnumerable = LoggerMessage.Define<string>(
138+
LogLevel.Debug,
139+
new EventId(2, "BufferingAsyncEnumerable"),
140+
"Buffering IAsyncEnumerable instance of type '{Type}'.");
141+
126142
public static void JsonResultExecuting(ILogger logger, object value)
127143
{
128144
var type = value == null ? "null" : value.GetType().FullName;
129145
_jsonResultExecuting(logger, type, null);
130146
}
147+
148+
public static void BufferingAsyncEnumerable(ILogger logger, IAsyncEnumerable<object> asyncEnumerable)
149+
=> _bufferingAsyncEnumerable(logger, asyncEnumerable.GetType().FullName, null);
131150
}
132151
}
133152
}

src/Mvc/Mvc.Core/src/MvcOptions.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,19 @@ public int MaxModelBindingRecursionDepth
359359
}
360360
}
361361

362+
/// <summary>
363+
/// Gets or sets the most number of entries of an <see cref="IAsyncEnumerable{T}"/> that
364+
/// that <see cref="ObjectResultExecutor"/> will buffer.
365+
/// <para>
366+
/// When <see cref="ObjectResult.Value" /> is an instance of <see cref="IAsyncEnumerable{T}"/>,
367+
/// <see cref="ObjectResultExecutor"/> will eagerly read the enumeration and add to a synchronous collection
368+
/// prior to invoking the selected formatter.
369+
/// This property determines the most number of entries that the executor is allowed to buffer.
370+
/// </para>
371+
/// </summary>
372+
/// <value>Defaults to <c>8192</c>.</value>
373+
public int MaxIAsyncEnumerableBufferLimit { get; set; } = 8192;
374+
362375
IEnumerator<ICompatibilitySwitch> IEnumerable<ICompatibilitySwitch>.GetEnumerator() => _switches.GetEnumerator();
363376

364377
IEnumerator IEnumerable.GetEnumerator() => _switches.GetEnumerator();

src/Mvc/Mvc.Core/src/Properties/Resources.Designer.cs

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Mvc/Mvc.Core/src/Resources.resx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,5 +503,8 @@
503503
</data>
504504
<data name="Property_MustBeInstanceOfType" xml:space="preserve">
505505
<value>Property '{0}.{1}' must be an instance of type '{2}'.</value>
506-
</data>
506+
</data>
507+
<data name="ObjectResultExecutor_MaxEnumerationExceeded" xml:space="preserve">
508+
<value>'{0}' reached the configured maximum size of the buffer when enumerating a value of type '{1}'. This limit is in place to prevent infinite streams of 'IAsyncEnumerable&lt;&gt;' from continuing indefinitely. If this is not a programming mistake, consider ways to reduce the collection size, or consider manually converting '{1}' into a list rather than increasing the limit.</value>
509+
</data>
507510
</root>

src/Mvc/Mvc.Core/test/AcceptedAtActionResultTests.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,8 @@ private static IServiceProvider CreateServices(Mock<IOutputFormatter> formatter)
275275
services.AddSingleton<IActionResultExecutor<ObjectResult>>(new ObjectResultExecutor(
276276
new DefaultOutputFormatterSelector(options, NullLoggerFactory.Instance),
277277
new TestHttpResponseStreamWriterFactory(),
278-
NullLoggerFactory.Instance));
278+
NullLoggerFactory.Instance,
279+
options));
279280

280281
return services.BuildServiceProvider();
281282
}

src/Mvc/Mvc.Core/test/AcceptedAtRouteResultTests.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,8 @@ private static IServiceProvider CreateServices(Mock<IOutputFormatter> formatter)
183183
services.AddSingleton<IActionResultExecutor<ObjectResult>>(new ObjectResultExecutor(
184184
new DefaultOutputFormatterSelector(options, NullLoggerFactory.Instance),
185185
new TestHttpResponseStreamWriterFactory(),
186-
NullLoggerFactory.Instance));
186+
NullLoggerFactory.Instance,
187+
options));
187188

188189
return services.BuildServiceProvider();
189190
}

src/Mvc/Mvc.Core/test/AcceptedResultTests.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,8 @@ private static IServiceProvider CreateServices(Mock<IOutputFormatter> formatter)
139139
services.AddSingleton<IActionResultExecutor<ObjectResult>>(new ObjectResultExecutor(
140140
new DefaultOutputFormatterSelector(options, NullLoggerFactory.Instance),
141141
new TestHttpResponseStreamWriterFactory(),
142-
NullLoggerFactory.Instance));
142+
NullLoggerFactory.Instance,
143+
options));
143144

144145
return services.BuildServiceProvider();
145146
}

0 commit comments

Comments
 (0)