Conversation
…peline on pooled channels NettyConnector created a new JerseyExpectContinueHandler for every request and waited on its latch, but the handler was added to the pipeline only when a new channel was created. On a channel from the pool the 100 Continue response was processed by the handler of the pipeline, which had no latch, so the wait ran into the timeout and the request was sent again without the Expect header. One JAX-RS call became two requests, the first one with an empty body. The connector now takes the handler of the pipeline for a channel that comes from the pool, resets it before the latch is attached, and resetHandler() clears the whole state of the handler, not only the latch. The new test sends four requests with Expect: 100-continue on one connection. Without the change the resource is invoked seven times for the four calls. Signed-off-by: abelet <26010730+abelet@users.noreply.github.com>
abelet
marked this pull request as draft
September 24, 2026 20:29
abelet
marked this pull request as ready for review
September 24, 2026 20:31
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
1 Summary
When the
Expect: 100-continuemechanism is used with chunked transfer encoding, the server receives two requests for one JAX-RS call: an empty one and the complete one.This happens for the following reason:
NettyConnectorcreates a newJerseyExpectContinueHandlerinstance for every request. If the request carries anExpect: 100-continueheader, the connector gives a latch to this instance and waits on that latch. The latch can be released by the handler that receives the100 Continueresponse. To receive the100 Continueresponse, the handler must be in the pipeline. But the handler is only added to the pipeline when a new channel is created. On a channel taken from the pool, the pipeline holds a different handler: the one that was created for the first request on that channel. That handler has no latch, so it releases nothing.As a result, every
Expect: 100-continuerequest sent on a reused channel waits until the timeout expires, even if the100 Continueresponse arrives immediately. The connector handles the timeout by writing an empty last chunk, so the server sees an empty finished request. After writing the last chunk, the connector sends the request again without theExpect: 100-continueheader, as a fallback. This is how one JAX-RS call becomes two requests.2 Environment
jersey-netty-connector), Netty 4.1.122.Final, JDK 213 Steps to reproduce
Conditions for reproducing the problem:
BUFFEREDmode theExpect: 100-continueheader is not put on the request, so the problem cannot be reproduced.The program below sends four PUT requests in a row to the same dedicated server. A parameter is used to control how the body is sent: chunked transfer encoding or a
Content-Lengthheader that is set by the caller. The first request creates a new channel; the others take a channel from the pool.The requests go to this JAX-RS endpoint:
The endpoint was deployed on Tomcat 11 and on Jetty 12, with the same result on both:
Content-Length(as in the code above)200 OKresponse, but requests 2–4 need about 500 ms more to be sent.REQUEST_ENTITY_PROCESSING=CHUNKED, without aContent-Lengthheader)With #6040 patched locally, setting 1 also causes data corruption, as the "Actual behaviour" section describes.
4 Expected behaviour
When the server sends a
100 Continueresponse, the connector should act on it and send the body of the request. This should happen for every request, not only for the first request on the channel.5 Actual behaviour
When the behaviour is examined in a dedicated environment, also on one thread and with one channel, the problem looks like a slowdown only: the first request is fast, but all the other requests take about 500 ms longer. (The 500 ms is not a delay in sending: it is the
EXPECT_100_CONTINUE_TIMEOUTexpiring.)What the server receives depends on the way the body is sent:
With chunked transfer encoding: The server receives the request twice (the first is empty).
For a request that is sent with a
Content-Lengthheader: The server receives the request data correctly due to another bug, described in #6040.With a
Content-Lengthheader, after #6040 is fixed: The server receives the repeated request line and headers as the first bytes of the body of the request. The end of the request body remains in the open socket and ends up at the beginning of the next request.6 Details of the problem
NettyConnector.executecreates aJerseyExpectContinueHandlerhandler for every request, but puts this handler into the pipeline with thep.addLast(EXPECT_100_CONTINUE_HANDLER, expect100ContinueHandler)call only when a new channel is created.For every request that carries an
Expect: 100-continueheader, the connector creates aCountDownLatchand attaches it (withattachCountDownLatch) to theJerseyExpectContinueHandlerhandler instance created for the request, and then it waits on that latch for the response. The connector reads the response (withprocessExpectationStatus) from the handler created for the request after the supposed release of the latch.When the channel is from the pool, the release of the latch never happens, because the response is processed by the handler in the pipeline, which did not get the latch. There are two handler instances: one is actually processing the data, and the other is used by the connector. Because the latch is never released, this leads to a timeout and a fallback behaviour in the connector: it resends the request without the
Expect: 100-continueheader. The exact behaviour depends on the way the body is sent:EMPTY_LAST_CONTENTelement to the channel. In theST_CONTENT_CHUNKstate it is encoded as a terminating chunk (0\r\n\r\n), so the server sees a finished request with an empty body: it processes the request and sends a response. The fallback request then arrives with the complete body, and the server processes it as a separate request. One JAX-RS call becomes two requests, and the server executes both of them.Content-Lengthheader: Due to #6040 theEMPTY_LAST_CONTENTwas not writen to the channel at the end of the previous request, so the encoder stayed in theST_CONTENT_NON_CHUNKstate. Because of this, the request line and the headers of theExpect: 100-continuerequest are not sent. When the timeout happens, the connector writes anEMPTY_LAST_CONTENTelement to the channel (fixing what #6040 left out), and it resets the state ofHttpClientCodec.EncodertoST_INIT. The fallback request is then sent correctly, only 500 ms later. The server sees one request, without theExpect: 100-continueheader.Content-Lengthheader, after #6040 is fixed: The request that carries theExpect: 100-continueheader is sent; the server answers with100 Continue, and then it waits for the body only. So it reads the request line and the headers from the fallback request as the first bytes of the original request's body. When the server has read the body up to the length inContent-Length, it leaves the remaining data in the socket. This data then ends up at the beginning of the next request, which the server either rejects or processes with an invalid method name.The incorrect behaviour was caused by the changes in PR #5847. Before that, the handler was also added to channels that come from the pool, with the
addLast(EXPECT_100_CONTINUE_HANDLER, expect100ContinueHandler)call.As a result of PR #5847, one handler is used during the whole lifetime of the channel: when it has nothing to do, it passes the messages on, but in the case of a
417or405response it has to follow the traffic until the end of the response. In the modified handler, theresetHandler()method makes it possible to use the same instance for the next request. However, for the channels taken from the pool, the handler in the channel pipeline is not used, andresetHandler()is not called. Instead, a new handler is created, and this is where the problem comes from.7 Possible fix
The problem can be fixed with a tiny change in three places. The fix is demonstrated on the 3.1.11 tag. The necessary changes are the following:
1. Creating the handler instance —
NettyConnector.javaline 258For channels from the pool, the handler in the channel pipeline must be used. A handler should be created only if there is no channel (
chan == null):2. Resetting the handler —
NettyConnector.javalines 502–503The reused handler instance holds a state that belongs to the processing of the previous request, so it has to be reset with the
resetHandler()call before the latch is attached:3. Making the reset complete —
JerseyExpectContinueHandler.javalines 129–131The
resetHandler()method should also clear thestatus, thecurrentStateand thepropagateLastMessageflag:Fixes #6125