Skip to content

Commit 73067c1

Browse files
ladeakladeakJamesNKamcasey
authored
Allow HTTP2 encoder to split headers across frames (#55322)
* Initial implementation * Review feedback * Updating a comment * Minor perf opt when all headers fit in a single header frame. * Adding MaxResponseHeadersLimit to H2 framewriter * Protect against overflow * Validating the header length in HPackHeaderWriter * Removing a test file commented out and adding more tests. * Review feedback * Removing header limits related code because it is up to the service owner to not to ddos itself * A test for 1MB header value * Review comments - removing an unneeded if condition * Sketch * using arraypool instead of arraybufferwriter * Re-using the rented buffers * Preserving the size of the temporary larger buffer within the framewriter. This way if the same large header is written multiple times over the lifespan of a connection, it does not need to go through the loops of increasing the headers repetitively. * Adjusting the logic of UpdateMaxFrameSize for setting a new _headersEncodingLargeBufferSize * Adjusting the logic of _headersEncodingLargeBufferSize to avoid 0 values. * Extenting Benachmark with headers sized 0, 10KB and 20KB * Remove int.Max on header size increase and using checked arithmetic instead. * Tests * Update src/Servers/Kestrel/Core/src/Internal/Http2/Http2FrameWriter.cs Co-authored-by: James Newton-King <[email protected]> * Review feedback: HeaderWriteResult not an inner type, adding comments on size increase * Update src/Servers/Kestrel/Core/src/Internal/Http2/Http2FrameWriter.cs Co-authored-by: Andrew Casey <[email protected]> * - Moving comments around for _headersEncodingLargeBufferSize - Rename splitting method to SplitHeaderAcrossFrames * Merging the implementation of BeginEncodeHeaders and RetryBeginEncodeHeaders * Apply suggestions from code review Co-authored-by: James Newton-King <[email protected]> --------- Co-authored-by: ladeak <[email protected]> Co-authored-by: James Newton-King <[email protected]> Co-authored-by: Andrew Casey <[email protected]>
1 parent 3612a9f commit 73067c1

File tree

13 files changed

+908
-324
lines changed

13 files changed

+908
-324
lines changed

src/Servers/Kestrel/Core/src/Internal/Http2/Http2Connection.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -938,7 +938,9 @@ private Task ProcessSettingsFrameAsync(in ReadOnlySequence<byte> payload)
938938
if (_clientSettings.MaxFrameSize != previousMaxFrameSize)
939939
{
940940
// Don't let the client choose an arbitrarily large size, this will be used for response buffers.
941-
_frameWriter.UpdateMaxFrameSize(Math.Min(_clientSettings.MaxFrameSize, _serverSettings.MaxFrameSize));
941+
// Safe cast, MaxFrameSize is limited to 2^24-1 bytes by the protocol and by Http2PeerSettings.
942+
// Ref: https://datatracker.ietf.org/doc/html/rfc7540#section-4.2
943+
_frameWriter.UpdateMaxFrameSize((int)Math.Min(_clientSettings.MaxFrameSize, _serverSettings.MaxFrameSize));
942944
}
943945

944946
// This difference can be negative.
@@ -1829,4 +1831,4 @@ private static class GracefulCloseInitiator
18291831
public const int Server = 1;
18301832
public const int Client = 2;
18311833
}
1832-
}
1834+
}

src/Servers/Kestrel/Core/src/Internal/Http2/Http2FrameWriter.cs

Lines changed: 107 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ internal sealed class Http2FrameWriter
2929
/// TODO (https://github.com/dotnet/aspnetcore/issues/51309): eliminate this limit.
3030
private const string MaximumFlowControlQueueSizeProperty = "Microsoft.AspNetCore.Server.Kestrel.Http2.MaxConnectionFlowControlQueueSize";
3131

32+
private const int HeaderBufferSizeMultiplier = 2;
33+
3234
private static readonly int? AppContextMaximumFlowControlQueueSize = GetAppContextMaximumFlowControlQueueSize();
3335

3436
private static int? GetAppContextMaximumFlowControlQueueSize()
@@ -71,8 +73,12 @@ internal sealed class Http2FrameWriter
7173
// This is only set to true by tests.
7274
private readonly bool _scheduleInline;
7375

74-
private uint _maxFrameSize = Http2PeerSettings.MinAllowedMaxFrameSize;
76+
private int _maxFrameSize = Http2PeerSettings.MinAllowedMaxFrameSize;
7577
private byte[] _headerEncodingBuffer;
78+
79+
// Keep track of the high-water mark of _headerEncodingBuffer's size so we don't have to grow
80+
// through intermediate sizes repeatedly.
81+
private int _headersEncodingLargeBufferSize = Http2PeerSettings.MinAllowedMaxFrameSize * HeaderBufferSizeMultiplier;
7682
private long _unflushedBytes;
7783

7884
private bool _completed;
@@ -110,7 +116,6 @@ public Http2FrameWriter(
110116
_headerEncodingBuffer = new byte[_maxFrameSize];
111117

112118
_scheduleInline = serviceContext.Scheduler == PipeScheduler.Inline;
113-
114119
_hpackEncoder = new DynamicHPackEncoder(serviceContext.ServerOptions.AllowResponseHeaderCompression);
115120

116121
_maximumFlowControlQueueSize = AppContextMaximumFlowControlQueueSize is null
@@ -367,12 +372,15 @@ public void UpdateMaxHeaderTableSize(uint maxHeaderTableSize)
367372
}
368373
}
369374

370-
public void UpdateMaxFrameSize(uint maxFrameSize)
375+
public void UpdateMaxFrameSize(int maxFrameSize)
371376
{
372377
lock (_writeLock)
373378
{
374379
if (_maxFrameSize != maxFrameSize)
375380
{
381+
// Safe multiply, MaxFrameSize is limited to 2^24-1 bytes by the protocol and by Http2PeerSettings.
382+
// Ref: https://datatracker.ietf.org/doc/html/rfc7540#section-4.2
383+
_headersEncodingLargeBufferSize = int.Max(_headersEncodingLargeBufferSize, maxFrameSize * HeaderBufferSizeMultiplier);
376384
_maxFrameSize = maxFrameSize;
377385
_headerEncodingBuffer = new byte[_maxFrameSize];
378386
}
@@ -507,11 +515,12 @@ private void WriteResponseHeadersUnsynchronized(int streamId, int statusCode, Ht
507515
{
508516
try
509517
{
518+
// In the case of the headers, there is always a status header to be returned, so BeginEncodeHeaders will not return BufferTooSmall.
510519
_headersEnumerator.Initialize(headers);
511520
_outgoingFrame.PrepareHeaders(headerFrameFlags, streamId);
512-
var buffer = _headerEncodingBuffer.AsSpan();
513-
var done = HPackHeaderWriter.BeginEncodeHeaders(statusCode, _hpackEncoder, _headersEnumerator, buffer, out var payloadLength);
514-
FinishWritingHeadersUnsynchronized(streamId, payloadLength, done);
521+
var writeResult = HPackHeaderWriter.BeginEncodeHeaders(statusCode, _hpackEncoder, _headersEnumerator, _headerEncodingBuffer, out var payloadLength);
522+
Debug.Assert(writeResult != HeaderWriteResult.BufferTooSmall, "This always writes the status as the first header, and it should never be an over the buffer size.");
523+
FinishWritingHeadersUnsynchronized(streamId, payloadLength, writeResult);
515524
}
516525
// Any exception from the HPack encoder can leave the dynamic table in a corrupt state.
517526
// 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
548557

549558
try
550559
{
551-
_headersEnumerator.Initialize(headers);
560+
// In the case of the trailers, there is no status header to be written, so even the first call to BeginEncodeHeaders can return BufferTooSmall.
552561
_outgoingFrame.PrepareHeaders(Http2HeadersFrameFlags.END_STREAM, streamId);
553-
var buffer = _headerEncodingBuffer.AsSpan();
554-
var done = HPackHeaderWriter.BeginEncodeHeaders(_hpackEncoder, _headersEnumerator, buffer, out var payloadLength);
555-
FinishWritingHeadersUnsynchronized(streamId, payloadLength, done);
562+
_headersEnumerator.Initialize(headers);
563+
var writeResult = HPackHeaderWriter.BeginEncodeHeaders(_hpackEncoder, _headersEnumerator, _headerEncodingBuffer, out var payloadLength);
564+
FinishWritingHeadersUnsynchronized(streamId, payloadLength, writeResult);
556565
}
557566
// Any exception from the HPack encoder can leave the dynamic table in a corrupt state.
558567
// 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
566575
}
567576
}
568577

569-
private void FinishWritingHeadersUnsynchronized(int streamId, int payloadLength, bool done)
578+
private void SplitHeaderAcrossFrames(int streamId, ReadOnlySpan<byte> dataToFrame, bool endOfHeaders, bool isFramePrepared)
570579
{
571-
var buffer = _headerEncodingBuffer.AsSpan();
572-
_outgoingFrame.PayloadLength = payloadLength;
573-
if (done)
580+
var shouldPrepareFrame = !isFramePrepared;
581+
while (dataToFrame.Length > 0)
574582
{
575-
_outgoingFrame.HeadersFlags |= Http2HeadersFrameFlags.END_HEADERS;
576-
}
583+
if (shouldPrepareFrame)
584+
{
585+
_outgoingFrame.PrepareContinuation(Http2ContinuationFrameFlags.NONE, streamId);
586+
}
577587

578-
WriteHeaderUnsynchronized();
579-
_outputWriter.Write(buffer.Slice(0, payloadLength));
588+
// Should prepare continuation frames.
589+
shouldPrepareFrame = true;
590+
var currentSize = Math.Min(dataToFrame.Length, _maxFrameSize);
591+
_outgoingFrame.PayloadLength = currentSize;
592+
if (endOfHeaders && dataToFrame.Length == currentSize)
593+
{
594+
_outgoingFrame.HeadersFlags |= Http2HeadersFrameFlags.END_HEADERS;
595+
}
580596

581-
while (!done)
582-
{
583-
_outgoingFrame.PrepareContinuation(Http2ContinuationFrameFlags.NONE, streamId);
597+
WriteHeaderUnsynchronized();
598+
_outputWriter.Write(dataToFrame[..currentSize]);
599+
dataToFrame = dataToFrame.Slice(currentSize);
600+
}
601+
}
584602

585-
done = HPackHeaderWriter.ContinueEncodeHeaders(_hpackEncoder, _headersEnumerator, buffer, out payloadLength);
603+
private void FinishWritingHeadersUnsynchronized(int streamId, int payloadLength, HeaderWriteResult writeResult)
604+
{
605+
Debug.Assert(payloadLength <= _maxFrameSize, "The initial payload lengths is written to _headerEncodingBuffer with size of _maxFrameSize");
606+
byte[]? largeHeaderBuffer = null;
607+
Span<byte> buffer;
608+
if (writeResult == HeaderWriteResult.Done)
609+
{
610+
// Fast path, only a single HEADER frame.
586611
_outgoingFrame.PayloadLength = payloadLength;
587-
588-
if (done)
612+
_outgoingFrame.HeadersFlags |= Http2HeadersFrameFlags.END_HEADERS;
613+
WriteHeaderUnsynchronized();
614+
_outputWriter.Write(_headerEncodingBuffer.AsSpan(0, payloadLength));
615+
return;
616+
}
617+
else if (writeResult == HeaderWriteResult.MoreHeaders)
618+
{
619+
_outgoingFrame.PayloadLength = payloadLength;
620+
WriteHeaderUnsynchronized();
621+
_outputWriter.Write(_headerEncodingBuffer.AsSpan(0, payloadLength));
622+
}
623+
else
624+
{
625+
// This may happen in case of the TRAILERS after the initial encode operation.
626+
// The _maxFrameSize sized _headerEncodingBuffer was too small.
627+
while (writeResult == HeaderWriteResult.BufferTooSmall)
628+
{
629+
Debug.Assert(payloadLength == 0, "Payload written even though buffer is too small");
630+
largeHeaderBuffer = ArrayPool<byte>.Shared.Rent(_headersEncodingLargeBufferSize);
631+
buffer = largeHeaderBuffer.AsSpan(0, _headersEncodingLargeBufferSize);
632+
writeResult = HPackHeaderWriter.RetryBeginEncodeHeaders(_hpackEncoder, _headersEnumerator, buffer, out payloadLength);
633+
if (writeResult != HeaderWriteResult.BufferTooSmall)
634+
{
635+
SplitHeaderAcrossFrames(streamId, buffer[..payloadLength], endOfHeaders: writeResult == HeaderWriteResult.Done, isFramePrepared: true);
636+
}
637+
else
638+
{
639+
_headersEncodingLargeBufferSize = checked(_headersEncodingLargeBufferSize * HeaderBufferSizeMultiplier);
640+
}
641+
ArrayPool<byte>.Shared.Return(largeHeaderBuffer);
642+
largeHeaderBuffer = null;
643+
}
644+
if (writeResult == HeaderWriteResult.Done)
589645
{
590-
_outgoingFrame.ContinuationFlags = Http2ContinuationFrameFlags.END_HEADERS;
646+
return;
591647
}
648+
}
592649

593-
WriteHeaderUnsynchronized();
594-
_outputWriter.Write(buffer.Slice(0, payloadLength));
650+
// HEADERS and zero or more CONTINUATIONS sent - all subsequent frames are (unprepared) CONTINUATIONs
651+
buffer = _headerEncodingBuffer;
652+
while (writeResult != HeaderWriteResult.Done)
653+
{
654+
writeResult = HPackHeaderWriter.ContinueEncodeHeaders(_hpackEncoder, _headersEnumerator, buffer, out payloadLength);
655+
if (writeResult == HeaderWriteResult.BufferTooSmall)
656+
{
657+
if (largeHeaderBuffer != null)
658+
{
659+
ArrayPool<byte>.Shared.Return(largeHeaderBuffer);
660+
_headersEncodingLargeBufferSize = checked(_headersEncodingLargeBufferSize * HeaderBufferSizeMultiplier);
661+
}
662+
largeHeaderBuffer = ArrayPool<byte>.Shared.Rent(_headersEncodingLargeBufferSize);
663+
buffer = largeHeaderBuffer.AsSpan(0, _headersEncodingLargeBufferSize);
664+
}
665+
else
666+
{
667+
// In case of Done or MoreHeaders: write to output.
668+
SplitHeaderAcrossFrames(streamId, buffer[..payloadLength], endOfHeaders: writeResult == HeaderWriteResult.Done, isFramePrepared: false);
669+
}
670+
}
671+
if (largeHeaderBuffer != null)
672+
{
673+
ArrayPool<byte>.Shared.Return(largeHeaderBuffer);
595674
}
596675
}
597676

@@ -1023,4 +1102,4 @@ private void EnqueueWaitingForMoreConnectionWindow(Http2OutputProducer producer)
10231102
_http2Connection.Abort(new ConnectionAbortedException("HTTP/2 connection exceeded the outgoing flow control maximum queue size."));
10241103
}
10251104
}
1026-
}
1105+
}

0 commit comments

Comments
 (0)