Skip to content
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

Fix byte buf leak when a ContentTooLargeException is raised #5227

Merged
merged 19 commits into from
Oct 26, 2023

Conversation

minwoox
Copy link
Member

@minwoox minwoox commented Oct 8, 2023

Motivation:
There are three bugs when the size of a request exceeds the max content length:

  • The ResponseHeaders that are sent to the client and recorded to RequestLog are different.
  • The AggregatingDecodedHttpRequest is not aborted thus ByteBufs are leaked. (When HTTP/1.1 is used)
  • The AggregatingDecodedHttpRequest does not get through the decorators so LoggingService doesn't log the exception.

Modifications:

  • Send 413 responses using HttpResponseSubscriber instead of sending them from HttpRequestDecoder by aborting the response.
    • 413 is not a protocol error so we should send it from the HttpResponseSubscriber.
    • If a responseHeaders is already sent, the stream is reset. (the channel is closed if HTTP/1.1)
  • Call ctx.fireChannelRead() if the request wasn't handed over to HttpServerHandler.
    • This lets the LoggingService log the ContentTooLargeException.
    • AggregatingDecodedHttpRequest is converted to StreamingDecodedHttpRequest not to raise an aggregate exception.
  • (misc) Raise a stream error instead of a connection error if it is stream level.

Result:

Motivation:
There are three bugs when the size of a request exceeds the max content length:
- The `ResponseHeaders` that is sent to the client and is recorded to `RequestLog` are different.
- The `AggregatingDecodedHttpRequest` is not aborted thus `ByteBuf`s are leaked. (When HTTP/1.1 is used)
- The `AggregatingDecodedHttpRequest` does not get throught the decorators so `LoggingService` doesn't log the exception.

Modifications:
- Send 413 response using `HttpResponseSubscriber` so that the recoreded `ResponseHaders` is actually sent
- Abort `AggregatingDecodedHttpRequest` if a `ContentTooLargeException` is raised when HTTP/1.1 is used.
- Convert `AggregatingDecodedHttpRequest` to `StreamingDecodedHttpRequest` and pass it to the service chain.
- Raise an stream error instead of connection error if it is stream level.

Result:
- Fix line#5180
- No more leaks when the size of a request exceeds the max content length.
@codecov
Copy link

codecov bot commented Oct 8, 2023

Codecov Report

Attention: 30 lines in your changes are missing coverage. Please review.

Comparison is base (25926dc) 73.94% compared to head (368b428) 73.94%.

Additional details and impacted files
@@             Coverage Diff              @@
##               main    #5227      +/-   ##
============================================
- Coverage     73.94%   73.94%   -0.01%     
- Complexity    20072    20085      +13     
============================================
  Files          1728     1728              
  Lines         74040    74092      +52     
  Branches       9438     9451      +13     
============================================
+ Hits          54752    54785      +33     
- Misses        14829    14833       +4     
- Partials       4459     4474      +15     
Files Coverage Δ
...orp/armeria/client/AbstractHttpRequestHandler.java 80.00% <100.00%> (ø)
...p/armeria/common/stream/DeferredStreamMessage.java 84.29% <100.00%> (+2.28%) ⬆️
...a/com/linecorp/armeria/common/util/Exceptions.java 40.68% <ø> (-2.07%) ⬇️
...rp/armeria/internal/common/Http1ObjectEncoder.java 71.42% <ø> (-1.72%) ⬇️
...orp/armeria/internal/common/HttpObjectEncoder.java 63.63% <100.00%> (+9.09%) ⬆️
...armeria/server/AbstractHttpResponseSubscriber.java 79.83% <100.00%> (+0.59%) ⬆️
...p/armeria/server/Http2ServerConnectionHandler.java 87.14% <100.00%> (-0.36%) ⬇️
.../internal/common/DefaultCancellationScheduler.java 77.16% <50.00%> (-0.22%) ⬇️
...rp/armeria/internal/common/Http2ObjectEncoder.java 71.42% <50.00%> (-16.81%) ⬇️
.../armeria/server/AggregatedHttpResponseHandler.java 82.27% <80.00%> (-7.47%) ⬇️
... and 9 more

... and 10 files with indirect coverage changes

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

Copy link
Contributor

@jrhee17 jrhee17 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not to raise an aggregate exception.

Do you mind explaining what points raised an aggregate exception?

Also, I think it will take too long for me to thoroughly review this PR.
Feel free to merge without my approval 🤞

Comment on lines 214 to 215
if (!req.isInitialized()) {
assert req.needsAggregation();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regardless of whether initialized or not, I think semantically it makes more sense that we fire the channel if the request isn't aggregated.

Also, if we happen to modify the pipeline later which reschedules the ctx.fireChannelRead(req); downstream, I feel like this can be a potential bug very easily

ditto for the other occurrences

Suggested change
if (!req.isInitialized()) {
assert req.needsAggregation();
if (req.needsAggregation()) {
assert !req.isInitialized();

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My original fix was calling ctx.fireChannelRead(req); with the aggregating request itself (without converting to stream request) so I need to check if the fireChannelRead method is called or not using isInitialized(). But I've changed the logic so let me revert it. 😉

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AggregatingDecodedHttpRequest is converted to StreamingDecodedHttpRequest not to raise an aggregate exception.

If the request is incomplete, wouldn't be clear to raise an exception when being aggregated?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AggregatingDecodedHttpRequest was implemented on the assumption that it is aggregated after being closed.
Do you mean we need to fix AggregatingDecodedHttpRequest not to raise an exception when aggregated?

@@ -394,7 +406,12 @@ public void onRstStreamRead(ChannelHandlerContext ctx, int streamId, long errorC

final ClosedStreamException cause =
new ClosedStreamException("received a RST_STREAM frame: " + Http2Error.valueOf(errorCode));
req.abortResponse(cause, /* cancel */ true);
if (req.needsAggregation() && !req.isInitialized()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (req.needsAggregation() && !req.isInitialized()) {
if (req.needsAggregation()) {
assert !req.isInitialized();

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't true because the server can receive an RST_STREAM after the aggregating request is initialized.

Copy link
Contributor

@ikhoon ikhoon Oct 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

request is initialized.

Q: Initialization seems more important to call req.toAbortedStreaming(). What do you think of using req.isInitialized() to check whether a call to req.toAbortedStreaming() is necessary for here and L335?

if (!req.isInitialized()) {
   ctx.fireChannelRead(req.toAbortedStreaming(inboundTrafficController, cause, false));
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good suggestion. Let me change it back. 😉

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original reason for my suggestion was because I wanted to do some testing on rescheduling the layer between channel and service event loops.

Let me just handle this separately then if needed

@minwoox
Copy link
Member Author

minwoox commented Oct 17, 2023

not to raise an aggregate exception.
Do you mind explaining what points raised an aggregate exception?

An AggregatingDecodedHttpRequest must be closed to be subscribed:

assert closed : getClass().getSimpleName() + " should be closed before publishing items";

However, we can just close it without fully receiving the request body.

Also, I think it will take too long for me to thoroughly review this PR.

Oh, please let me know if there're any points that you don't understand. 😉

Copy link
Member

@trustin trustin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Copy link
Contributor

@ikhoon ikhoon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall looks good.

@@ -394,7 +406,12 @@ public void onRstStreamRead(ChannelHandlerContext ctx, int streamId, long errorC

final ClosedStreamException cause =
new ClosedStreamException("received a RST_STREAM frame: " + Http2Error.valueOf(errorCode));
req.abortResponse(cause, /* cancel */ true);
if (req.needsAggregation() && !req.isInitialized()) {
Copy link
Contributor

@ikhoon ikhoon Oct 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

request is initialized.

Q: Initialization seems more important to call req.toAbortedStreaming(). What do you think of using req.isInitialized() to check whether a call to req.toAbortedStreaming() is necessary for here and L335?

if (!req.isInitialized()) {
   ctx.fireChannelRead(req.toAbortedStreaming(inboundTrafficController, cause, false));
}

final Http2Stream stream = encoder.connection().stream(streamId);

if (sendResetIfRemoteIsOpen && !stream.state().localSideOpen()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: Why is the local side open checked? sendResetIfRemoteIsOpen says reset if the remote is open.

Copy link
Member Author

@minwoox minwoox Oct 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I copied it from here and I think I also need to fix it. 😓
Let me run the CI a few more times.

// Send a RST_STREAM frame only for an active stream which did not send a RST_STREAM frame already.
if (stream != null && !stream.isResetSent()) {
return encoder.writeRstStream(ctx, streamId, error.code(), ctx.newPromise());
if (!sendResetIfRemoteIsOpen || stream.state().remoteSideOpen()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: Do we need to send RST_FRAME when half-close on remote? An EOF for the stream has been received from the remote peer already.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change not to send RST if EOF is received. 😉

Copy link
Contributor

@ikhoon ikhoon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for handling this non-trivial issue. 🙇‍♂️🙇‍♂️

Copy link
Contributor

@jrhee17 jrhee17 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only left a minor question but looks good 👍 Thanks @minwoox 🙇 👍 🙇

final HttpHeaders trailingHeaders = ((LastHttpContent) msg).trailingHeaders();
if (!trailingHeaders.isEmpty()) {
decodedReq.write(ArmeriaHttpUtil.toArmeria(trailingHeaders));
}
decodedReq.close();
if (decodedReq.needsAggregation()) {
assert !decodedReq.isInitialized();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this can be removed

Suggested change
assert !decodedReq.isInitialized();

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but let's leave it as is if you don't mind. 😉

@@ -394,7 +406,12 @@ public void onRstStreamRead(ChannelHandlerContext ctx, int streamId, long errorC

final ClosedStreamException cause =
new ClosedStreamException("received a RST_STREAM frame: " + Http2Error.valueOf(errorCode));
req.abortResponse(cause, /* cancel */ true);
if (req.needsAggregation() && !req.isInitialized()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original reason for my suggestion was because I wanted to do some testing on rescheduling the layer between channel and service event loops.

Let me just handle this separately then if needed

assert decodedReq.needsAggregation();
final StreamingDecodedHttpRequest streamingReq = decodedReq.toAbortedStreaming(
inboundTrafficController, httpStatusException, shouldReset);
ctx.fireChannelRead(streamingReq);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question) It seems like we don't early return when encoder is swapped to http2 and an upgrade request is received.

Is there a code point where we guard against ctx.fireChannelRead being invoked twice? (once for HTTP1 upgrade, once for HTTP2)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure. 😉
An UpgradeEvent is created when it receives the upgrade event:

final UpgradeEvent event = new UpgradeEvent(request);

Http2ConnectionHandler is added to the pipleline:
ctx.pipeline().addAfter(ctx.name(), null, connectionHandler);

Http1RequestDecoder receives the UpgradeEvent and calls channelRead() with the HTTP/1.1 request:

After handling the request, Http1RequestDecoder is removed:
final boolean endOfStream = msg instanceof LastHttpContent;
if (endOfStream && encoder instanceof ServerHttp2ObjectEncoder) {
// An HTTP/1 connection has been upgraded to HTTP/2.
ctx.pipeline().remove(this);
}

Copy link
Contributor

@jrhee17 jrhee17 Oct 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like we don't early return

My question was rather when ctx.pipeline().remove(this); is called, but the content length of the request is exceeded.

Let me do some testing in my local env. instead and follow up if needed, feel free to merge 😄

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I misunderstood it. 😓
No, I didn't consider that case and I have to fix it. Thanks!

@minwoox minwoox merged commit d398661 into line:main Oct 26, 2023
13 of 15 checks passed
@minwoox minwoox deleted the leak_contentTooLarge branch October 26, 2023 06:01
@minwoox
Copy link
Member Author

minwoox commented Oct 26, 2023

Thanks all for the review. 😉

Bue-von-hon pushed a commit to Bue-von-hon/armeria that referenced this pull request Nov 10, 2023
)

Motivation:
There are three bugs when the size of a request exceeds the max content length:
- The `ResponseHeaders` that are sent to the client and recorded to `RequestLog` are different.
- The `AggregatingDecodedHttpRequest` is not aborted thus `ByteBuf`s are leaked. (When HTTP/1.1 is used)
- The `AggregatingDecodedHttpRequest` does not get through the decorators so `LoggingService` doesn't log the exception.

Modifications:
- Send 413 responses using `HttpResponseSubscriber` instead of sending them from `HttpRequestDecoder` by aborting the response.
  - 413 is not a protocol error so we should send it from the `HttpResponseSubscriber`.
  - If a responseHeaders is already sent, the stream is reset. (the channel is closed if HTTP/1.1) 
- Call `ctx.fireChannelRead()` if the request wasn't handed over to `HttpServerHandler`.
  - This lets the `LoggingService` log the `ContentTooLargeException`.
  - `AggregatingDecodedHttpRequest` is converted to `StreamingDecodedHttpRequest` not to raise an aggregate exception.
- (misc) Raise a stream error instead of a connection error if it is stream level.

Result:
- Fix line#3803, line#5180
- No more leaks when the size of a request exceeds the max content length.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

When grpc streams requests over maxRequestLength, the request may not be handled
4 participants