Skip to content

Commit ef65a77

Browse files
BrennanConroywtgodbe
authored andcommitted
Merged PR 42925: Merged PR 41476: Fix Http3 Pipe complete
Don't complete the `PipeWriter` when it still might be used by Application code. ---- #### AI description (iteration 1) #### PR Classification Bug fix #### PR Summary This pull request addresses a bug in the HTTP/3 implementation related to memory management and pipe completion. - `Http3RequestTests.cs`: Added a new test to ensure memory is preserved when the connection closes. - `Http3OutputProducer.cs`: Refactored `Dispose` method and added `Complete` method to properly handle pipe completion. - `DiagnosticMemoryPool.cs`: Added `ContainsMemory` method to check if memory is still rented from the pool. - `Http3Stream.cs`: Ensured `Complete` is called on `_http3Output` when the stream is closed. - `Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.csproj`: Added `InternalsVisibleTo` for `Interop.FunctionalTests`.
1 parent b77c79e commit ef65a77

File tree

5 files changed

+181
-10
lines changed

5 files changed

+181
-10
lines changed

src/Servers/Kestrel/Core/src/Internal/Http3/Http3OutputProducer.cs

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,25 +68,21 @@ public void StreamReset()
6868
_dataWriteProcessingTask = ProcessDataWrites().Preserve();
6969
}
7070

71-
public void Dispose()
71+
// Called once Application code has exited
72+
// Or on Dispose which also would occur after Application code finished
73+
public void Complete()
7274
{
7375
lock (_dataWriterLock)
7476
{
75-
if (_disposed)
76-
{
77-
return;
78-
}
79-
80-
_disposed = true;
81-
8277
Stop();
8378

79+
_pipeWriter.Complete();
80+
8481
if (_fakeMemoryOwner != null)
8582
{
8683
_fakeMemoryOwner.Dispose();
8784
_fakeMemoryOwner = null;
8885
}
89-
9086
if (_fakeMemory != null)
9187
{
9288
ArrayPool<byte>.Shared.Return(_fakeMemory);
@@ -95,6 +91,21 @@ public void Dispose()
9591
}
9692
}
9793

94+
public void Dispose()
95+
{
96+
lock (_dataWriterLock)
97+
{
98+
if (_disposed)
99+
{
100+
return;
101+
}
102+
103+
_disposed = true;
104+
105+
Complete();
106+
}
107+
}
108+
98109
// In HTTP/1.x, this aborts the entire connection. For HTTP/3 we abort the stream.
99110
void IHttpOutputAborter.Abort(ConnectionAbortedException abortReason, ConnectionEndReason reason)
100111
{
@@ -288,7 +299,9 @@ public void Stop()
288299

289300
_streamCompleted = true;
290301

291-
_pipeWriter.Complete(new OperationCanceledException());
302+
// Application code could be using this PipeWriter, we cancel the next (or in progress) flush so they can observe this Stop
303+
// Additionally, _streamCompleted will cause any future PipeWriter operations to noop
304+
_pipeWriter.CancelPendingFlush();
292305
}
293306
}
294307

src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,8 @@ private void CompleteStream(bool errored)
561561
TryClose();
562562
}
563563

564+
_http3Output.Complete();
565+
564566
// Stream will be pooled after app completed.
565567
// Wait to signal app completed after any potential aborts on the stream.
566568
_appCompletedTaskSource.SetResult(null);

src/Servers/Kestrel/Transport.Sockets/src/Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,6 @@
4444
<InternalsVisibleTo Include="IIS.Http.FunctionalTests" />
4545
<InternalsVisibleTo Include="IIS.LongTests" />
4646
<InternalsVisibleTo Include="Microsoft.AspNetCore.Server.Kestrel.Tests" />
47+
<InternalsVisibleTo Include="Interop.FunctionalTests" />
4748
</ItemGroup>
4849
</Project>

src/Servers/Kestrel/test/Interop.FunctionalTests/Http3/Http3RequestTests.cs

Lines changed: 132 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.Buffers;
45
using System.Diagnostics;
56
using System.Diagnostics.Metrics;
67
using System.Net;
@@ -1145,6 +1146,137 @@ public async Task POST_Bidirectional_LargeData_Cancellation_Error(HttpProtocols
11451146
}
11461147
}
11471148

1149+
internal class MemoryPoolFeature : IMemoryPoolFeature
1150+
{
1151+
public MemoryPool<byte> MemoryPool { get; set; }
1152+
}
1153+
1154+
[ConditionalTheory]
1155+
[MsQuicSupported]
1156+
[InlineData(HttpProtocols.Http3)]
1157+
[InlineData(HttpProtocols.Http2)]
1158+
public async Task ApplicationWriteWhenConnectionClosesPreservesMemory(HttpProtocols protocol)
1159+
{
1160+
// Arrange
1161+
var memoryPool = new DiagnosticMemoryPool(new PinnedBlockMemoryPool(), allowLateReturn: true);
1162+
1163+
var writingTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
1164+
var cancelTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
1165+
var completionTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
1166+
1167+
var builder = CreateHostBuilder(async context =>
1168+
{
1169+
try
1170+
{
1171+
var requestBody = context.Request.Body;
1172+
1173+
await context.Response.BodyWriter.FlushAsync();
1174+
1175+
// Test relies on Htt2Stream/Http3Stream aborting the token after stopping Http2OutputProducer/Http3OutputProducer
1176+
// It's very fragile but it is sort of a best effort test anyways
1177+
// Additionally, Http2 schedules it's stopping, so doesn't directly do anything to the PipeWriter when calling stop on Http2OutputProducer
1178+
context.RequestAborted.Register(() =>
1179+
{
1180+
cancelTcs.SetResult();
1181+
});
1182+
1183+
while (true)
1184+
{
1185+
var memory = context.Response.BodyWriter.GetMemory();
1186+
1187+
// Unblock client-side to close the connection
1188+
writingTcs.TrySetResult();
1189+
1190+
await cancelTcs.Task;
1191+
1192+
// Verify memory is still rented from the memory pool after the producer has been stopped
1193+
Assert.True(memoryPool.ContainsMemory(memory));
1194+
1195+
context.Response.BodyWriter.Advance(memory.Length);
1196+
var flushResult = await context.Response.BodyWriter.FlushAsync();
1197+
1198+
if (flushResult.IsCanceled || flushResult.IsCompleted)
1199+
{
1200+
break;
1201+
}
1202+
}
1203+
1204+
completionTcs.SetResult();
1205+
}
1206+
catch (Exception ex)
1207+
{
1208+
writingTcs.TrySetException(ex);
1209+
// Exceptions annoyingly don't show up on the client side when doing E2E + cancellation testing
1210+
// so we need to use a TCS to observe any unexpected errors
1211+
completionTcs.TrySetException(ex);
1212+
throw;
1213+
}
1214+
}, protocol: protocol,
1215+
configureKestrel: o =>
1216+
{
1217+
o.Listen(IPAddress.Parse("127.0.0.1"), 0, listenOptions =>
1218+
{
1219+
listenOptions.Protocols = protocol;
1220+
listenOptions.UseHttps(TestResources.GetTestCertificate()).Use(@delegate =>
1221+
{
1222+
// Connection middleware for Http/1.1 and Http/2
1223+
return (context) =>
1224+
{
1225+
// Set the memory pool used by the connection so we can observe if memory from the PipeWriter is still rented from the pool
1226+
context.Features.Set<IMemoryPoolFeature>(new MemoryPoolFeature() { MemoryPool = memoryPool });
1227+
return @delegate(context);
1228+
};
1229+
});
1230+
1231+
IMultiplexedConnectionBuilder multiplexedConnectionBuilder = listenOptions;
1232+
multiplexedConnectionBuilder.Use(@delegate =>
1233+
{
1234+
// Connection middleware for Http/3
1235+
return (context) =>
1236+
{
1237+
// Set the memory pool used by the connection so we can observe if memory from the PipeWriter is still rented from the pool
1238+
context.Features.Set<IMemoryPoolFeature>(new MemoryPoolFeature() { MemoryPool = memoryPool });
1239+
return @delegate(context);
1240+
};
1241+
});
1242+
});
1243+
});
1244+
1245+
var httpClientHandler = new HttpClientHandler();
1246+
httpClientHandler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
1247+
1248+
using (var host = builder.Build())
1249+
using (var client = new HttpClient(httpClientHandler))
1250+
{
1251+
await host.StartAsync().DefaultTimeout();
1252+
1253+
var cts = new CancellationTokenSource();
1254+
1255+
var request = new HttpRequestMessage(HttpMethod.Post, $"https://127.0.0.1:{host.GetPort()}/");
1256+
request.Version = GetProtocol(protocol);
1257+
request.VersionPolicy = HttpVersionPolicy.RequestVersionExact;
1258+
1259+
// Act
1260+
var responseTask = client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
1261+
1262+
Logger.LogInformation("Client waiting for headers.");
1263+
var response = await responseTask.DefaultTimeout();
1264+
await writingTcs.Task;
1265+
1266+
Logger.LogInformation("Client canceled request.");
1267+
response.Dispose();
1268+
1269+
// Assert
1270+
await host.StopAsync().DefaultTimeout();
1271+
1272+
await completionTcs.Task;
1273+
1274+
memoryPool.Dispose();
1275+
1276+
await memoryPool.WhenAllBlocksReturnedAsync(TimeSpan.FromSeconds(15));
1277+
}
1278+
}
1279+
11481280
// Verify HTTP/2 and HTTP/3 match behavior
11491281
[ConditionalTheory]
11501282
[MsQuicSupported]

src/Shared/Buffers.MemoryPool/DiagnosticMemoryPool.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,27 @@ public async Task WhenAllBlocksReturnedAsync(TimeSpan timeout)
160160

161161
await task;
162162
}
163+
164+
public bool ContainsMemory(Memory<byte> memory)
165+
{
166+
lock (_syncObj)
167+
{
168+
foreach (var block in _blocks)
169+
{
170+
unsafe
171+
{
172+
fixed (byte* inUseMemoryPtr = memory.Span)
173+
fixed (byte* beginPooledMemoryPtr = block.Memory.Span)
174+
{
175+
byte* endPooledMemoryPtr = beginPooledMemoryPtr + block.Memory.Length;
176+
if (inUseMemoryPtr >= beginPooledMemoryPtr && inUseMemoryPtr < endPooledMemoryPtr)
177+
{
178+
return true;
179+
}
180+
}
181+
}
182+
}
183+
return false;
184+
}
185+
}
163186
}

0 commit comments

Comments
 (0)