Skip to content

Commit eec5dc1

Browse files
authored
[8.0] Critical fix to output cache; remove OutputCacheEntry recycling (#51067)
1 parent 9373166 commit eec5dc1

File tree

4 files changed

+121
-32
lines changed

4 files changed

+121
-32
lines changed

src/Middleware/OutputCaching/src/OutputCacheEntry.cs

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
using System.Buffers;
55
using System.IO.Pipelines;
6-
using System.Runtime.InteropServices;
76
using Microsoft.AspNetCore.Http;
87
using Microsoft.Extensions.Primitives;
98
using Microsoft.Net.Http.Headers;
@@ -18,8 +17,6 @@ public OutputCacheEntry(DateTimeOffset created, int statusCode)
1817
StatusCode = statusCode;
1918
}
2019

21-
private bool _recycleBuffers; // does this instance own the memory behind the segments?
22-
2320
public StringValues FindHeader(string key)
2421
{
2522
TryFindHeader(key, out var value);
@@ -69,26 +66,9 @@ public bool TryFindHeader(string key, out StringValues values)
6966
internal void SetBody(ReadOnlySequence<byte> value, bool recycleBuffers)
7067
{
7168
Body = value;
72-
_recycleBuffers = recycleBuffers;
73-
}
74-
75-
public void Dispose()
76-
{
77-
var headers = Headers;
78-
var body = Body;
79-
Headers = default;
80-
Body = default;
81-
Recycle(headers);
82-
RecyclableReadOnlySequenceSegment.RecycleChain(body, _recycleBuffers);
83-
// ^^ note that this only recycles the chain, not the actual buffers
84-
}
85-
86-
private static void Recycle<T>(ReadOnlyMemory<T> value)
87-
{
88-
if (MemoryMarshal.TryGetArray<T>(value, out var segment) && segment.Array is { Length: > 0 })
89-
{
90-
ArrayPool<T>.Shared.Return(segment.Array);
91-
}
69+
_ = recycleBuffers; // satisfy IDE0060
70+
// note that recycleBuffers is not stored currently, until OutputCacheEntry buffer recycling is re-implemented;
71+
// it indicates whether this instance "owns" the memory behind the segments, such that they can be recycled later if desired
9272
}
9373

9474
internal OutputCacheEntry CreateBodyFrom(IList<byte[]> segments) // mainly used from tests
@@ -118,6 +98,7 @@ internal OutputCacheEntry CopyHeadersFrom(IHeaderDictionary headers)
11898
if (index == 0) // only ignored headers
11999
{
120100
ArrayPool<(string, StringValues)>.Shared.Return(arr);
101+
Headers = default;
121102
}
122103
else
123104
{
@@ -142,4 +123,6 @@ public void CopyHeadersTo(IHeaderDictionary headers)
142123

143124
public ValueTask CopyToAsync(PipeWriter destination, CancellationToken cancellationToken)
144125
=> RecyclableReadOnlySequenceSegment.CopyToAsync(Body, destination, cancellationToken);
126+
127+
public void Dispose() { } // intention here is to add recycling; this was removed late in NET8, but is retained as a callback
145128
}

src/Middleware/OutputCaching/src/OutputCacheMiddleware.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ private async Task InvokeAwaited(HttpContext httpContext, IReadOnlyList<IOutputC
200200
{
201201
// avoid recycling in unknown outcomes, especially re concurrent buffer access thru cancellation
202202
hasException = true;
203+
throw;
203204
}
204205
finally
205206
{

src/Middleware/OutputCaching/src/RecyclableReadOnlySequenceSegment.cs

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,25 @@
44
using System.Buffers;
55
using System.Collections.Concurrent;
66
using System.IO.Pipelines;
7-
using System.Runtime.InteropServices;
87

98
namespace Microsoft.AspNetCore.OutputCaching;
109

10+
// TODO: reinstate pooling
11+
// context: a last-minute bug was detected during net8 preparation that impacted
12+
// buffer reuse from output-cache (the only consumer of this type); the preferred
13+
// solution for this is understood, but is more "moving parts" than we're comfortable
14+
// touching in the last phase of net8, so to avoid risk, *temporarily*, the buffer
15+
// reuse is disabled; this is consistent with net7, which never used buffer recycling
16+
// in output-cache, so this is not a regression. The work to properly implement buffer
17+
// reuse in output-cache is in progress to be merged in net9 and hopefully backported
18+
// into a net8 service release.
1119
internal sealed class RecyclableReadOnlySequenceSegment : ReadOnlySequenceSegment<byte>
1220
{
1321
public int Length => Memory.Length;
1422
private RecyclableReadOnlySequenceSegment() { }
1523

1624
public static RecyclableReadOnlySequenceSegment Create(int minimumLength, RecyclableReadOnlySequenceSegment? previous)
17-
=> Create(Rent(minimumLength), previous);
25+
=> Create(GetBuffer(minimumLength), previous);
1826

1927
public static RecyclableReadOnlySequenceSegment Create(ReadOnlyMemory<byte> memory, RecyclableReadOnlySequenceSegment? previous)
2028
{
@@ -112,14 +120,16 @@ public static async ValueTask CopyToAsync(ReadOnlySequence<byte> source, PipeWri
112120
}
113121
}
114122

115-
private static byte[] Rent(int minimumLength)
116-
=> ArrayPool<byte>.Shared.Rent(minimumLength);
123+
// TODO: reinstate ArrayPool<byte>.Shared usage.Rent(minimumLength);
124+
private static byte[] GetBuffer(int minimumLength)
125+
=> new byte[minimumLength];
117126

118-
private static void Recycle(ReadOnlyMemory<byte> value)
127+
private static void Recycle(ReadOnlyMemory<byte> _)
119128
{
120-
if (MemoryMarshal.TryGetArray(value, out var segment) && segment.Offset == 0 && segment.Count != 0)
121-
{
122-
ArrayPool<byte>.Shared.Return(segment.Array!);
123-
}
129+
// TODO: reinstate buffer recycling
130+
//if (MemoryMarshal.TryGetArray(value, out var segment) && segment.Offset == 0 && segment.Count != 0)
131+
//{
132+
// ArrayPool<byte>.Shared.Return(segment.Array!);
133+
//}
124134
}
125135
}

src/Middleware/OutputCaching/test/OutputCacheTests.cs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Net;
45
using System.Net.Http;
56
using Microsoft.AspNetCore.Http;
67
using Microsoft.AspNetCore.TestHost;
@@ -935,6 +936,100 @@ public async Task ServesCachedContent_IfAvailable_UsingHead_WithContentLength()
935936
}
936937
}
937938

939+
[Fact]
940+
public async Task MiddlewareFaultsAreObserved()
941+
{
942+
var builders = TestUtils.CreateBuildersWithOutputCaching(contextAction: _ => throw new SomeException());
943+
944+
foreach (var builder in builders)
945+
{
946+
using var host = builder.Build();
947+
948+
await host.StartAsync();
949+
950+
using var server = host.GetTestServer();
951+
952+
for (int i = 0; i < 10; i++)
953+
{
954+
await RunClient(server);
955+
}
956+
}
957+
958+
static async Task RunClient(TestServer server)
959+
{
960+
var client = server.CreateClient();
961+
await Assert.ThrowsAsync<SomeException>(
962+
() => client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "")));
963+
}
964+
}
965+
966+
sealed class SomeException : Exception { }
967+
968+
[Fact]
969+
public async Task ServesCorrectlyUnderConcurrentLoad()
970+
{
971+
var builders = TestUtils.CreateBuildersWithOutputCaching();
972+
973+
foreach (var builder in builders)
974+
{
975+
using var host = builder.Build();
976+
977+
await host.StartAsync();
978+
979+
using var server = host.GetTestServer();
980+
981+
var guid = await RunClient(server, -1);
982+
983+
var clients = new Task<Guid>[1024];
984+
for (int i = 0; i < clients.Length; i++)
985+
{
986+
clients[i] = Task.Run(() => RunClient(server, i));
987+
}
988+
await Task.WhenAll(clients);
989+
990+
// note already completed
991+
for (int i = 0; i < clients.Length; i++)
992+
{
993+
Assert.Equal(guid, await clients[i]);
994+
}
995+
}
996+
997+
static async Task<Guid> RunClient(TestServer server, int id)
998+
{
999+
string s = null;
1000+
try
1001+
{
1002+
var client = server.CreateClient();
1003+
var resp = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, ""));
1004+
var len = resp.Content.Headers.ContentLength;
1005+
s = await resp.Content.ReadAsStringAsync();
1006+
1007+
Assert.NotNull(len);
1008+
Guid value;
1009+
switch (len.Value)
1010+
{
1011+
case 36:
1012+
// usually we just write a guid
1013+
Assert.True(Guid.TryParse(s, out value));
1014+
break;
1015+
case 98:
1016+
// the file-based builder prepends extra data
1017+
Assert.True(Guid.TryParse(s.Substring(s.Length - 36), out value));
1018+
break;
1019+
default:
1020+
Assert.Fail($"Unexpected length: {len.Value}");
1021+
value = Guid.NewGuid(); // not reached
1022+
break;
1023+
}
1024+
return value;
1025+
}
1026+
catch (Exception ex)
1027+
{
1028+
throw new InvalidOperationException($"Client {id} failed; payload '{s}', failure: {ex.Message}", ex);
1029+
}
1030+
}
1031+
}
1032+
9381033
private static void Assert304Headers(HttpResponseMessage initialResponse, HttpResponseMessage subsequentResponse)
9391034
{
9401035
// https://tools.ietf.org/html/rfc7232#section-4.1

0 commit comments

Comments
 (0)