-
Notifications
You must be signed in to change notification settings - Fork 10.4k
Allow HTTP2 encoder to split headers across frames #55322
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 26 commits
a10d9ee
5cad996
f53dd7c
231c716
887f505
b91a34c
a655a38
f31c8c8
6ab7271
e21620a
9720c3a
f8f8ff9
994b27d
a77a930
4f14b0c
46be03f
9857dcb
95c9553
c4b93d6
3fe0a73
0dfc97b
79be13b
8773062
a98500b
9014f28
957dfc3
57cc264
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -29,6 +29,8 @@ internal sealed class Http2FrameWriter | |
/// TODO (https://github.com/dotnet/aspnetcore/issues/51309): eliminate this limit. | ||
private const string MaximumFlowControlQueueSizeProperty = "Microsoft.AspNetCore.Server.Kestrel.Http2.MaxConnectionFlowControlQueueSize"; | ||
|
||
private const int HeaderBufferSizeMultiplier = 2; | ||
|
||
private static readonly int? AppContextMaximumFlowControlQueueSize = GetAppContextMaximumFlowControlQueueSize(); | ||
|
||
private static int? GetAppContextMaximumFlowControlQueueSize() | ||
|
@@ -71,8 +73,12 @@ internal sealed class Http2FrameWriter | |
// This is only set to true by tests. | ||
private readonly bool _scheduleInline; | ||
|
||
private uint _maxFrameSize = Http2PeerSettings.MinAllowedMaxFrameSize; | ||
private int _maxFrameSize = Http2PeerSettings.MinAllowedMaxFrameSize; | ||
private byte[] _headerEncodingBuffer; | ||
|
||
// Keep track of the high-water mark of _headerEncodingBuffer's size so we don't have to grow | ||
// through intermediate sizes repeatedly. | ||
private int _headersEncodingLargeBufferSize = Http2PeerSettings.MinAllowedMaxFrameSize * HeaderBufferSizeMultiplier; | ||
private long _unflushedBytes; | ||
|
||
private bool _completed; | ||
|
@@ -110,7 +116,6 @@ public Http2FrameWriter( | |
_headerEncodingBuffer = new byte[_maxFrameSize]; | ||
|
||
_scheduleInline = serviceContext.Scheduler == PipeScheduler.Inline; | ||
|
||
_hpackEncoder = new DynamicHPackEncoder(serviceContext.ServerOptions.AllowResponseHeaderCompression); | ||
|
||
_maximumFlowControlQueueSize = AppContextMaximumFlowControlQueueSize is null | ||
|
@@ -367,12 +372,15 @@ public void UpdateMaxHeaderTableSize(uint maxHeaderTableSize) | |
} | ||
} | ||
|
||
public void UpdateMaxFrameSize(uint maxFrameSize) | ||
public void UpdateMaxFrameSize(int maxFrameSize) | ||
{ | ||
lock (_writeLock) | ||
{ | ||
if (_maxFrameSize != maxFrameSize) | ||
{ | ||
// Safe multiply, MaxFrameSize is limited to 2^24-1 bytes by the protocol and by Http2PeerSettings. | ||
// Ref: https://datatracker.ietf.org/doc/html/rfc7540#section-4.2 | ||
_headersEncodingLargeBufferSize = int.Max(_headersEncodingLargeBufferSize, maxFrameSize * HeaderBufferSizeMultiplier); | ||
_maxFrameSize = maxFrameSize; | ||
_headerEncodingBuffer = new byte[_maxFrameSize]; | ||
ladeak marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
@@ -507,11 +515,12 @@ private void WriteResponseHeadersUnsynchronized(int streamId, int statusCode, Ht | |
{ | ||
try | ||
{ | ||
// In the case of the headers, there is always a status header to be returned, so BeginEncodeHeaders will not return BufferTooSmall. | ||
_headersEnumerator.Initialize(headers); | ||
_outgoingFrame.PrepareHeaders(headerFrameFlags, streamId); | ||
var buffer = _headerEncodingBuffer.AsSpan(); | ||
var done = HPackHeaderWriter.BeginEncodeHeaders(statusCode, _hpackEncoder, _headersEnumerator, buffer, out var payloadLength); | ||
FinishWritingHeadersUnsynchronized(streamId, payloadLength, done); | ||
var writeResult = HPackHeaderWriter.BeginEncodeHeaders(statusCode, _hpackEncoder, _headersEnumerator, _headerEncodingBuffer, out var payloadLength); | ||
Debug.Assert(writeResult != HeaderWriteResult.BufferTooSmall, "This always writes the status as the first header, and it should never be an over the buffer size."); | ||
FinishWritingHeadersUnsynchronized(streamId, payloadLength, writeResult); | ||
} | ||
// Any exception from the HPack encoder can leave the dynamic table in a corrupt state. | ||
// Since we allow custom header encoders we don't know what type of exceptions to expect. | ||
|
@@ -548,11 +557,11 @@ private ValueTask<FlushResult> WriteDataAndTrailersAsync(Http2Stream stream, in | |
|
||
try | ||
{ | ||
_headersEnumerator.Initialize(headers); | ||
// In the case of the trailers, there is no status header to be written, so even the first call to BeginEncodeHeaders can return BufferTooSmall. | ||
_outgoingFrame.PrepareHeaders(Http2HeadersFrameFlags.END_STREAM, streamId); | ||
var buffer = _headerEncodingBuffer.AsSpan(); | ||
var done = HPackHeaderWriter.BeginEncodeHeaders(_hpackEncoder, _headersEnumerator, buffer, out var payloadLength); | ||
FinishWritingHeadersUnsynchronized(streamId, payloadLength, done); | ||
_headersEnumerator.Initialize(headers); | ||
var writeResult = HPackHeaderWriter.BeginEncodeHeaders(_hpackEncoder, _headersEnumerator, _headerEncodingBuffer, out var payloadLength); | ||
FinishWritingHeadersUnsynchronized(streamId, payloadLength, writeResult); | ||
} | ||
// Any exception from the HPack encoder can leave the dynamic table in a corrupt state. | ||
// Since we allow custom header encoders we don't know what type of exceptions to expect. | ||
|
@@ -566,32 +575,102 @@ private ValueTask<FlushResult> WriteDataAndTrailersAsync(Http2Stream stream, in | |
} | ||
} | ||
|
||
private void FinishWritingHeadersUnsynchronized(int streamId, int payloadLength, bool done) | ||
private void SplitHeaderAcrossFrames(int streamId, ReadOnlySpan<byte> dataToFrame, bool endOfHeaders, bool isFramePrepared) | ||
{ | ||
var buffer = _headerEncodingBuffer.AsSpan(); | ||
_outgoingFrame.PayloadLength = payloadLength; | ||
if (done) | ||
var shouldPrepareFrame = !isFramePrepared; | ||
while (dataToFrame.Length > 0) | ||
{ | ||
_outgoingFrame.HeadersFlags |= Http2HeadersFrameFlags.END_HEADERS; | ||
} | ||
if (shouldPrepareFrame) | ||
{ | ||
_outgoingFrame.PrepareContinuation(Http2ContinuationFrameFlags.NONE, streamId); | ||
} | ||
|
||
WriteHeaderUnsynchronized(); | ||
_outputWriter.Write(buffer.Slice(0, payloadLength)); | ||
// Should prepare continuation frames. | ||
shouldPrepareFrame = true; | ||
var currentSize = Math.Min(dataToFrame.Length, _maxFrameSize); | ||
_outgoingFrame.PayloadLength = currentSize; | ||
if (endOfHeaders && dataToFrame.Length == currentSize) | ||
{ | ||
_outgoingFrame.HeadersFlags |= Http2HeadersFrameFlags.END_HEADERS; | ||
} | ||
|
||
while (!done) | ||
{ | ||
_outgoingFrame.PrepareContinuation(Http2ContinuationFrameFlags.NONE, streamId); | ||
WriteHeaderUnsynchronized(); | ||
_outputWriter.Write(dataToFrame[..currentSize]); | ||
dataToFrame = dataToFrame.Slice(currentSize); | ||
} | ||
} | ||
|
||
done = HPackHeaderWriter.ContinueEncodeHeaders(_hpackEncoder, _headersEnumerator, buffer, out payloadLength); | ||
private void FinishWritingHeadersUnsynchronized(int streamId, int payloadLength, HeaderWriteResult writeResult) | ||
{ | ||
Debug.Assert(payloadLength <= _maxFrameSize, "The initial payload lengths is written to _headerEncodingBuffer with size of _maxFrameSize"); | ||
byte[]? largeHeaderBuffer = null; | ||
Span<byte> buffer; | ||
if (writeResult == HeaderWriteResult.Done) | ||
{ | ||
// Fast path, only a single HEADER frame. | ||
_outgoingFrame.PayloadLength = payloadLength; | ||
|
||
if (done) | ||
_outgoingFrame.HeadersFlags |= Http2HeadersFrameFlags.END_HEADERS; | ||
WriteHeaderUnsynchronized(); | ||
_outputWriter.Write(_headerEncodingBuffer.AsSpan(0, payloadLength)); | ||
return; | ||
} | ||
else if (writeResult == HeaderWriteResult.MoreHeaders) | ||
{ | ||
_outgoingFrame.PayloadLength = payloadLength; | ||
WriteHeaderUnsynchronized(); | ||
_outputWriter.Write(_headerEncodingBuffer.AsSpan(0, payloadLength)); | ||
} | ||
else | ||
{ | ||
// This may happen in case of the TRAILERS after the initial encode operation. | ||
// The _maxFrameSize sized _headerEncodingBuffer was too small. | ||
while (writeResult == HeaderWriteResult.BufferTooSmall) | ||
{ | ||
Debug.Assert(payloadLength == 0, "Payload written even though buffer is too small"); | ||
largeHeaderBuffer = ArrayPool<byte>.Shared.Rent(_headersEncodingLargeBufferSize); | ||
buffer = largeHeaderBuffer.AsSpan(0, _headersEncodingLargeBufferSize); | ||
writeResult = HPackHeaderWriter.RetryBeginEncodeHeaders(_hpackEncoder, _headersEnumerator, buffer, out payloadLength); | ||
if (writeResult != HeaderWriteResult.BufferTooSmall) | ||
{ | ||
SplitHeaderAcrossFrames(streamId, buffer[..payloadLength], endOfHeaders: writeResult == HeaderWriteResult.Done, isFramePrepared: true); | ||
} | ||
else | ||
{ | ||
_headersEncodingLargeBufferSize = checked(_headersEncodingLargeBufferSize * HeaderBufferSizeMultiplier); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For example, request 1 has a response header that is 10megs in size, so this field and the buffer eventually grows to that size. Then request 2 has a response header that is 100kb in size (larger than max frame size) but the writer rents a 10meg buffer because that is Is this behavior intentional? Is the size preserved to avoid retrying because a connection keeps sending large response headers? Should add a comment if that is the case. If the behavior isn't intentional, then I believe the field is only used inside There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This behavior was intentional, exactly as you said: it might be ie. a cookie that is sent on all streams and that is always large. If you think that is not a desired behavior, I am happy to make it local. In the meantime, I will leave a comment explaining this. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @JamesNK There's some discussion here: #55322 (comment) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This will throw when the header is at least There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note that the header may be actually already larger to When it throws there, this is what it would throw:
which then ends up handled in But I could only get here by forcing a large initial value to |
||
} | ||
ArrayPool<byte>.Shared.Return(largeHeaderBuffer); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should returning the buffer be in a finally to ensure its returned when an error occurs? There are definitely ways to throw when encoding headers. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My understanding was that in these cases the buffer would be garbage collected. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's my default inclination as well, but I understood we (dotnet? aspnetcore?) had guidelines saying rented objects should not be returned in the event of an exception. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let me know, I am happy to change already prepared on my machine. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, there's definitely strong precedent for only recycling on success, especially when async operations are possible; as a side-effect this also avoids the need for some |
||
largeHeaderBuffer = null; | ||
} | ||
if (writeResult == HeaderWriteResult.Done) | ||
{ | ||
ladeak marked this conversation as resolved.
Show resolved
Hide resolved
|
||
_outgoingFrame.ContinuationFlags = Http2ContinuationFrameFlags.END_HEADERS; | ||
return; | ||
} | ||
} | ||
|
||
WriteHeaderUnsynchronized(); | ||
_outputWriter.Write(buffer.Slice(0, payloadLength)); | ||
// HEADERS and zero or more CONTINUATIONS sent - all subsequent frames are (unprepared) CONTINUATIONs | ||
buffer = _headerEncodingBuffer; | ||
while (writeResult != HeaderWriteResult.Done) | ||
{ | ||
writeResult = HPackHeaderWriter.ContinueEncodeHeaders(_hpackEncoder, _headersEnumerator, buffer, out payloadLength); | ||
if (writeResult == HeaderWriteResult.BufferTooSmall) | ||
{ | ||
if (largeHeaderBuffer != null) | ||
{ | ||
ArrayPool<byte>.Shared.Return(largeHeaderBuffer); | ||
_headersEncodingLargeBufferSize = checked(_headersEncodingLargeBufferSize * HeaderBufferSizeMultiplier); | ||
} | ||
largeHeaderBuffer = ArrayPool<byte>.Shared.Rent(_headersEncodingLargeBufferSize); | ||
buffer = largeHeaderBuffer.AsSpan(0, _headersEncodingLargeBufferSize); | ||
} | ||
else | ||
{ | ||
// In case of Done or MoreHeaders: write to output. | ||
SplitHeaderAcrossFrames(streamId, buffer[..payloadLength], endOfHeaders: writeResult == HeaderWriteResult.Done, isFramePrepared: false); | ||
} | ||
} | ||
if (largeHeaderBuffer != null) | ||
{ | ||
ArrayPool<byte>.Shared.Return(largeHeaderBuffer); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be in a finally? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My understanding was that in these cases the buffer would be garbage collected. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not returning doesn't create a memory leak, but it could cause future rent calls to allocate because buffers haven't been returned. |
||
} | ||
} | ||
|
||
|
@@ -1023,4 +1102,4 @@ private void EnqueueWaitingForMoreConnectionWindow(Http2OutputProducer producer) | |
_http2Connection.Abort(new ConnectionAbortedException("HTTP/2 connection exceeded the outgoing flow control maximum queue size.")); | ||
} | ||
} | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.